Merge main into external import shadow fix

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-21 22:17:52 +02:00
88 changed files with 12430 additions and 1413 deletions
+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 }}
+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. |
+11
View File
@@ -537,6 +537,7 @@ PIPELINE_SRCS = \
src/pipeline/pass_compile_commands.c \
src/pipeline/pass_infrascan.c \
src/pipeline/pass_k8s.c \
src/pipeline/helm_template.c \
src/pipeline/pass_operator.c \
src/pipeline/pass_shell.c \
src/pipeline/pass_terraform.c \
@@ -1298,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
+100 -51
View File
@@ -1,9 +1,9 @@
# codebase-memory-mcp: local MCP server for code search, lower token usage and visualization
# 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,33 +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)
**Search your code, follow call graphs, and explore software architecture in your browser.** codebase-memory-mcp is a local [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that gives Claude Code, Codex, and other coding agents a persistent code knowledge graph. It indexes functions, classes, imports, HTTP routes, and their relationships across [162 languages](#language-support). [Hybrid LSP](#hybrid-lsp) adds semantic type resolution for **Python, TypeScript, JavaScript, JSX/TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl**.
**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.
**About 120× fewer tokens** in the project's reported five-query comparison: ~3,400 vs ~412,000 with file-by-file exploration. Published backend benchmarks also report **Linux kernel indexing in ~3 minutes** (28M lines of code) and **Cypher relationship queries under 1 ms** on an Apple M3 Pro. [Benchmark details](#performance).
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.
**CBM Lens Beta helps you understand unfamiliar code and stay in control as it grows.** Use it to get up to speed in a new codebase, tackle rescue engineering on an inherited project, or make sense of 50,000 lines of AI-generated code from a single day. Its IDE-style source reader, local code chat, 3D architecture map, and Galaxy graph let you see how the code fits together and follow dependencies and call paths back to the source. Use that context to review changes, investigate fragile areas, and keep code quality high. Architecture decisions stay alongside the project's index.
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).
The native C backend exposes 17 MCP tools and supports 45 automatic or conditional client integrations. Indexing and graph queries need no hosted service or API key.
> **CBM Lens Beta is new on `main`:** it arrived with [PR #2068](https://github.com/DeusData/codebase-memory-mcp/pull/2068) and is not part of the current v0.11.0 release, whose installer still ships the previous graph UI. To run the interface shown below now, [build from source](#build-from-source).
[Quick start](#quick-start) · [Explore CBM Lens](#cbm-lens-beta-code-visualization-and-source-navigation) · [Chat with your codebase locally](#chat-with-your-codebase-locally) · [MCP tools](#mcp-tools) · [Build from source](#build-from-source)
> **Research:** The [preprint](https://arxiv.org/abs/2603.27277) compares agents across 31 repositories, reporting **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 backend results are separate from the five-query comparison above and from CBM Lens.
[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).
@@ -58,27 +53,27 @@ The native C backend exposes 17 MCP tools and supports 45 automatic or condition
</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
- **Indexing performance**: Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
- **Plug and play**: native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
- **162 languages**: vendored tree-sitter grammars compiled into the binary. No separate parser installation.
- **About 120× fewer tokens in the reported five-query comparison**: ~3,400 tokens vs ~412,000 via file-by-file search. See [Performance](#performance) for the scope and sources.
- **45 supported automatic/conditional client surfaces**: `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
- **CBM Lens Beta**: read source, chat about selected code with a local model, explore architecture and call graphs, inspect coverage gaps, and edit architecture decision records in your browser.
- **Infrastructure-as-code indexing**: Dockerfiles, container images, docker compose, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds and compose services, `Image` nodes joining the images that are built and run, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources; a Service walks to the program its container runs.
- **17 MCP tools**: search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more.
- **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.
- **Explore visually with CBM Lens Beta**: read source, follow call paths, inspect architecture and coverage gaps, manage architecture decisions, and ask a local model about selected code.
- **Equip your coding agents**: `install` sets up supported client configuration, durable guidance, skills, Scout/Verify/Auditor profiles, and applicable context hooks. Support varies by client; see the [integration matrix](#multi-agent-support).
- **Keep context local and reusable**: indexes persist between sessions, eligible active MCP sessions can keep them synchronized, and optional graph artifacts let a team share a baseline.
- **Run a native tool on your machine**: standard releases include CBM Lens for macOS, Linux, and Windows. The native runtime needs no Docker or language runtime; parser grammars are included.
## Quick Start
**Install the latest release** (macOS / Linux). For CBM Lens Beta shown above, [build from source](#build-from-source) until the next release.
**Install the latest release** (macOS / Linux):
```bash
curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
```
The released graph UI is included in the standard installer.
The standard native release includes CBM Lens. Start it after installation with `codebase-memory-mcp daemon start --open --port=9749`. The [Nix packages](#nix-flake) and [source builds](#build-from-source) also offer builds without the UI.
**Windows** (PowerShell):
```powershell
@@ -136,7 +131,19 @@ Restart your coding agent. Say **"Index this project"**: done.
The `install` command automatically strips macOS quarantine attributes and ad-hoc signs the binary; no manual `xattr`/`codesign` needed.
</details>
The `install` command auto-detects installed coding agents and configures their documented MCP entries plus durable instructions, skills, and lifecycle hooks where supported.
### Agent setup: skills, profiles, and hooks
Installation also equips supported coding agents to use the graph in daily work. The native `install` command detects clients and installs the integration pieces their documented formats support:
| Component | What it adds |
|-----------|--------------|
| **MCP configuration** | Connects the client to the local server and its tools. |
| **Instructions and skills** | Explains graph-first exploration, index freshness, coverage checks, and when to read source. |
| **Scout, Verify, and Auditor profiles** | Offers fast provisional lookup, task-directed verification, or bounded full verification, where custom agents are supported. |
| **Session and compaction hooks** | Restores graph context at supported lifecycle events. |
| **Search and read hooks** | Adds relevant graph context to supported searches and flags recorded coverage gaps after supported file reads. |
Hook augmentation is context-only and fail-open: it does not replace or block the requested tool call. Features vary by client and platform; some clients use durable instructions or a parent-agent handoff instead of hooks or direct subagent MCP access. Codex users review and trust installed hooks through `/hooks`. See [Multi-Agent Support](#multi-agent-support) and [Sessions, compaction, and subagents](#sessions-compaction-and-subagents) for exact support and activation requirements.
### Session Coordination Daemon
@@ -154,18 +161,18 @@ 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 built into the binary. It builds on [CodeAtlasWeb](graph-ui/README.md) by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) (design and tracking in #1964). It runs on loopback; the only request that leaves your machine is the optional model download from Hugging Face, and only after you start it. 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
./build/c/codebase-memory-mcp daemon start --open --port=9749
codebase-memory-mcp daemon start --open --port=9749
```
The browser opens at `http://127.0.0.1:9749`. A permanent daemon keeps the UI available after the command exits. Use `daemon status` to inspect it and `daemon stop` when you are finished. For an MCP session, `--ui=true --port=9749` enables the same shared UI. Active clients must use the same executable build and cache directory.
@@ -209,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.
@@ -232,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>
@@ -246,7 +253,7 @@ Enable automatic indexing on MCP session start:
codebase-memory-mcp config set auto_index true
```
When enabled, new projects are indexed automatically on first connection. Previously-indexed projects are registered with the background watcher for ongoing git-based change detection. Configurable file limit: `config set auto_index_limit 50000`.
`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.
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.
@@ -267,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.
@@ -299,9 +306,10 @@ The install script placed beside the binary is **reported, not deleted**: uninst
### Graph & analysis
- **Architecture overview**: `get_architecture` returns a compact summary by default. Request specific `aspects` for routes, hotspots, dependencies, clusters, or cycles. `system_structure` provides the projection used by CBM Lens and must be requested on its own.
- **Architecture Decision Records**: `manage_adr` persists architectural decisions across sessions
- **Architecture Decision Records**: `manage_adr` persists the project's decision record across sessions; indexing also extracts supported ADR metadata and supersession from documentation files
- **Louvain community detection**: Discovers functional modules by clustering call edges
- **Git diff impact mapping**: `detect_changes` maps uncommitted changes to affected symbols with risk classification
- **Change impact**: `detect_changes` maps uncommitted changes to affected symbols with risk classification and can report test impact with `scope: "tests"`; `compare_graphs` compares indexed snapshots
- **Runtime observations**: `ingest_traces` adds observed call relationships from separately collected traces alongside static analysis; static call paths alone do not show what ran
- **Call graph**: Resolves function calls across files and packages (import-aware, type-inferred)
- **Dead code candidates**: find functions with no indexed callers, excluding entry points. Check dynamic dispatch, coverage gaps, and framework conventions before removing code.
- **Cypher-like queries**: `MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'main' RETURN g.name`
@@ -312,6 +320,27 @@ The install script placed beside the binary is **reported, not deleted**: uninst
- **Structural search** (`search_graph`): regex name patterns, label filters, min/max degree, file scoping
- **Code search** (`search_code`): graph-augmented grep over indexed files only
### Documentation connected to code
Find the explanation behind a symbol and follow supported references back to its implementation. Documentation participates in the graph alongside source files:
- **Markdown, reStructuredText, and AsciiDoc** become navigable document sections; **PDFs with text layers** become searchable text pages. PDF extraction does not perform OCR.
- **Document-to-code references** and supported language doc-comment references create `MENTIONS` relationships when the target can be resolved.
- **Architecture decision records** contribute metadata such as status, deciders, and supersession, so decisions can be explored with the code they describe.
- **Supported diagrams and OpenAPI descriptions** contribute structured entities. Extraction and reference resolution differ by format; not every textual mention becomes a graph edge.
Use graph and text search to locate the relevant section, follow its recorded relationships, and open the source evidence. Check `check_index_coverage` before treating a missing result as proof that the repository has no such documentation or reference.
### Infrastructure and deployment
Ask **"What deploys this service?"**, **"Which image runs it?"**, or **"Where does this configuration reach code?"** CBM connects supported infrastructure forms to the source they build, configure, and run:
- **Containers and clusters**: Dockerfiles, Compose services, Kubernetes resources, Helm charts and releases, and Kustomize overlays and patches.
- **Provisioning and configuration**: Terraform/OpenTofu resources and modules, tfvars overrides, Terragrunt dependencies, Ansible tasks and handlers, and supported configuration keys and environment-variable access.
- **Build and delivery**: shell commands, Make targets, and supported CI steps that build images, invoke repository scripts, or deploy resources.
For resolvable inputs, a trace can follow **Service → workload → image → Dockerfile → program**, or follow deployment and configuration relationships. Use `trace_path` modes for topology, deployment, and run paths. These are static relationships from supported source forms; dynamic values and unresolved templates can leave gaps. See [Edge types](#edge-types-selected) for the exact relationship rules and limitations.
### Cross-service linking
- **HTTP** route ↔ call-site matching with confidence scoring
- **gRPC, GraphQL, tRPC** service detection with protobuf Route extraction
@@ -323,6 +352,21 @@ The install script placed beside the binary is **reported, not deleted**: uninst
- **Run status**: `index_status` (and the UI layout next to `linked_projects`) carries a `cross_repo` object, `{"status":"never_run"}` until `index_repository(mode="cross-repo-intelligence")` has run with that project as the source, then `"ran"` with `last_run_at`, `outcome`, `targets`, `projects_scanned`, `total_cross_edges` and `skipped_projects`. That is how you tell "never linked" apart from "linked, nothing matched". The status is stored in the project's own index next to its `CROSS_*` edges, so a reindex that rebuilds the index drops both at once and the status goes back to `never_run`.
- **Skipped stores**: `target_projects: ["*"]` skips an index built before the #768 schema change rather than failing the whole run. The response lists each one as `skipped_projects: [{"name", "reason": "pre_768_schema", "hint": "reindex this project"}]`.
### Smart CI and test impact
Use the graph to explain which tests a change reaches, and use that evidence to focus supported CI runs. **Smart CI is experimental** and has two layers:
| Layer | What it does | What you supply |
|-------|--------------|-----------------|
| **CI job selection** | Chooses jobs from the changed-file list. | Repository-specific path and job rules; this repository's selector is an example. |
| **Test selection** | Combines static reachability, changed tests, and admitted per-test coverage to select suites or tests. | A supported C/C++ test harness, configuration, a coverage map, and a runner that accepts named selections. |
`codebase-memory-mcp test-impact select` produces a selection with reasons; `test-impact publish` produces the graph-and-coverage bundle used as its baseline. For agent-assisted investigation, `detect_changes` with `scope: "tests"` exposes test impact through MCP.
**Narrowing currently supports C/C++ family code with this repository's test conventions or declared equivalents.** It is not general test selection for every language CBM indexes. Missing, stale, incompatible, or incomplete evidence broadens the selection or returns `run_all`; static reachability without admitted coverage never omits a suite. Begin with report-only shadow mode and validate your harness before enabling gating.
In this repository, test narrowing is wired to the Linux x86_64 test leg when `TEST_IMPACT_MODE=gate`; release and dry-run workflows retain full execution. See the [Smart CI guide](docs/SMART_CI.md) for supported conventions, coverage admission, setup, version requirements, and fallback behavior.
### Edge types (selected)
- `CALLS`: a callable is invoked at the source site
- `CALL_REFERENCE`: a callable is used at a supported reference site (for example, a direct value argument) and resolves to one exact target
@@ -333,6 +377,7 @@ The install script placed beside the binary is **reported, not deleted**: uninst
- `INFRA_MAPS`: a Kubernetes Service to each workload whose pod labels carry every `key: value` of the Service's selector, within one namespace
- `CONNECTS_TO`: a Kubernetes workload or compose service to the Service its containers' environment addresses (`PRODUCT_CATALOG_SERVICE_ADDR: productcatalogservice:3550`, a URL's host, `<svc>.<ns>.svc...`, a bare name on an `*_ADDR` / `*_HOST` / `*_URL` / ... key; compose: the project's `.env` interpolated): a Kubernetes Service of the same bundle and namespace, or a compose service of the same file. The edge keeps `via: env`, `key` and `container`, never the value
- `USAGE` with `ref: image`: a Kubernetes workload (any pod template: containers, initContainers, CronJobs, custom resources) or a docker compose service to the `Image` it runs
- Helm templates are rendered for their images: with the chart's values (the `Chart` `DEPLOYS` them, `default_of`: the values keys read) and with each release's (a Flux HelmRelease, a kustomize `helmCharts` entry, a Terraform `helm_release`: `override_of`). An image the render cannot know is left out, never guessed: after a change of values it does not run exactly, a `tpl` or include it cannot look into, a release value it cannot read, or a work budget it runs past. Known limit: each template file renders on its own, while Helm renders a chart's files with one shared `.Values` (a subchart's is its parent's `.Values.<alias>`), so a chart that changes `.Values` in one file and reads it in another can still show a wrong image
- `DEPLOYS`: what puts manifests, kustomizations or charts on a cluster, to what it puts there: a Flux Kustomization or Argo Application to the kustomizations / manifests / chart of its path, a Flux HelmRelease to its chart (`spec.chart` or `chartRef` → OCIRepository), a shell command or Make recipe (`kubectl apply -f/-k`, `kustomize build | kubectl apply`, `helm install/upgrade/template`), a Go installer loading a chart with the Helm SDK, a Terraform `helm_release`, a Terraform `kubernetes_*` resource the object it declares (a `Resource` like a manifest's, `ecosystem: terraform`, when its name is a literal), a kustomization inflating a chart (`helmCharts`; its values file an `IMPORTS`). A chart outside the repository is an external `Chart`, keyed by its publisher when its repository URL is stated (`__chart__<repository>/<name>`, the same node in every project, the URL in `repository`), else one per project and name; the edge says `via` (`flux`, `argo`, `helmrelease`, `kubectl`, `kustomize`, `helm`, `helm-sdk`, `terraform`, `helmcharts`, ...)
- `DEPENDS_ON`: a Helm chart to the charts it declares (the local chart for a resolvable `file://` repository, otherwise an external `Chart`; the edge keeps `repository`), and a `go.mod` / `requirements.txt` file to an external `Package`; a Dockerfile to the `Image`s it builds on (`via: from`, `copy-from`); an `Image` to the Dockerfile it is built from (`via: build`; `tool`: `skaffold`, `compose`, `docker-build` for `docker` / `podman` / `nerdctl` / `buildah` build commands in scripts, Make and CI); a compose service to the services its `depends_on` / `links` name
- `CONFIGURES`: code reading an environment variable to the shared `EnvVar` of that name (`strategy: env_access`); a Kubernetes container, compose service or Dockerfile (`ENV` of the image) setting one to that container's own `EnvVar` (`strategy: env_set`, with `source`: `value`, `configMapKeyRef`, `secretKeyRef`, `fieldRef`, ..., `compose`, `dockerfile`; values are never stored); and that container's `EnvVar` to the code reading the name inside the files its image is built from (`strategy: env_read`, with `dockerfile`): the image's files are the sources its Dockerfile's `COPY` / `ADD` put into it, less the context's `.dockerignore`
@@ -423,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**: 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. That is approximately **120× fewer tokens**, or a **99.2% reduction**, for that comparison. The separate [31-repository study](https://arxiv.org/html/2603.27277v1#S4.SS1) reports a 10× token reduction. Its [system-performance measurements](https://arxiv.org/html/2603.27277v1#S4.SS3) include Linux kernel indexing in about 3 minutes and Cypher relationship queries under 1 ms on an Apple M3 Pro.
**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
@@ -570,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
@@ -626,7 +671,7 @@ Restart your agent. Verify with `/mcp`; you should see `codebase-memory-mcp` wit
## Multi-Agent Support
`install` configures 45 client surfaces: 39 detected automatically and 6
`install` configures **45 supported automatic/conditional client surfaces**: 39 detected automatically and 6
conditional or explicit. “Conditional” means the installer writes only when the
documented platform or an explicit, already-existing config path proves the
target is active. It never flips experimental feature flags, enables plugins,
@@ -820,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
@@ -829,7 +874,7 @@ 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. |
| `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. |
@@ -945,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.
@@ -975,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
@@ -1067,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
@@ -1086,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
+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

+447 -782
View File
File diff suppressed because it is too large Load Diff
+37 -14
View File
@@ -1,26 +1,49 @@
# codebase-memory-mcp
> An open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query the graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens (~3,400 vs ~412,000 across five structural queries). Parses 162 languages via vendored tree-sitter grammars, with Hybrid LSP semantic type resolution for Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust. It also generates semantic graph edges (SEMANTICALLY_RELATED for vocabulary-mismatch matches, SIMILAR_TO for near-clone detection) and supports semantic vector search via bundled nomic-embed-code embeddings compiled into the native executable — no API key, no Ollama, no Docker. Ships a native executable with an authenticated integration asset; the UI variant adds one content-addressed frontend pack. All processing is local — the embeddings run on-device; there is no embedded LLM and no API key.
> 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.
## Key facts
## Capabilities
- Code exploration: symbol and structural search, BM25 text search, semantic search, source snippets and file outlines, call paths, data flow, architecture summaries, hotspots, cycles, and coverage diagnostics. The server registers 17 MCP tools.
- Semantic and similarity relationships: bundled embeddings support natural-language discovery; SEMANTICALLY_RELATED edges connect vocabulary-mismatch matches, and SIMILAR_TO edges expose near-clone candidates.
- Languages and formats: vendored parsers cover source, documentation, and configuration formats. Hybrid LSP adds semantic type resolution for Python, TypeScript/JavaScript/JSX/TSX, PHP, C#, Go, C/C++, Java, Kotlin, Rust, and Perl. Support and resolution depth differ by language; use the README language matrix and check_index_coverage for the indexed project.
- Documentation connected to code: Markdown, reStructuredText, and AsciiDoc sections; PDF text pages (no OCR); supported document and language doc-comment references to code; ADR metadata and supersession. Supported diagrams and OpenAPI descriptions contribute entities, with format-specific reference-resolution limits.
- Infrastructure and deployment: Docker/Compose, Kubernetes, Helm, Kustomize, Terraform/OpenTofu, Terragrunt, Ansible, shell, Make, and supported CI relationships. Trace deployments, images, entrypoints, configuration keys, and environment-variable access back to source when inputs can be resolved statically.
- Cross-service and cross-repository relationships: supported HTTP, asynchronous messaging, gRPC, GraphQL, and tRPC links. Cross-repo analysis explicitly links projects indexed in the same store; missing links do not prove that no runtime relationship exists.
- 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 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
- License: MIT, open source.
- Languages: 158 (158 vendored tree-sitter grammars compiled into the binary).
- Hybrid LSP type resolution: 9 language families (Python, TypeScript/JavaScript/JSX/TSX, PHP, C#, Go, C/C++, Java, Kotlin, Rust) — a lightweight C implementation of language type-resolution algorithms, structurally inspired by and compatible with major language servers including tsserver, pyright, gopls, Roslyn, Eclipse JDT, and rust-analyzer.
- MCP tools: 15 (search_graph incl. semantic_query vector search, trace_path (alias: trace_call_path), check_index_coverage, query_graph (Cypher), detect_changes, get_architecture, get_code_snippet, manage_adr, and more).
- Semantic search: natural-language code discovery via bundled nomic-embed-code embeddings (768-dim, compiled into the binary); 11-signal combined scoring; fully local, no API key.
- Semantic & similarity edges: SEMANTICALLY_RELATED (vocabulary-mismatch matches) and SIMILAR_TO (MinHash + LSH near-clone / duplicate detection).
- Cross-repo intelligence: CROSS_* edges link nodes across multiple repos indexed in one store; multi-galaxy 3D layout and cross-repo architecture summary.
- Cross-service linking: HTTP route ↔ call-site matching, plus gRPC/GraphQL/tRPC detection and pub/sub channels (EMITS/LISTENS_ON for Socket.IO, EventEmitter, generic buses).
- Supported agents: 45 automatic/conditional client surfaces (39 automatically detected + 6 conditional/explicit): Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Continue / cn, Visual Studio, TRAE, Roo Code, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob IDE, IBM Bob Shell, Pochi, Pi, Oh My Pi (omp), and Sourcegraph Cody.
- Agent profiles: documented custom-agent formats receive Scout (fast/provisional), Verify (default/task-directed), and Auditor (bounded/full verification) definitions. Every direct tier checks exact path/scope coverage with check_index_coverage and falls back to source for flagged gaps; unsafe child-MCP formats use explicit parent handoff. Kiro and Junie use positive-allowlist Scout/Analysis server profiles (7/11 tools); Qoder combines named-server selection with exact tier-specific MCP tool IDs, while Factory uses exact registered IDs without additive whole-server exposure. Foreign Junie aliases are preserved and force parent handoff.
- Performance: Linux kernel (28M LOC, 75K files) full index in 3 minutes → 4.81M nodes, 7.72M edges; Cypher queries in under 1ms.
- Distribution: verified runtime set (native executable plus authenticated release-owned assets); fully static Linux builds are available in the `-portable` archives. Also available through npm, PyPI, Homebrew, Scoop, AUR, and `go install`.
## Measurement and limits
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
- Homepage: https://deusdata.github.io/codebase-memory-mcp/
- Source code (GitHub): https://github.com/DeusData/codebase-memory-mcp
- Source and feature reference: https://github.com/DeusData/codebase-memory-mcp
- Latest release: https://github.com/DeusData/codebase-memory-mcp/releases/latest
- Quick start and installation: https://github.com/DeusData/codebase-memory-mcp#quick-start
- Documentation indexing: https://github.com/DeusData/codebase-memory-mcp#documentation-connected-to-code
- Infrastructure and deployment: https://github.com/DeusData/codebase-memory-mcp#infrastructure-and-deployment
- CBM Lens: https://github.com/DeusData/codebase-memory-mcp#cbm-lens-beta-code-visualization-and-source-navigation
- Agent integrations: https://github.com/DeusData/codebase-memory-mcp#multi-agent-support
- 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
- 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>
+5 -1
View File
@@ -734,7 +734,9 @@ typedef struct {
* from the URL `name`; a HelmRelease whose chartRef names it deploys the
* chart there.
* CBM_K8S_FACT_IMAGE: object def_index runs its container `container`
* (NULL: unnamed) from image `name` (a literal reference); via "container".
* (NULL: unnamed) from image `name` (a literal reference, or a Helm
* template's placeholder `<...>`, which names none); via "container";
* `line` the image's line, start_line / end_line the container's.
* CBM_K8S_FACT_ENV: object def_index sets environment variable `name` in
* its container `container` (NULL: unnamed) at `line`; via = where the
* value comes from ("value", or the valueFrom kind) -- never the value.
@@ -773,6 +775,8 @@ typedef struct {
const char *url; /* DEPLOY (kustomize): the chart repository */
const char *release; /* DEPLOY (kustomize): the release name */
int line;
int start_line; /* IMAGE: the container mapping's first line */
int end_line; /* IMAGE: its last line */
} CBMK8sFact;
typedef struct {
+30
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);
+520 -11
View File
@@ -484,12 +484,32 @@ static void parse_python_imports(CBMExtractCtx *ctx) {
ts_tree_cursor_delete(&cursor);
}
/* A proven same-module function (its OWN_DEF fact and definition), and
* whether a top-level call of it leaves the namespace intact (see
* py_ns_allowed_call). */
typedef enum {
PY_NS_CALL_UNCHECKED = 0,
PY_NS_CALL_ALLOWED = 1,
PY_NS_CALL_EFFECT = 2,
} PyNsCallVerdict;
typedef struct {
int fact;
TSNode node;
PyNsCallVerdict verdict;
const char **shadows; /* submodules the body imports into the package */
int shadow_count;
} PyNsFunction;
/* Ordered Python namespace facts are a separate content-only pass. The older
* import records keep their existing graph/heuristic meaning. */
typedef struct {
CBMExtractCtx *ctx;
CBMPyNamespaceFacts facts;
CBMPyNamespaceFailure failure;
PyNsFunction *functions; /* in fact order; scratch, never part of the result */
int function_count;
int function_cap;
} PyNamespaceBuilder;
#ifdef CBM_ENABLE_TEST_SEAMS
@@ -516,6 +536,18 @@ static void *py_ns_alloc(PyNamespaceBuilder *b, size_t size) {
return p;
}
/* Builder bookkeeping nothing in the result points at: the extraction scratch
* when the context has one. */
static void *py_ns_scratch_alloc(PyNamespaceBuilder *b, size_t size) {
if (b->failure)
return NULL;
CBMArena *arena = b->ctx->scratch ? b->ctx->scratch : b->ctx->arena;
void *p = cbm_arena_alloc(arena, size);
if (!p)
b->failure = CBM_PY_NS_FAILURE_ALLOCATION;
return p;
}
static const char *py_ns_copy(PyNamespaceBuilder *b, const char *text, size_t len) {
if (len == SIZE_MAX) {
b->failure = CBM_PY_NS_FAILURE_LIMIT;
@@ -1024,6 +1056,477 @@ static bool py_ns_future_import(PyNamespaceBuilder *b, TSNode node) {
return ok && emitted > 0;
}
/* ── A top-level call the namespace survives ──────────────────────────────
* Any top-level call may rebind any name of the module, so it withdraws the
* whole namespace (UNKNOWN, reason EFFECT). sqlalchemy's sql/__init__.py ends
* with `__go(locals())`, which withdrew all of its 108 re-exports. A call is
* allowed when its callee provably cannot rebind the caller's names:
* - the callee is the latest binding of its name, a proven (undecorated)
* same-module function, with no star import or unknown effect since;
* - every argument is a safe expression or a bare `locals()` / `globals()`
* the module does not rebind;
* - the body, nested scopes included, has no `global` / `nonlocal` and never
* names a parameter, a function or class this module defined earlier (it
* could rebind through `global`), the module's own name, or a name that
* reaches a namespace (py_ns_namespace_names).
* The body may import: each submodule of this package it imports becomes a
* package attribute, so in a package `__init__` those names are shadowed at
* the call. Every other call stays a whole-namespace effect. */
static const char *const py_ns_namespace_names[] = {
"globals", "vars", "locals", "exec", "eval", "setattr", "delattr",
"__import__", "sys", "importlib", "__name__", "__dict__", "__globals__", "__builtins__",
"builtins", "__self__", "inspect", "f_globals", "f_locals", "f_back", "gc",
};
typedef struct {
const char *text;
size_t len;
} PyNsSlice;
typedef struct {
PyNamespaceBuilder *b;
PyNsSlice own;
bool package;
PyNsSlice *params;
int param_count;
const char **defs; /* earlier same-module functions and classes, sorted */
int def_count;
const char **shadows;
int shadow_count;
int shadow_cap;
} PyNsCallCheck;
static bool py_ns_text_is(const char *text, size_t len, const char *s) {
return strlen(s) == len && memcmp(text, s, len) == 0;
}
/* strcmp of a NUL-terminated string against a slice. */
static int py_ns_slice_cmp(const char *s, const char *text, size_t len) {
int r = strncmp(s, text, len);
if (r != 0)
return r;
return s[len] ? 1 : 0;
}
static int py_ns_cmp_cstr(const void *a, const void *b) {
return strcmp(*(const char *const *)a, *(const char *const *)b);
}
/* `pkg/sub/__init__.py` -> "sub", a package; `pkg/mod.py` -> "mod". */
static bool py_ns_own_name(const char *rel_path, PyNsSlice *own, bool *package) {
if (!rel_path)
return false;
const char *slash = strrchr(rel_path, '/');
const char *file = slash ? slash + 1 : rel_path;
const char *ext = strrchr(file, '.');
if (!ext || ext == file)
return false;
size_t stem = (size_t)(ext - file);
*package = py_ns_text_is(file, stem, "__init__");
if (!*package) {
*own = (PyNsSlice){file, stem};
} else {
if (!slash)
return false;
const char *dir = slash;
while (dir > rel_path && dir[-1] != '/')
dir--;
*own = (PyNsSlice){dir, (size_t)(slash - dir)};
}
return py_ns_identifier_bytes(own->text, own->len, false);
}
static bool py_ns_reaches_namespace(const char *text, size_t len) {
for (size_t i = 0; i < sizeof(py_ns_namespace_names) / sizeof(py_ns_namespace_names[0]); i++) {
if (py_ns_text_is(text, len, py_ns_namespace_names[i]))
return true;
}
return false;
}
/* A name of the body (not a module path) the call cannot allow. */
static bool py_ns_forbidden_name(const PyNsCallCheck *c, const char *text, size_t len) {
if (py_ns_reaches_namespace(text, len) ||
(len == c->own.len && memcmp(text, c->own.text, len) == 0))
return true;
for (int i = 0; i < c->param_count; i++) {
if (len == c->params[i].len && memcmp(text, c->params[i].text, len) == 0)
return true;
}
int lo = 0, hi = c->def_count - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int r = py_ns_slice_cmp(c->defs[mid], text, len);
if (r == 0)
return true;
if (r < 0)
lo = mid + 1;
else
hi = mid - 1;
}
return false;
}
static bool py_ns_node_forbidden(const PyNsCallCheck *c, TSNode node) {
const char *text = NULL;
size_t len = 0;
return ts_node_is_null(node) || !py_ns_slice(c->b, node, &text, &len) ||
py_ns_forbidden_name(c, text, len);
}
static bool py_ns_add_shadow(PyNsCallCheck *c, const char *text, size_t len) {
if (!py_ns_identifier_bytes(text, len, false))
return false;
if (c->shadow_count == c->shadow_cap) {
int cap = c->shadow_cap ? c->shadow_cap * 2 : 8;
const char **items = py_ns_scratch_alloc(c->b, (size_t)cap * sizeof(*items));
if (!items)
return false;
if (c->shadow_count)
memcpy(items, c->shadows, (size_t)c->shadow_count * sizeof(*items));
c->shadows = items;
c->shadow_cap = cap;
}
const char *name = py_ns_copy(c->b, text, len);
if (!name)
return false;
c->shadows[c->shadow_count++] = name;
return true;
}
/* The segments of a dotted module path: none may reach a namespace. Sets
* *own_at to the index of the segment naming this module (-1: none) and
* *next to the slice after it (empty when it is the last segment). */
static bool py_ns_check_path(const PyNsCallCheck *c, const char *text, size_t len, int *own_at,
PyNsSlice *next) {
*own_at = -1;
*next = (PyNsSlice){NULL, 0};
int index = 0;
size_t start = 0;
while (start <= len && len > 0) {
size_t end = start;
while (end < len && text[end] != '.')
end++;
const char *seg = text + start;
size_t seg_len = end - start;
if (py_ns_reaches_namespace(seg, seg_len))
return false;
if (*own_at >= 0 && *own_at == index - 1)
*next = (PyNsSlice){seg, seg_len};
if (*own_at < 0 && seg_len == c->own.len && memcmp(seg, c->own.text, seg_len) == 0)
*own_at = index;
index++;
start = end + 1;
}
return true;
}
/* The relative level of a `from` module path: its leading dots. */
static size_t py_ns_leading_dots(const char *text, size_t len) {
const char *end = text + len;
const char *p = text;
while (p != end && *p == '.')
p++;
return (size_t)(p - text);
}
/* An import in the callee's body. When its module is this package, the
* submodule it installs as a package attribute is shadowed: the first
* segment after the package, or every imported member when the import names
* the package itself. A relative import of a package `__init__` is relative
* to the package (level 1) or to its parent (level 2, whose first segment
* then names this package or a sibling). Deeper relative imports that name
* this module cannot be placed and are refused. The names an import binds are
* checked like every name of the body; this module's own name is allowed in a
* module path only. */
static bool py_ns_check_import(PyNsCallCheck *c, TSNode node, bool from) {
TSNode module = from ? ts_node_child_by_field_name(node, TS_FIELD("module_name")) : (TSNode){0};
bool into_package = false; /* the import's module is this package */
PyNsSlice sub = {NULL, 0}; /* the submodule after the package, if any */
if (from) {
const char *text = NULL;
size_t len = 0;
if (!py_ns_slice(c->b, module, &text, &len))
return false;
size_t level = py_ns_leading_dots(text, len);
PyNsSlice rest = {text + level, len - level};
if (rest.len && !py_ns_identifier_bytes(rest.text, rest.len, true))
return false;
int own_at = -1;
PyNsSlice next;
if (!py_ns_check_path(c, rest.text, rest.len, &own_at, &next))
return false;
if (c->package && level == 1) {
/* `from . import x` names the package; `from .x.y import z` installs x */
const char *dot = rest.len ? memchr(rest.text, '.', rest.len) : NULL;
into_package = rest.len == 0;
sub = (PyNsSlice){rest.text, dot ? (size_t)(dot - rest.text) : rest.len};
} else if (c->package && (level == 0 || (level == 2 && own_at == 0)) && own_at >= 0) {
/* level 2: the parent's child of this name is this package; a
* later segment of the same name is a package further down */
if (next.len)
sub = next;
else
into_package = true;
} else if (c->package && level > 2 && own_at >= 0) {
return false;
}
if (sub.len && !py_ns_add_shadow(c, sub.text, sub.len))
return false;
}
TSTreeCursor cursor = ts_tree_cursor_new(node);
bool ok = true;
if (ts_tree_cursor_goto_first_child(&cursor)) {
do {
TSNode child = ts_tree_cursor_current_node(&cursor);
if (!ts_node_is_named(child) || ts_node_eq(child, module) ||
strcmp(ts_node_type(child), "comment") == 0)
continue;
const char *kind = ts_node_type(child);
bool alias = strcmp(kind, "aliased_import") == 0;
if (!alias && strcmp(kind, "dotted_name") != 0) {
ok = false; /* a star import, or a form this check does not read */
break;
}
TSNode name = alias ? ts_node_child_by_field_name(child, TS_FIELD("name")) : child;
const char *text = NULL;
size_t len = 0;
if (!py_ns_slice(c->b, name, &text, &len)) {
ok = false;
break;
}
if (alias &&
py_ns_node_forbidden(c, ts_node_child_by_field_name(child, TS_FIELD("alias")))) {
ok = false;
break;
}
if (from) {
/* a member is a name the import binds (or a submodule) */
if (py_ns_forbidden_name(c, text, len) ||
(into_package && !py_ns_add_shadow(c, text, len))) {
ok = false;
break;
}
continue;
}
int own_at = -1;
PyNsSlice next;
if (!py_ns_identifier_bytes(text, len, true) ||
!py_ns_check_path(c, text, len, &own_at, &next) ||
(alias && own_at >= 0 && !next.len)) {
ok = false; /* `import pkg.own as alias` hands out this module */
break;
}
if (c->package && own_at >= 0 && next.len &&
!py_ns_add_shadow(c, next.text, next.len)) {
ok = false;
break;
}
} while (ts_tree_cursor_goto_next_sibling(&cursor));
}
ts_tree_cursor_delete(&cursor);
return ok;
}
static bool py_ns_collect_params(PyNsCallCheck *c, TSNode def) {
TSNode params = ts_node_child_by_field_name(def, TS_FIELD("parameters"));
if (ts_node_is_null(params))
return false;
uint32_t count = ts_node_named_child_count(params);
c->params = count ? py_ns_scratch_alloc(c->b, (size_t)count * sizeof(*c->params)) : NULL;
if (count && !c->params)
return false;
for (uint32_t i = 0; i < count; i++) {
TSNode p = ts_node_named_child(params, i);
const char *kind = ts_node_type(p);
if (strcmp(kind, "comment") == 0 || strcmp(kind, "keyword_separator") == 0 ||
strcmp(kind, "positional_separator") == 0)
continue;
TSNode name = p;
if (strcmp(kind, "default_parameter") == 0 ||
strcmp(kind, "typed_default_parameter") == 0) {
name = ts_node_child_by_field_name(p, TS_FIELD("name"));
} else if (strcmp(kind, "typed_parameter") == 0 ||
strcmp(kind, "list_splat_pattern") == 0 ||
strcmp(kind, "dictionary_splat_pattern") == 0) {
name = ts_node_named_child(p, 0);
const char *inner = ts_node_is_null(name) ? "" : ts_node_type(name);
if (strcmp(inner, "list_splat_pattern") == 0 ||
strcmp(inner, "dictionary_splat_pattern") == 0)
name = ts_node_named_child(name, 0);
} else if (strcmp(kind, "identifier") != 0) {
return false;
}
PyNsSlice s;
if (ts_node_is_null(name) || strcmp(ts_node_type(name), "identifier") != 0 ||
!py_ns_slice(c->b, name, &s.text, &s.len))
return false;
c->params[c->param_count++] = s;
}
return true;
}
static bool py_ns_collect_defs(PyNsCallCheck *c, int before) {
c->defs = before ? py_ns_scratch_alloc(c->b, (size_t)before * sizeof(*c->defs)) : NULL;
if (before && !c->defs)
return false;
for (int i = 0; i < before; i++) {
const CBMPyNamespaceFact *f = &c->b->facts.items[i];
bool def = f->kind == CBM_PY_NS_OWN_DEF ||
(f->kind == CBM_PY_NS_SHADOW && (f->reason == CBM_PY_NS_REASON_DECORATED ||
f->reason == CBM_PY_NS_REASON_UNPROVEN_CLASS));
if (def && f->local_name)
c->defs[c->def_count++] = f->local_name;
}
if (c->def_count > 1)
qsort(c->defs, (size_t)c->def_count, sizeof(*c->defs), py_ns_cmp_cstr);
return true;
}
/* Walk the callee's body (iteratively: nesting must not exhaust the stack). */
static bool py_ns_check_body(PyNsCallCheck *c, TSNode body) {
TSTreeCursor cursor = ts_tree_cursor_new(body);
bool ok = true;
bool done = false;
while (ok && !done && !c->b->failure) {
TSNode node = ts_tree_cursor_current_node(&cursor);
const char *kind = ts_node_type(node);
bool from = strcmp(kind, "import_from_statement") == 0;
bool is_import = from || strcmp(kind, "import_statement") == 0;
if (strcmp(kind, "global_statement") == 0 || strcmp(kind, "nonlocal_statement") == 0 ||
strcmp(kind, "future_import_statement") == 0) {
ok = false;
} else if (is_import) {
ok = py_ns_check_import(c, node, from);
} else if (strcmp(kind, "identifier") == 0) {
ok = !py_ns_node_forbidden(c, node);
}
if (!ok)
break;
if (!is_import && ts_tree_cursor_goto_first_child(&cursor))
continue;
while (!ts_tree_cursor_goto_next_sibling(&cursor)) {
if (!ts_tree_cursor_goto_parent(&cursor)) {
done = true;
break;
}
}
}
ts_tree_cursor_delete(&cursor);
return ok && !c->b->failure;
}
static PyNsCallVerdict py_ns_check_function(PyNamespaceBuilder *b, PyNsFunction *fn) {
PyNsCallCheck c = {.b = b};
TSNode body = ts_node_child_by_field_name(fn->node, TS_FIELD("body"));
if (ts_node_is_null(body) || !py_ns_own_name(b->ctx->rel_path, &c.own, &c.package) ||
!py_ns_collect_params(&c, fn->node) || !py_ns_collect_defs(&c, fn->fact) ||
!py_ns_check_body(&c, body))
return PY_NS_CALL_EFFECT;
if (!c.package)
c.shadow_count = 0; /* a plain module's imports install nothing in it */
fn->shadows = c.shadows;
fn->shadow_count = c.shadow_count;
return PY_NS_CALL_ALLOWED;
}
static void py_ns_note_function(PyNamespaceBuilder *b, int fact, TSNode node) {
if (b->function_count == b->function_cap) {
int cap = b->function_cap ? b->function_cap * 2 : 16;
PyNsFunction *items = py_ns_scratch_alloc(b, (size_t)cap * sizeof(*items));
if (!items)
return;
if (b->function_count)
memcpy(items, b->functions, (size_t)b->function_count * sizeof(*items));
b->functions = items;
b->function_cap = cap;
}
b->functions[b->function_count++] = (PyNsFunction){.fact = fact, .node = node};
}
enum { PY_NS_UNBOUND = -1, PY_NS_BARRIER = -2 };
/* The index of the latest fact binding `name`: PY_NS_UNBOUND when none did,
* PY_NS_BARRIER when a star import or an unknown effect may have since. */
static int py_ns_latest_binding(const PyNamespaceBuilder *b, const char *name, size_t len) {
for (int i = b->facts.count - 1; i >= 0; i--) {
const CBMPyNamespaceFact *f = &b->facts.items[i];
if (f->kind == CBM_PY_NS_UNKNOWN || f->kind == CBM_PY_NS_IMPORT_STAR)
return PY_NS_BARRIER;
if (f->local_name && py_ns_slice_cmp(f->local_name, name, len) == 0)
return i;
}
return PY_NS_UNBOUND;
}
/* Safe expressions, or a bare `locals()` / `globals()` of the builtins (the
* body never names the parameter that receives it). */
static bool py_ns_call_arguments_safe(PyNamespaceBuilder *b, TSNode args) {
if (ts_node_is_null(args) || strcmp(ts_node_type(args), "argument_list") != 0)
return false;
uint32_t count = ts_node_named_child_count(args);
for (uint32_t i = 0; i < count; i++) {
TSNode arg = ts_node_named_child(args, i);
if (py_ns_safe_expr(b, arg, 0))
continue;
if (strcmp(ts_node_type(arg), "call") != 0)
return false;
TSNode fn = ts_node_child_by_field_name(arg, TS_FIELD("function"));
TSNode inner = ts_node_child_by_field_name(arg, TS_FIELD("arguments"));
const char *text = NULL;
size_t len = 0;
if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "identifier") != 0 ||
!py_ns_slice(b, fn, &text, &len) ||
!(py_ns_text_is(text, len, "locals") || py_ns_text_is(text, len, "globals")) ||
ts_node_is_null(inner) || ts_node_named_child_count(inner) != 0 ||
py_ns_latest_binding(b, text, len) != PY_NS_UNBOUND)
return false;
}
return true;
}
/* True when the top-level call `call` cannot rebind a name of this module's
* namespace; the submodules its callee imports into the package are then
* shadowed here. False when it stays a whole-namespace effect. */
static bool py_ns_allowed_call(PyNamespaceBuilder *b, TSNode call) {
TSNode callee = ts_node_child_by_field_name(call, TS_FIELD("function"));
const char *name = NULL;
size_t len = 0;
if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0 ||
!py_ns_slice(b, callee, &name, &len) ||
!py_ns_call_arguments_safe(b, ts_node_child_by_field_name(call, TS_FIELD("arguments"))))
return false;
int at = py_ns_latest_binding(b, name, len);
if (at < 0 || b->facts.items[at].kind != CBM_PY_NS_OWN_DEF ||
b->facts.items[at].def_kind != CBM_PY_NS_DEF_FUNCTION)
return false;
int lo = 0, hi = b->function_count - 1;
PyNsFunction *fn = NULL;
while (lo <= hi && !fn) {
int mid = lo + (hi - lo) / 2;
if (b->functions[mid].fact == at)
fn = &b->functions[mid];
else if (b->functions[mid].fact < at)
lo = mid + 1;
else
hi = mid - 1;
}
if (!fn)
return false;
if (fn->verdict == PY_NS_CALL_UNCHECKED)
fn->verdict = py_ns_check_function(b, fn);
if (fn->verdict != PY_NS_CALL_ALLOWED || b->failure)
return false;
for (int i = 0; i < fn->shadow_count; i++) {
if (!py_ns_push(b, (CBMPyNamespaceFact){.kind = CBM_PY_NS_SHADOW,
.local_name = fn->shadows[i],
.reason = CBM_PY_NS_REASON_VALUE}))
return false;
}
return true;
}
static bool py_ns_statement(PyNamespaceBuilder *b, TSNode node) {
const char *kind = ts_node_type(node);
if (strcmp(kind, "comment") == 0 || strcmp(kind, "pass_statement") == 0)
@@ -1050,16 +1553,20 @@ static bool py_ns_statement(PyNamespaceBuilder *b, TSNode node) {
if (!py_ns_unknown(b, CBM_PY_NS_REASON_EFFECT))
return false;
}
return py_ns_push(
b, proven ? (CBMPyNamespaceFact){.kind = CBM_PY_NS_OWN_DEF,
.local_name = name,
.def_kind = function ? CBM_PY_NS_DEF_FUNCTION
: CBM_PY_NS_DEF_CLASS}
: (CBMPyNamespaceFact){.kind = CBM_PY_NS_SHADOW,
.local_name = name,
.reason = decorated
? CBM_PY_NS_REASON_DECORATED
: CBM_PY_NS_REASON_UNPROVEN_CLASS});
if (!py_ns_push(
b, proven ? (CBMPyNamespaceFact){.kind = CBM_PY_NS_OWN_DEF,
.local_name = name,
.def_kind = function ? CBM_PY_NS_DEF_FUNCTION
: CBM_PY_NS_DEF_CLASS}
: (CBMPyNamespaceFact){
.kind = CBM_PY_NS_SHADOW,
.local_name = name,
.reason = decorated ? CBM_PY_NS_REASON_DECORATED
: CBM_PY_NS_REASON_UNPROVEN_CLASS}))
return false;
if (proven && function)
py_ns_note_function(b, b->facts.count - 1, def);
return !b->failure;
}
}
if (strcmp(kind, "delete_statement") == 0)
@@ -1069,7 +1576,9 @@ static bool py_ns_statement(PyNamespaceBuilder *b, TSNode node) {
const char *ek = ts_node_type(expr);
if (strcmp(ek, "assignment") == 0 || strcmp(ek, "augmented_assignment") == 0)
return py_ns_assignment(b, expr);
return py_ns_safe_expr(b, expr, 0) || py_ns_unknown(b, CBM_PY_NS_REASON_EFFECT);
if (py_ns_safe_expr(b, expr, 0) || (strcmp(ek, "call") == 0 && py_ns_allowed_call(b, expr)))
return true;
return py_ns_unknown(b, CBM_PY_NS_REASON_EFFECT);
}
if (strcmp(kind, "if_statement") == 0 && py_ns_is_type_checking_if(b, node))
return py_ns_type_checking_block(b, node);
+8 -2
View File
@@ -1487,12 +1487,18 @@ static void k8s_container(k8s_walk_t *w, const char *path, TSNode container) {
const char *cname = k8s_text(w, container, "name");
cname = cname && cname[0] && !strstr(cname, "{{") ? cname : NULL;
const char *image = k8s_text(w, container, "image");
if (k8s_image_literal(image)) {
/* a Helm template's image is its skeleton placeholder: pass_k8s.c renders it */
if (k8s_image_literal(image) || (image && strchr(image, '<'))) {
TSNode iv = k8s_child(w, container, "image");
CBMK8sFact f = {.fact = CBM_K8S_FACT_IMAGE,
.def_index = w->def_index,
.name = image,
.container = cname,
.via = "container"};
.via = "container",
.line =
ts_node_is_null(iv) ? 0 : (int)ts_node_start_point(iv).row + SKIP_ONE,
.start_line = (int)ts_node_start_point(container).row + SKIP_ONE,
.end_line = (int)ts_node_end_point(container).row + SKIP_ONE};
cbm_k8sfact_push(&w->ctx->result->k8s_facts, w->ctx->arena, f);
}
k8s_iter_t env = k8s_iter(k8s_child(w, container, "env"));
+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;
}
+161 -40
View File
@@ -59,6 +59,10 @@ void cbm_ts_full_registry_builds_reset(void) {
* constructs it does not model. -1 = unlimited (no entry point armed it). */
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 int g_ts_max_member_lookup_steps;
static _Thread_local int g_ts_max_method_lookup_steps;
#endif
static void ts_type_budget_reset(size_t source_len) {
const char *e = cbm_runtime_getenv("CBM_TS_TYPE_BUDGET");
@@ -170,6 +174,12 @@ long cbm_ts_lsp_test_budget_remaining(void) {
bool cbm_ts_lsp_test_budget_warned(void) {
return g_ts_type_budget_warned;
}
int cbm_ts_lsp_test_max_member_lookup_steps(void) {
return g_ts_max_member_lookup_steps;
}
int cbm_ts_lsp_test_max_method_lookup_steps(void) {
return g_ts_max_method_lookup_steps;
}
#endif
#define TS_LSP_MAX_EVAL_DEPTH 64
@@ -190,6 +200,10 @@ static const CBMType *type_of_identifier(TSLSPContext *ctx, const char *name);
static const CBMType *lookup_member_type(TSLSPContext *ctx, const CBMType *recv, const char *name);
static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType *recv,
const char *method_name);
typedef struct TSLookupState TSLookupState;
static const CBMRegisteredFunc *lookup_method_at(TSLSPContext *ctx, const CBMType *recv,
const char *method_name, int depth,
TSLookupState *state);
static char *node_text(TSLSPContext *ctx, TSNode node);
// Collect a node's children into an arena array via a single O(n) cursor pass.
@@ -1569,28 +1583,77 @@ static const char *builtin_wrapper_class(const char *builtin_name) {
}
// Look up a property `name` on a receiver type. Returns the property type or UNKNOWN.
static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType *recv,
const char *name);
#define TS_LSP_MAX_MEMBER_DEPTH 64
#define TS_LSP_INLINE_VISITED_TYPES 16
/* Depth-guarded entry: member lookup recurses through wrapper classes, union
* members and registered-type expansion; cyclic type graphs in real-world TS
* (microsoft/TypeScript reallyLargeFile.ts) recursed without bound — SIGBUS
* stack overflow under endless lookup_member_type frames. Past the cap the
* member resolves as unknown — graceful degradation, not a crash. */
static const CBMType *lookup_member_type(TSLSPContext *ctx, const CBMType *recv, const char *name) {
if (!ctx || ctx->member_depth >= TS_LSP_MAX_MEMBER_DEPTH)
return cbm_type_unknown();
ctx->member_depth++;
const CBMType *r = lookup_member_type_inner(ctx, recv, name);
ctx->member_depth--;
return r;
typedef struct {
const char *qn;
int depth;
} TSVisitedType;
struct TSLookupState {
TSVisitedType inline_visited[TS_LSP_INLINE_VISITED_TYPES];
TSVisitedType *visited;
int visited_count;
int visited_capacity;
#ifdef CBM_ENABLE_TEST_SEAMS
int steps;
#endif
};
/* A named type needs another visit only when reached along a shallower path.
* Grow the visited table rather than silently dropping entries at a fixed cap:
* the depth bound then limits each named type to at most 64 admitted visits. */
static bool ts_lookup_visit_named(CBMArena *arena, TSLookupState *state, const char *qn,
int depth) {
for (int i = 0; i < state->visited_count; i++) {
if (strcmp(state->visited[i].qn, qn) == 0) {
if (state->visited[i].depth <= depth)
return false;
state->visited[i].depth = depth;
return true;
}
}
if (!state->visited) {
state->visited = state->inline_visited;
state->visited_capacity = TS_LSP_INLINE_VISITED_TYPES;
} else if (state->visited_count == state->visited_capacity) {
int capacity = state->visited_capacity * 2;
TSVisitedType *larger =
(TSVisitedType *)cbm_arena_alloc(arena, (size_t)capacity * sizeof(*larger));
if (!larger) {
fprintf(stderr, "[tslsp] lookup visited allocation failed\n");
return false;
}
memcpy(larger, state->visited, (size_t)state->visited_count * sizeof(*larger));
state->visited = larger;
state->visited_capacity = capacity;
}
state->visited[state->visited_count++] = (TSVisitedType){.qn = qn, .depth = depth};
return true;
}
static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType *recv,
const char *name) {
if (!ctx || !recv || !name)
static const CBMType *lookup_member_type_at(TSLSPContext *ctx, const CBMType *recv,
const char *name, int depth, TSLookupState *state);
/* Depth and shallower-only named-type visits bound work on cyclic inheritance. */
static const CBMType *lookup_member_type(TSLSPContext *ctx, const CBMType *recv, const char *name) {
TSLookupState state = {0};
const CBMType *result = lookup_member_type_at(ctx, recv, name, 0, &state);
#ifdef CBM_ENABLE_TEST_SEAMS
if (state.steps > g_ts_max_member_lookup_steps)
g_ts_max_member_lookup_steps = state.steps;
#endif
return result;
}
static const CBMType *lookup_member_type_at(TSLSPContext *ctx, const CBMType *recv,
const char *name, int depth, TSLookupState *state) {
#ifdef CBM_ENABLE_TEST_SEAMS
if (state)
state->steps++;
#endif
if (!ctx || !recv || !name || !state || depth >= TS_LSP_MAX_MEMBER_DEPTH)
return cbm_type_unknown();
const CBMType *base = simplify_type(ctx, recv);
if (!base)
@@ -1604,7 +1667,7 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType
const char *wrap = builtin_wrapper_class(base->data.builtin.name);
if (wrap) {
const CBMType *wrapped = cbm_type_named(ctx->arena, wrap);
return lookup_member_type(ctx, wrapped, name);
return lookup_member_type_at(ctx, wrapped, name, depth + 1, state);
}
return cbm_type_unknown();
}
@@ -1659,7 +1722,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType
// first hit instead of building a union of results.)
if (base->data.union_type.members) {
for (int i = 0; i < base->data.union_type.count; i++) {
const CBMType *m = lookup_member_type(ctx, base->data.union_type.members[i], name);
if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH)
break;
const CBMType *m = lookup_member_type_at(ctx, base->data.union_type.members[i],
name, depth + 1, state);
if (!cbm_type_is_unknown(m))
return m;
}
@@ -1670,7 +1736,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType
if (base->kind == CBM_TYPE_INTERSECTION) {
if (base->data.union_type.members) {
for (int i = 0; i < base->data.union_type.count; i++) {
const CBMType *m = lookup_member_type(ctx, base->data.union_type.members[i], name);
if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH)
break;
const CBMType *m = lookup_member_type_at(ctx, base->data.union_type.members[i],
name, depth + 1, state);
if (!cbm_type_is_unknown(m))
return m;
}
@@ -1682,6 +1751,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType
return cbm_type_unknown();
const char *recv_qn = base->data.named.qualified_name;
if (!recv_qn)
return cbm_type_unknown();
if (!ts_lookup_visit_named(ctx->arena, state, recv_qn, depth))
return cbm_type_unknown();
const CBMRegisteredType *rt = cbm_registry_resolve_alias(ctx->registry, recv_qn);
if (!rt) {
rt = cbm_registry_lookup_type(ctx->registry, recv_qn);
@@ -1711,8 +1784,10 @@ static const CBMType *lookup_member_type_inner(TSLSPContext *ctx, const CBMType
// Walk extends/implements.
if (rt->embedded_types) {
for (int i = 0; rt->embedded_types[i]; i++) {
if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH)
break;
const CBMType *parent = cbm_type_named(ctx->arena, rt->embedded_types[i]);
const CBMType *m = lookup_member_type(ctx, parent, name);
const CBMType *m = lookup_member_type_at(ctx, parent, name, depth + 1, state);
if (!cbm_type_is_unknown(m))
return m;
}
@@ -1795,9 +1870,6 @@ static const CBMType *eval_indexed_access(TSLSPContext *ctx, const CBMType *obj,
return cbm_type_unknown();
}
static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBMType *recv,
const char *method_name);
/* Depth-guarded entry, same contract as lookup_member_type: the method walk
* recurses through wrapper classes, union members and the extends/implements
* chain. A cyclic chain (#1743: the Effect idiom `class Service extends
@@ -1806,18 +1878,24 @@ static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBM
* cap the method is simply not found. */
static const CBMRegisteredFunc *lookup_method(TSLSPContext *ctx, const CBMType *recv,
const char *method_name) {
if (!ctx || ctx->method_depth >= TS_LSP_MAX_MEMBER_DEPTH)
return NULL;
ctx->method_depth++;
const CBMRegisteredFunc *f = lookup_method_inner(ctx, recv, method_name);
ctx->method_depth--;
return f;
TSLookupState state = {0};
const CBMRegisteredFunc *result = lookup_method_at(ctx, recv, method_name, 0, &state);
#ifdef CBM_ENABLE_TEST_SEAMS
if (state.steps > g_ts_max_method_lookup_steps)
g_ts_max_method_lookup_steps = state.steps;
#endif
return result;
}
// Look up a method on a receiver type — returns the registered func.
static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBMType *recv,
const char *method_name) {
if (!ctx || !recv || !method_name)
/* Depth and shallower-only named-type visits bound cyclic inheritance work. */
static const CBMRegisteredFunc *lookup_method_at(TSLSPContext *ctx, const CBMType *recv,
const char *method_name, int depth,
TSLookupState *state) {
#ifdef CBM_ENABLE_TEST_SEAMS
if (state)
state->steps++;
#endif
if (!ctx || !recv || !method_name || !state || depth >= TS_LSP_MAX_MEMBER_DEPTH)
return NULL;
const CBMType *base = simplify_type(ctx, recv);
if (!base)
@@ -1831,7 +1909,7 @@ static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBM
const char *wrap = builtin_wrapper_class(base->data.builtin.name);
if (wrap) {
const CBMType *wrapped = cbm_type_named(ctx->arena, wrap);
return lookup_method(ctx, wrapped, method_name);
return lookup_method_at(ctx, wrapped, method_name, depth + 1, state);
}
return NULL;
}
@@ -1860,8 +1938,10 @@ static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBM
if (base->kind == CBM_TYPE_UNION || base->kind == CBM_TYPE_INTERSECTION) {
if (base->data.union_type.members) {
for (int i = 0; i < base->data.union_type.count; i++) {
const CBMRegisteredFunc *f =
lookup_method(ctx, base->data.union_type.members[i], method_name);
if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH)
break;
const CBMRegisteredFunc *f = lookup_method_at(ctx, base->data.union_type.members[i],
method_name, depth + 1, state);
if (f)
return f;
}
@@ -1872,6 +1952,11 @@ static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBM
if (base->kind != CBM_TYPE_NAMED)
return NULL;
const char *recv_qn = base->data.named.qualified_name;
if (!recv_qn)
return NULL;
if (!ts_lookup_visit_named(ctx->arena, state, recv_qn, depth))
return NULL;
const CBMRegisteredFunc *f =
cbm_registry_lookup_method_aliased(ctx->registry, recv_qn, method_name);
@@ -1882,8 +1967,11 @@ static const CBMRegisteredFunc *lookup_method_inner(TSLSPContext *ctx, const CBM
const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, recv_qn);
if (rt && rt->embedded_types) {
for (int i = 0; rt->embedded_types[i]; i++) {
if (depth + 1 >= TS_LSP_MAX_MEMBER_DEPTH)
break;
const CBMType *parent = cbm_type_named(ctx->arena, rt->embedded_types[i]);
const CBMRegisteredFunc *pf = lookup_method(ctx, parent, method_name);
const CBMRegisteredFunc *pf =
lookup_method_at(ctx, parent, method_name, depth + 1, state);
if (pf)
return pf;
}
@@ -3757,6 +3845,30 @@ static void process_function_body(TSLSPContext *ctx, TSNode func_node, const cha
ctx->current_scope = saved_scope;
}
static void process_top_level_variable_arrows(TSLSPContext *ctx, TSNode declaration) {
if (!ctx || ts_node_is_null(declaration) || !ctx->module_qn)
return;
uint32_t count = ts_node_named_child_count(declaration);
for (uint32_t i = 0; i < count; i++) {
TSNode declarator = ts_node_named_child(declaration, i);
if (!node_kind_is(declarator, "variable_declarator"))
continue;
TSNode name = ts_node_child_by_field_name(declarator, "name", TS_LSP_FIELD_LEN("name"));
TSNode value = ts_node_child_by_field_name(declarator, "value", TS_LSP_FIELD_LEN("value"));
if (!node_kind_is(name, "identifier") || !node_kind_is(value, "arrow_function"))
continue;
char *function_name = node_text(ctx, name);
if (!function_name)
continue;
const char *function_qn =
cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, function_name);
process_function_body(ctx, value, function_qn, NULL);
}
}
void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) {
if (!ctx || ts_node_is_null(root))
return;
@@ -3782,7 +3894,9 @@ void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) {
TSNode child = kids[i];
const char *kind = ts_node_type(child);
if (strcmp(kind, "function_declaration") == 0) {
if (strcmp(kind, "lexical_declaration") == 0 || strcmp(kind, "variable_declaration") == 0) {
process_top_level_variable_arrows(ctx, child);
} else if (strcmp(kind, "function_declaration") == 0) {
TSNode name = ts_node_child_by_field_name(child, "name", TS_LSP_FIELD_LEN("name"));
if (ts_node_is_null(name) || !ctx->module_qn)
continue;
@@ -3821,6 +3935,9 @@ void ts_lsp_process_file(TSLSPContext *ctx, TSNode root) {
continue;
const char *fqn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, fn);
process_function_body(ctx, inner, fqn, NULL);
} else if (strcmp(ik, "lexical_declaration") == 0 ||
strcmp(ik, "variable_declaration") == 0) {
process_top_level_variable_arrows(ctx, inner);
} else if (strcmp(ik, "class_declaration") == 0 ||
strcmp(ik, "internal_module") == 0) {
process_node(ctx, inner);
@@ -3838,6 +3955,10 @@ void ts_lsp_init(TSLSPContext *ctx, CBMArena *arena, const char *source, int sou
bool jsx_mode, bool dts_mode, CBMResolvedCallArray *out) {
if (!ctx)
return;
#ifdef CBM_ENABLE_TEST_SEAMS
g_ts_max_member_lookup_steps = 0;
g_ts_max_method_lookup_steps = 0;
#endif
memset(ctx, 0, sizeof(TSLSPContext));
ctx->arena = arena;
ctx->source = source;
+4 -7
View File
@@ -65,13 +65,6 @@ typedef struct {
// first completed eval; see TsEvalMemo in ts_lsp.c). Kills the exponential
// re-evaluation of shared subexpressions under overload resolution.
struct TsEvalMemo *eval_memo;
// Recursion guard for lookup_member_type: cyclic type graphs (mutually
// recursive unions/wrappers across registered types) otherwise recurse
// without bound — stack overflow on real repos.
int member_depth;
// Same guard for lookup_method: its extends/implements walk recursed
// without bound on a cyclic heritage chain (#1743).
int method_depth;
} TSLSPContext;
#ifdef CBM_ENABLE_TEST_SEAMS
@@ -79,6 +72,10 @@ 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);
// 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);
int cbm_ts_lsp_test_max_method_lookup_steps(void);
#endif
// --- Initialization ---
+18 -21
View File
@@ -5,9 +5,9 @@
[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/DeusData/codebase-memory-mcp/blob/main/LICENSE)
[![Platform](https://img.shields.io/badge/macOS_%7C_Linux_%7C_Windows-supported-lightgrey)](https://github.com/DeusData/codebase-memory-mcp/releases/latest)
**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. This npm wrapper downloads, verifies, and caches the selected native runtime set: the executable, its authenticated integration asset, and—when requested—the content-addressed UI pack.
**Local code intelligence for developers and AI agents.** CBM connects source code, documentation and infrastructure in a persistent knowledge graph. Explore through MCP, the CLI or CBM Lens to find code, trace dependencies and deployments, and understand what a change affects.
High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across 162 languages — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 17 MCP tools. No hosted service or API key; this package requires Node.js to manage and launch the cached native runtime set. Plug and play across 45 automatic/conditional client surfaces.
This npm wrapper downloads, verifies and caches the native executable, its authenticated integration asset and the content-addressed Lens frontend pack. Node.js manages installation and launch; the graph is processed locally without a hosted service or API key. The installer offers 45 client integration targets, with capabilities that vary by client.
## Installation
@@ -25,14 +25,16 @@ Then configure your coding agents:
codebase-memory-mcp install
```
Restart your agent. Say **"Index this project"** — done.
Restart your agent and ask it to **"Index this project"**. Use `codebase-memory-mcp daemon start --open` to open CBM Lens. Supported integrations also install graph instructions, skills, agent profiles and context hooks; consult the full client matrix for differences.
## Why codebase-memory-mcp
- **Extreme indexing speed** — Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline with LZ4 compression and in-memory SQLite.
- **Connected repository knowledge** — source symbols and calls, documentation sections and references, configuration, infrastructure and cross-service links.
- **Plug and play** — verified native runtime sets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (arm64/amd64). No Docker or API keys; Node.js owns package download, cache repair, and launch.
- **162 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
- **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search.
- **Broad language and format support** — bundled parsers, with deeper semantic resolution for supported language families. See the [language reference](https://github.com/DeusData/codebase-memory-mcp#language-support) for coverage.
- **Focused context** — retrieve symbols, relationships and bounded source excerpts instead of repeatedly reading whole files. Savings depend on the task and workflow.
- **CBM Lens** — explore source, architecture, call paths, decisions and index coverage in a local browser workspace.
- **Smart CI** — experimental change-aware test selection for supported C/C++ test runners, using graph relationships and admitted per-test coverage. Missing evidence broadens execution. See the [Smart CI guide](https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/SMART_CI.md).
- **45 supported automatic/conditional client surfaces** — `install` configures the appropriate MCP, durable-context, and documented hook surfaces without widening client permissions.
- **Detected automatically (39)** — Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, Cursor, Windsurf, Augment / Auggie, OpenClaw, Kiro, Junie, Hermes, OpenHands, Cline, Warp, Qwen Code, GitHub Copilot CLI, Factory Droid, Crush, Goose, Mistral Vibe, Grok Build, Qoder CLI, Kimi Code CLI, GitLab Duo CLI, Rovo Dev CLI, Amp, Devin CLI / Local, Tabnine, Amazon Q Developer IDE, CodeBuddy Code CLI, IBM Bob Shell, Pochi, Pi, and Oh My Pi (omp).
- **Conditional or explicit (6)** — Continue / cn, Visual Studio, TRAE, Roo Code, IBM Bob IDE, and Sourcegraph Cody. Bob IDE is touched only when `~/.bob/mcp.json` already exists.
@@ -68,30 +70,25 @@ Every MCP tool is also available directly from the command line:
```bash
codebase-memory-mcp cli index_repository '{"repo_path": "/path/to/repo"}'
codebase-memory-mcp cli search_graph '{"name_pattern": ".*Handler.*", "label": "Function"}'
codebase-memory-mcp cli trace_call_path '{"function_name": "main", "direction": "both"}'
codebase-memory-mcp cli get_architecture '{}'
codebase-memory-mcp cli list_projects '{}'
# Replace PROJECT with the indexed project name returned above.
codebase-memory-mcp cli search_graph '{"project":"PROJECT","name_pattern":".*Handler.*","label":"Function"}'
codebase-memory-mcp cli trace_path '{"project":"PROJECT","function_name":"main","direction":"both"}'
codebase-memory-mcp cli get_architecture '{"project":"PROJECT"}'
```
## MCP Tools
| Category | Tools |
|----------|-------|
| **Indexing** | `index_repository`, `list_projects`, `delete_project`, `index_status` |
| **Querying** | `search_graph`, `trace_call_path`, `detect_changes`, `query_graph` |
| **Analysis** | `get_architecture`, `get_graph_schema`, `get_code_snippet`, `search_code` |
| **Advanced** | `manage_adr`, `ingest_traces` |
| **Indexing and coverage** | `index_repository`, `list_projects`, `delete_project`, `index_status`, `check_index_coverage` |
| **Search and source** | `search_graph`, `search_code`, `get_code_snippet`, `get_file_outline` |
| **Relationships and changes** | `trace_path`, `query_graph`, `detect_changes`, `compare_graphs` |
| **Architecture and evidence** | `get_architecture`, `get_graph_schema`, `manage_adr`, `ingest_traces` |
## Performance
Benchmarked on Apple M3 Pro:
| Operation | Time |
|-----------|------|
| Linux kernel full index (28M LOC, 75K files) | 3 min |
| Django full index | ~6s |
| Cypher query | <1ms |
| Trace call path (depth=5) | <10ms |
Performance and context savings depend on the repository, query, machine and agent workflow. See the [scoped benchmark results](https://github.com/DeusData/codebase-memory-mcp#performance) and [methodology](https://github.com/DeusData/codebase-memory-mcp/blob/main/docs/BENCHMARK.md) for recorded measurements and limitations.
## Full Documentation
+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
+121
View File
@@ -0,0 +1,121 @@
#!/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
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:
+1 -1
View File
@@ -293,7 +293,7 @@ build_mcpb_bundle() {
"name": "codebase-memory-mcp",
"display_name": "Codebase Memory",
"version": "$mcpb_version",
"description": "Codebase knowledge graph for AI agents — 162 languages, sub-ms queries, 99% fewer tokens.",
"description": "Local code intelligence connecting code, docs and infrastructure for developers and AI agents.",
"author": { "name": "DeusData", "url": "https://github.com/DeusData" },
"repository": { "type": "git", "url": "https://github.com/DeusData/codebase-memory-mcp" },
"homepage": "https://deusdata.github.io/codebase-memory-mcp/",
@@ -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) ==="
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.DeusData/codebase-memory-mcp",
"title": "Codebase Memory",
"description": "Codebase knowledge graph for AI agents \u2014 162 languages, sub-ms queries, 99% fewer tokens.",
"description": "Local code intelligence connecting code, docs and infrastructure for developers and AI agents.",
"repository": {
"url": "https://github.com/DeusData/codebase-memory-mcp",
"source": "github"
+26 -4
View File
@@ -435,29 +435,51 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
sb_append(&sb, "const BIN = '");
sb_append(&sb, bin);
sb_append(&sb, "';\n\n");
sb_append(&sb, "';\n"
"const AUGMENT_TIMEOUT_MS = 10000;\n\n");
/* hook-augment requires hook_event_name; its default dialect accepts
* Grep/Glob under PreToolUse, Read under PostToolUse, and the
* SessionStart lifecycle event — and emits the Claude JSON envelope, so
* the plugin unwraps additionalContext instead of pasting raw JSON into
* the tool output. Every failure path resolves to '' (fail open). */
* the tool output. Every failure path resolves to '' (fail open) —
* including a child that never exits: augment() is awaited inline in
* tool.execute.after, so a hung hook-augment would stall every
* grep/glob/read completion. The timer SIGKILLs the child and resolves
* ''; the promise keeps the first resolution, so the kill-triggered
* 'close' event cannot override it. Stream errors (EPIPE on a write
* still buffered when the child dies, spawn ENOENT) fire on child.stdin,
* not on the child, and would crash the host as an unhandled 'error'
* event — hence the stdin listener and the guarded end(). */
sb_append(&sb, "function augment(payload) {\n"
" return new Promise((resolve) => {\n"
" const child = spawn(BIN, ['hook-augment'], {\n"
" stdio: ['pipe', 'pipe', 'ignore'],\n"
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
" });\n"
" // Stream errors fire on the stream, not the child.\n"
" child.stdin.on('error', () => {});\n"
" const timer = setTimeout(() => {\n"
" child.kill('SIGKILL');\n"
" child.stdout.destroy();\n"
" resolve('');\n"
" }, AUGMENT_TIMEOUT_MS);\n"
" let out = '';\n"
" child.stdout.on('data', (d) => (out += d.toString()));\n"
" child.on('error', () => resolve(''));\n"
" child.on('error', () => {\n"
" clearTimeout(timer);\n"
" resolve('');\n"
" });\n"
" child.on('close', () => {\n"
" clearTimeout(timer);\n"
" try {\n"
" const ctx = JSON.parse(out)?.hookSpecificOutput?.additionalContext;\n"
" resolve(typeof ctx === 'string' ? ctx : '');\n"
" } catch { resolve(''); }\n"
" });\n"
" child.stdin.end(JSON.stringify(payload));\n"
" try {\n"
" child.stdin.end(JSON.stringify(payload));\n"
" } catch { resolve(''); }\n"
" });\n"
"}\n\n");
+14 -3
View File
@@ -2899,12 +2899,23 @@ 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, 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)) {
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;
}
+2 -2
View File
@@ -449,8 +449,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) {
+47 -34
View File
@@ -31,6 +31,8 @@ enum {
CBM_DAEMON_IPC_RETRY_INTERVAL_MS = 10,
CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS = 500,
CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS = 250,
CBM_DAEMON_IPC_RELEASE_ERROR = -1,
CBM_DAEMON_IPC_RELEASE_COMPLETE = 1,
};
/* Last private-namespace validation refusal, set at the exact failing check.
@@ -3988,19 +3990,19 @@ bool cbm_daemon_ipc_local_transition_begin_work(cbm_daemon_ipc_local_transition_
return true;
}
bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io) {
int cbm_daemon_ipc_local_transition_try_release(cbm_daemon_ipc_local_transition_t **transition_io) {
if (!transition_io) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
cbm_daemon_ipc_local_transition_t *transition = *transition_io;
if (!transition) {
return true;
return CBM_DAEMON_IPC_RELEASE_COMPLETE;
}
if (transition->legacy_fd < 0 || !transition->legacy_process_entry ||
transition->owner_pid != getpid() ||
(!transition->work_begun &&
(transition->startup_v2_fd < 0 || !transition->startup_v2_process_entry))) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
posix_named_lock_release(transition->legacy_fd, transition->legacy_process_entry);
if (!transition->work_begun) {
@@ -4008,7 +4010,7 @@ bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t *
}
free(transition);
*transition_io = NULL;
return true;
return CBM_DAEMON_IPC_RELEASE_COMPLETE;
}
#else /* _WIN32 */
@@ -6203,35 +6205,39 @@ static void win_participant_group_release_complete(cbm_private_file_lock_t **gro
}
}
static bool win_participant_state_release(const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_private_lock_directory_t *directory,
cbm_private_file_lock_t **startup_v2_io,
win_legacy_mutex_guard_t **legacy_guard_io,
cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) {
static int win_participant_state_release(const cbm_daemon_ipc_endpoint_t *endpoint,
cbm_private_lock_directory_t *directory,
cbm_private_file_lock_t **startup_v2_io,
win_legacy_mutex_guard_t **legacy_guard_io,
cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) {
if (!endpoint || !directory || !startup_v2_io || !legacy_guard_io || !group_io ||
!sentinel_io) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
if (!*startup_v2_io) {
int startup = win_startup_v2_try_acquire(directory, startup_v2_io);
if (startup != 1) {
return false;
return startup;
}
}
if (!*legacy_guard_io) {
int legacy = win_legacy_mutex_guard_try_acquire(endpoint, legacy_guard_io);
if (legacy != 1) {
(void)cbm_private_file_lock_release(startup_v2_io);
return false;
if (cbm_private_file_lock_release(startup_v2_io) != CBM_PRIVATE_FILE_LOCK_OK) {
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
return legacy;
}
}
if (!win_participant_group_release_locked(group_io, sentinel_io)) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
if (!win_legacy_mutex_guard_release(legacy_guard_io)) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
return cbm_private_file_lock_release(startup_v2_io) == CBM_PRIVATE_FILE_LOCK_OK;
return cbm_private_file_lock_release(startup_v2_io) == CBM_PRIVATE_FILE_LOCK_OK
? CBM_DAEMON_IPC_RELEASE_COMPLETE
: CBM_DAEMON_IPC_RELEASE_ERROR;
}
cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved(
@@ -7007,9 +7013,9 @@ bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io
if (!lock) {
return true;
}
if (!win_participant_state_release(lock->endpoint, lock->directory, &lock->startup_v2_lock,
&lock->legacy_guard, &lock->group_lock,
&lock->legacy_sentinel)) {
if (win_participant_state_release(lock->endpoint, lock->directory, &lock->startup_v2_lock,
&lock->legacy_guard, &lock->group_lock,
&lock->legacy_sentinel) != CBM_DAEMON_IPC_RELEASE_COMPLETE) {
return false;
}
cbm_private_lock_directory_close(lock->directory);
@@ -7072,9 +7078,10 @@ bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t
if (!guard) {
return true;
}
if (!win_participant_state_release(
guard->endpoint, guard->directory, &guard->teardown_startup_v2_lock,
&guard->teardown_legacy_guard, &guard->group_lock, &guard->legacy_sentinel)) {
if (win_participant_state_release(guard->endpoint, guard->directory,
&guard->teardown_startup_v2_lock,
&guard->teardown_legacy_guard, &guard->group_lock,
&guard->legacy_sentinel) != CBM_DAEMON_IPC_RELEASE_COMPLETE) {
return false;
}
cbm_private_lock_directory_close(guard->directory);
@@ -7169,39 +7176,45 @@ bool cbm_daemon_ipc_local_transition_begin_work(cbm_daemon_ipc_local_transition_
return true;
}
bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io) {
int cbm_daemon_ipc_local_transition_try_release(cbm_daemon_ipc_local_transition_t **transition_io) {
if (!transition_io) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
cbm_daemon_ipc_local_transition_t *transition = *transition_io;
if (!transition) {
return true;
return CBM_DAEMON_IPC_RELEASE_COMPLETE;
}
if (!transition->endpoint || !transition->directory) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
if (transition->group_lock || transition->legacy_sentinel != INVALID_HANDLE_VALUE ||
transition->teardown_legacy_guard) {
if (!win_participant_state_release(transition->endpoint, transition->directory,
&transition->startup_v2_lock,
&transition->teardown_legacy_guard,
&transition->group_lock, &transition->legacy_sentinel)) {
return false;
int released = win_participant_state_release(
transition->endpoint, transition->directory, &transition->startup_v2_lock,
&transition->teardown_legacy_guard, &transition->group_lock,
&transition->legacy_sentinel);
if (released != CBM_DAEMON_IPC_RELEASE_COMPLETE) {
return released;
}
} else if (transition->startup_v2_lock &&
cbm_private_file_lock_release(&transition->startup_v2_lock) !=
CBM_PRIVATE_FILE_LOCK_OK) {
return false;
return CBM_DAEMON_IPC_RELEASE_ERROR;
}
cbm_private_lock_directory_close(transition->directory);
transition->directory = NULL;
free(transition);
*transition_io = NULL;
return true;
return CBM_DAEMON_IPC_RELEASE_COMPLETE;
}
#endif /* _WIN32 */
bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io) {
return cbm_daemon_ipc_local_transition_try_release(transition_io) ==
CBM_DAEMON_IPC_RELEASE_COMPLETE;
}
bool cbm_daemon_ipc_send_frame(cbm_daemon_ipc_connection_t *connection,
cbm_daemon_frame_type_t type, uint16_t flags, const void *payload,
uint32_t length) {
+7 -1
View File
@@ -320,7 +320,13 @@ bool cbm_daemon_ipc_local_transition_begin_work(cbm_daemon_ipc_local_transition_
* Windows teardown reacquires startup-v2 and the legacy mutex, releases group
* SH, closes this participant's sentinel last, then releases mutex and v2.
* Success consumes and clears *transition_io; a retryable native cleanup
* failure returns false and retains it. */
* failure or startup-gate contention returns false and retains it. */
bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io);
/* Nonblocking release: 1 consumes and clears *transition_io (also when already
* empty), 0 retains it while another startup owns a required gate, and -1
* retains it on an unsafe/native I/O failure. A NULL transition_io is invalid.
* Callers can give healthy contention its own wait policy without extending
* the deadline for repeated native cleanup failures. */
int cbm_daemon_ipc_local_transition_try_release(cbm_daemon_ipc_local_transition_t **transition_io);
#endif /* CBM_DAEMON_IPC_H */
+6 -1
View File
@@ -1197,7 +1197,12 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
struct stat st;
bool is_symlink = false;
if (safe_stat(abs_path, &st, &is_symlink) != 0) {
if (out->count_only) {
/* A count aborts when it cannot observe an entry, because its caller
* (auto-index admission) treats an unreliable count as a refusal. A
* symlink is not that case: it is skipped deliberately, on every walk.
* Failing here made one symlink anywhere in a tree refuse the whole
* repo, and the refusal surfaced as "unsafe_or_unavailable_path". */
if (out->count_only && !is_symlink) {
out->failed = true;
} else if (is_symlink) {
/* Deliberately not indexed (#963): record so callers can
+2
View File
@@ -168,6 +168,8 @@ static const lang_name_entry_t LANG_NAME_TABLE[] = {
{"form", CBM_LANG_FORM},
{"magma", CBM_LANG_MAGMA},
{"wolfram", CBM_LANG_WOLFRAM},
{"jinja2", CBM_LANG_JINJA2},
{"jinja", CBM_LANG_JINJA2},
};
#define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0]))
+10 -1
View File
@@ -257,7 +257,16 @@ static bool ws_prefix_equal(const char *a, const char *b, size_t len) {
}
/* True when b is a or lives under a. Compares on a separator boundary so
* "/a/bc" is not treated as living under "/a/b". */
* "/a/bc" is not treated as living under "/a/b".
*
* '/' and '\' are equivalent here, as they are everywhere else in this module
* (ws_is_sep). The Windows sensitive-grant escape hatch (#1718) records the
* grant from cbm_canonical_path — backslashes — while the indexer candidate
* arrives normalized to '/'. A byte-exact comparison made the grant never
* match the candidate on Windows, so --approve-sensitive could not lift a
* "Program Files" refusal. Comparing on the separator-equivalence boundary
* keeps the containment semantics (a separator byte is never "part of a
* component name") while erasing the platform spelling difference. */
static bool ws_is_ancestor_or_equal(const char *a, const char *b) {
if (!a || !b || !a[0] || !b[0]) {
return false;
+14 -10
View File
@@ -1645,20 +1645,24 @@ static bool main_project_lock_manager_close(cbm_project_lock_manager_t **manager
}
static bool main_local_transition_close(cbm_daemon_ipc_local_transition_t **transition) {
uint64_t deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS);
uint64_t ceiling = main_deadline_after(MAIN_STARTUP_CONTENTION_CEILING_MS);
uint64_t error_deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS);
while (transition && *transition) {
/* A failed release always RETAINS the transition and is retriable by
* contract: the Windows release transition must briefly try-hold the
* shared startup-v2/legacy gates, so concurrent one-shot teardowns
* legitimately collide and succeed on retry. Success consumes the
* transition; only never-finishing cleanup is a failure, and the
* deadline escalation below owns that. */
(void)cbm_daemon_ipc_local_transition_release(transition);
/* Windows teardown reacquires the startup-v2/legacy gates before
* releasing its participant claim. A live startup owner can hold them
* beyond the native cleanup deadline. Retain protection and use the
* acquisition backstop for contention; repeated native errors still
* get only the short cleanup deadline. Success consumes the handle. */
int status = cbm_daemon_ipc_local_transition_try_release(transition);
if (*transition) {
if (cbm_now_ms() >= deadline) {
uint64_t now = cbm_now_ms();
if (now >= ceiling || (status < 0 && now >= error_deadline)) {
main_coordination_cleanup_fail_stop("local_transition_cleanup");
}
cbm_usleep(1000);
if (status == 0) {
error_deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS);
}
cbm_usleep(status == 0 ? 10000 : 1000);
}
}
return true;
+3 -1
View File
@@ -21029,7 +21029,9 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
cbm_log_mcp_request(req.method, NULL, true, dur_us);
/* server/discover is the modern connect probe. -32601 stays so the
* client falls back to initialize; that miss is not an error (#2433). */
cbm_log_mcp_request(req.method, NULL, strcmp(req.method, "server/discover") != 0, dur_us);
cbm_mcp_server_request_scope_end(srv);
cbm_jsonrpc_request_free(&req);
return err;
+3
View File
@@ -329,6 +329,9 @@ static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_
continue;
}
if (seg_len == FQN_DOTDOT_LEN && seg_start[0] == '.' && seg_start[FQN_SEP_LEN] == '.') {
if (!buf[0]) {
return NULL;
}
path_pop_segment(buf);
continue;
}
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
/*
* helm_template.h -- Helm values and the rendering of a chart template's
* image references (pass_k8s.c). Internal to the pipeline.
*
* Values are JSON (yyjson mutable values), as Helm holds them after its
* YAML -> JSON step: go-yaml v2 scalars (YAML 1.1 booleans), every number a
* float64. A template renders with Go text/template semantics and the part
* of Sprig image expressions use; anything else -- a function not modelled,
* .Release / .Capabilities / lookup, a missing helper, a parse error on the
* way -- is UNKNOWN, and an image that reads an UNKNOWN is no image. Nothing
* is guessed: a value is what Helm would render, or none.
*/
#ifndef CBM_HELM_TEMPLATE_H
#define CBM_HELM_TEMPLATE_H
#include "foundation/arena.h"
#include "tree_sitter/api.h"
#include <yyjson/yyjson.h>
#include <stdbool.h>
#include <stddef.h>
/* The first document of YAML text src as a value of doc (Helm's scalar
* types); NULL when it does not parse. An empty document is an empty map. */
yyjson_mut_val *cbm_helm_values_parse(yyjson_mut_doc *doc, const char *src, size_t len);
/* The YAML node `node` of text `src` (already parsed, e.g. a HelmRelease's
* spec.values) as a value of doc; NULL when it holds an unknown part. */
yyjson_mut_val *cbm_helm_values_node(yyjson_mut_doc *doc, const char *src, TSNode node);
/* Helm's coalesce: a deep copy of `base` with `over` merged in -- maps merge
* key by key, any other value replaces, a null deletes. Either may be NULL. */
yyjson_mut_val *cbm_helm_values_coalesce(yyjson_mut_doc *doc, yyjson_mut_val *base,
yyjson_mut_val *over);
/* Helm --set NAME=VALUE into map `root`: NAME's `.` separates keys (`\.`
* escapes one), `[n]` indexes a list; VALUE is typed like --set (int, bool,
* null, else string) unless `as_string`. False when NAME is malformed. */
bool cbm_helm_values_set(yyjson_mut_doc *doc, yyjson_mut_val *root, const char *name,
const char *value, bool as_string);
/* A values scalar as a template prints it (a number like Go's %v: 1.10 ->
* 1.1) into out; false for a map, a list or null. */
bool cbm_helm_scalar_text(yyjson_mut_val *v, char *out, size_t sz);
/* A chart's metadata, as its templates see it under .Chart -- and where its
* templates are: .Template.BasePath as Helm sets it (`<chart path>/templates`,
* the chart path its root's name and `/charts/<name or alias>` per subchart
* below it; NULL: not known) and the chart's directory as its template files
* are added to the defines ("" at the repository root). */
typedef struct {
const char *name;
const char *version;
const char *app_version;
const char *base_path;
const char *dir;
} cbm_helm_chart_meta_t;
/* The named templates (`define` / `block`) of one chart family -- a chart and
* the charts below it share one namespace; for one name the define of the
* file nearest the family root wins (Helm loads deeper files first), then
* the alphabetically first path. */
typedef struct cbm_helm_defines cbm_helm_defines_t;
cbm_helm_defines_t *cbm_helm_defines_new(void);
/* Register the defines of template file `rel` (text src, len). The text is
* copied; its parse lives as long as the set. */
void cbm_helm_defines_add(cbm_helm_defines_t *d, const char *rel, const char *src, size_t len);
void cbm_helm_defines_free(cbm_helm_defines_t *d);
/* A probe: the `image:` key on a template line. Each time rendering passes
* that line it captures the scalar written after the key. */
typedef struct {
int line; /* 1-based line of the key in the template */
int n; /* captures */
int cap; /* room in image / keys (the render's own) */
const char **image; /* the rendered scalar, YAML quoting removed; NULL: UNKNOWN */
const char **keys; /* JSON array of the values paths it read; NULL: none */
} cbm_helm_probe_t;
/* Render template `rel` (text src, len) with `values` (its .Values; NULL:
* an empty map), `chart` and the family's defines, filling `probes` (strings
* in arena a, which gets only these: the render works in an arena of its own).
* Values paths are dot-joined keys, a key's own `.` escaped (`\.`), as --set
* writes them. False when the render ran past its step, output or memory
* budget -- a template Helm would not finish either (logged): then every
* capture is UNKNOWN. */
bool cbm_helm_render(CBMArena *a, const char *rel, const char *src, size_t len,
yyjson_mut_val *values, const cbm_helm_chart_meta_t *chart,
const cbm_helm_defines_t *defs, cbm_helm_probe_t *probes, int nprobes);
#endif /* CBM_HELM_TEMPLATE_H */
+37
View File
@@ -414,6 +414,43 @@ static inline bool cbm_pipeline_invocation_leaf_matches(const CBMResolvedCall *r
call_leaf && strcmp(cbm_lsp_bare_segment(resolved->reason), call_leaf) == 0;
}
/* #606: a sub-floor `php_method_typed_unindexed` row still proves the receiver
* is not a project class, so a weak short-name fallback must not bind it. A
* project class with the same simple name keeps the fallback, since the PHP
* LSP does not follow every cross-file type. */
static inline bool cbm_pipeline_php_receiver_type_unindexed(const CBMResolvedCallArray *arr,
const CBMCall *call,
const cbm_registry_t *registry) {
if (!arr || !call || !call->enclosing_func_qn || !call->callee_name) {
return false;
}
for (int i = 0; i < arr->count; i++) {
const CBMResolvedCall *rc = &arr->items[i];
if (rc->kind != CBM_RESOLVED_INVOCATION || !rc->caller_qn || !rc->callee_qn ||
!rc->strategy || strcmp(rc->strategy, "php_method_typed_unindexed") != 0 ||
strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) {
continue;
}
int site_rank = cbm_pipeline_invocation_site_rank(rc, call);
if (site_rank == 0 || !cbm_pipeline_invocation_leaf_matches(rc, call, site_rank)) {
continue;
}
const char *method_dot = strrchr(rc->callee_qn, '.');
if (!method_dot || method_dot == rc->callee_qn) {
continue;
}
char class_qn[CBM_SZ_512];
size_t class_len = (size_t)(method_dot - rc->callee_qn);
if (class_len >= sizeof(class_qn)) {
continue;
}
memcpy(class_qn, rc->callee_qn, class_len);
class_qn[class_len] = '\0';
return !cbm_registry_has_type_named(registry, cbm_lsp_bare_segment(class_qn));
}
return false;
}
/* Look up the highest-confidence LSP-resolved call entry whose caller QN
* matches the textual call's enclosing function and whose callee QN
* short-name matches the textual callee. Returns a pointer into `arr`
+4 -1
View File
@@ -1170,7 +1170,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
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());
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));
/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
+91 -4
View File
@@ -25,6 +25,7 @@
*/
#include "foundation/constants.h"
#include "foundation/compat.h"
#include "foundation/str_util.h" /* cbm_json_escape */
#include "pipeline/pipeline_internal.h"
#include "cbm.h"
#include "helpers.h" /* cbm_env_address */
@@ -783,9 +784,33 @@ static const char *ci_env_lookup(const ci_env_t *env, const char *name, size_t n
return NULL;
}
/* JSON array `list` (NULL: empty) with identifier `name` appended once. */
static const char *ci_names_add(CBMArena *a, const char *list, const char *name, size_t nl) {
if (!list) {
return cbm_arena_sprintf(a, "[\"%.*s\"]", (int)nl, name);
}
char quoted[CBM_SZ_256];
snprintf(quoted, sizeof(quoted), "\"%.*s\"", (int)nl, name);
if (strstr(list, quoted)) {
return list;
}
return cbm_arena_sprintf(a, "%.*s,%s]", (int)(strlen(list) - SKIP_ONE), list, quoted);
}
static const char *ci_interp_marks(CBMArena *a, const char *s, const ci_env_t *env,
const char **dflt_of, const char **ovr_of);
/* `s` with ${X} / ${X:-d} / ${X-d} / $X expanded from env; NULL when a
* variable has no value (and no default). */
static const char *ci_interp(CBMArena *a, const char *s, const ci_env_t *env) {
return ci_interp_marks(a, s, env, NULL, NULL);
}
/* ci_interp; `dflt_of` (may be NULL) gains, as a JSON array, the variables
* whose inline default was used, `ovr_of` those with an inline default that
* env set instead. */
static const char *ci_interp_marks(CBMArena *a, const char *s, const ci_env_t *env,
const char **dflt_of, const char **ovr_of) {
if (!s || !strchr(s, '$')) {
return s;
}
@@ -839,6 +864,11 @@ static const char *ci_interp(CBMArena *a, const char *s, const ci_env_t *env) {
}
use = dflt;
ul = dl;
if (dflt_of) {
*dflt_of = ci_names_add(a, *dflt_of, name, nl);
}
} else if (dflt && ovr_of) {
*ovr_of = ci_names_add(a, *ovr_of, name, nl);
}
if (len + ul + SKIP_ONE > cap) {
return NULL;
@@ -1054,7 +1084,53 @@ static int ci_compose_env(const ci_t *c, TSNode svc, const ci_env_t *dotenv, con
return k;
}
/* A compose build's `args` (a mapping, or a list of "K=V" items), their
* values interpolated, as a JSON object; NULL: none. An argument without a
* value (the environment's), with an unknown one or with a secret (by name or
* value -- never kept in the graph, never an image) is left out. */
static const char *ci_compose_build_args(const ci_t *c, TSNode build, const ci_env_t *env) {
TSNode args = ts_node_is_null(build) ? build : ci_get(c, build, "args");
if (ts_node_is_null(args)) {
return NULL;
}
int n = 0;
TSNode *items = ci_items(c, args, &n);
ci_pair_t *pairs = items ? NULL : ci_pairs(c, args, &n);
const char *json = NULL;
for (int i = 0; i < n; i++) {
const char *key = NULL;
const char *raw = NULL;
if (items) {
const char *s = ci_scalar(c, items[i], NULL);
const char *eq = s ? strchr(s, '=') : NULL;
key = eq ? cbm_arena_strndup(c->a, s, (size_t)(eq - s)) : NULL;
raw = eq ? eq + SKIP_ONE : NULL;
} else {
key = pairs[i].key;
raw = ts_node_is_null(pairs[i].value) ? NULL : ci_scalar(c, pairs[i].value, NULL);
}
const char *value = raw ? ci_interp(c->a, raw, env) : NULL;
if (!key || !key[0] || !value || cbm_is_secret_binding(key, value)) {
continue;
}
/* JSON escaping writes at most 6 bytes a byte: nothing is cut */
int kn = (int)(strlen(key) * 6 + SKIP_ONE);
int vn = (int)(strlen(value) * 6 + SKIP_ONE);
char *ek = cbm_arena_alloc(c->a, (size_t)kn);
char *ev = cbm_arena_alloc(c->a, (size_t)vn);
if (!ek || !ev) {
return NULL;
}
cbm_json_escape(ek, kn, key);
cbm_json_escape(ev, vn, value);
json = cbm_arena_sprintf(c->a, "%s%s\"%s\":\"%s\"", json ? json : "{", json ? "," : "", ek,
ev);
}
return json ? cbm_arena_sprintf(c->a, "%s}", json) : NULL;
}
static void ci_compose(const ci_t *c, TSNode root, const char *dir, const ci_env_t *env) {
static const ci_env_t no_env = {0};
int ns = 0;
ci_pair_t *svcs = ci_pairs(c, ci_get(c, root, "services"), &ns);
for (int i = 0; i < ns; i++) {
@@ -1064,16 +1140,27 @@ static void ci_compose(const ci_t *c, TSNode root, const char *dir, const ci_env
}
int row = 0;
const char *raw = ci_scalar(c, ci_get(c, svc, "image"), &row);
const char *image = raw ? ci_interp(c->a, raw, env) : NULL;
const char *df = ci_compose_dockerfile(c, ci_get(c, svc, "build"), dir, env);
const char *dflt_of = NULL;
const char *ovr_of = NULL;
const char *image = raw ? ci_interp_marks(c->a, raw, env, &dflt_of, &ovr_of) : NULL;
/* With .env replacing a default: what the defaults alone would name. */
const char *default_image = image && ovr_of ? ci_interp(c->a, raw, &no_env) : NULL;
TSNode build = ci_get(c, svc, "build");
const char *df = ci_compose_dockerfile(c, build, dir, env);
if (c->site_fn && image && df) {
cbm_build_site_t site = {
.via = "compose", .image = image, .dockerfile = df, .line = row + SKIP_ONE};
cbm_build_site_t site = {.via = "compose",
.image = image,
.dockerfile = df,
.build_args = ci_compose_build_args(c, build, env),
.line = row + SKIP_ONE};
c->site_fn(&site, c->ud);
}
if (c->service_fn) {
cbm_compose_service_t s = {.name = svcs[i].key,
.image = image,
.image_default_of = image ? dflt_of : NULL,
.image_override_of = image ? ovr_of : NULL,
.default_image = default_image,
.dockerfile = df,
.line = (int)ts_node_start_point(svc).row,
.end_line = (int)ts_node_end_point(svc).row + SKIP_ONE};
+610 -49
View File
@@ -108,12 +108,31 @@ typedef struct {
CBMHashTable *dirs; /* every directory holding a file (not the root) */
} dk_index_t;
/* Where a value came from: the ARGs whose default (`dflt`) or build argument
* (`ovr`) supplied part of it, as '\x1f'-separated names; NULL: none. */
typedef struct {
const char *dflt;
const char *ovr;
} dk_prov_t;
typedef struct dk_var {
const char *name;
const char *value; /* NULL: declared without a value */
bool arg; /* an ARG: a build argument can set it */
dk_prov_t prov;
struct dk_var *next;
} dk_var_t;
/* An image reference of FROM / COPY --from, linked once the file is read. */
typedef struct {
int64_t img;
const char *ref;
const char *via;
int stage;
int line;
dk_prov_t prov;
} dk_ref_t;
typedef struct {
cbm_pipeline_ctx_t *ctx;
CBMArena *a;
@@ -131,6 +150,14 @@ typedef struct {
dk_entry_t entry; /* the current stage's ENTRYPOINT */
dk_entry_t cmd; /* the current stage's CMD */
const dk_index_t *ix;
dk_ref_t *refs; /* FROM / COPY --from references, in file order */
int nrefs;
int refs_cap;
/* A build's view (dk_build_args): its build arguments set the ARGs they
* name, and the references they change link from the Image it builds. */
const dk_var_t *build_args;
int64_t built; /* 0: the Dockerfile's own view */
const char *build_props; /* the build's tool / file / build_line members */
} dk_t;
static bool dk_type(TSNode n, const char *t) {
@@ -172,14 +199,72 @@ static const dk_var_t *dk_find(const dk_var_t *v, const char *name, size_t nl) {
return NULL;
}
static void dk_set(dk_t *d, dk_var_t **list, const char *name, const char *value) {
static void dk_set(dk_t *d, dk_var_t **list, const char *name, const char *value, bool arg,
dk_prov_t prov) {
dk_var_t *v = cbm_arena_alloc(d->a, sizeof(*v));
if (v) {
*v = (dk_var_t){.name = name, .value = value, .next = *list};
*v = (dk_var_t){.name = name, .value = value, .arg = arg, .prov = prov, .next = *list};
*list = v;
}
}
enum { DK_NAME_SEP = '\x1f' };
static bool dk_names_has(const char *list, const char *name, size_t nl) {
for (const char *p = list; p && *p;) {
size_t l = strcspn(p, "\x1f");
if (l == nl && strncmp(p, name, nl) == 0) {
return true;
}
p += l;
p += *p ? SKIP_ONE : 0;
}
return false;
}
/* `list` with `name` appended unless it holds it already. */
static const char *dk_names_add(dk_t *d, const char *list, const char *name, size_t nl) {
if (dk_names_has(list, name, nl)) {
return list;
}
return list && list[0] ? cbm_arena_sprintf(d->a, "%s%c%.*s", list, DK_NAME_SEP, (int)nl, name)
: cbm_arena_strndup(d->a, name, nl);
}
static const char *dk_names_union(dk_t *d, const char *a, const char *b) {
for (const char *p = b; p && *p;) {
size_t l = strcspn(p, "\x1f");
a = dk_names_add(d, a, p, l);
p += l;
p += *p ? SKIP_ONE : 0;
}
return a;
}
static void dk_prov_merge(dk_t *d, dk_prov_t *into, dk_prov_t from) {
into->dflt = dk_names_union(d, into->dflt, from.dflt);
into->ovr = dk_names_union(d, into->ovr, from.ovr);
}
/* `,"key":["A","B"]` for a non-empty name list, else "". */
static const char *dk_names_member(dk_t *d, const char *key, const char *list) {
if (!list || !list[0]) {
return "";
}
const char *out = cbm_arena_sprintf(d->a, ",\"%s\":[", key);
for (const char *p = list; out && *p;) {
size_t l = strcspn(p, "\x1f");
char esc[CBM_SZ_256];
char name[CBM_SZ_256];
snprintf(name, sizeof(name), "%.*s", (int)l, p);
cbm_json_escape(esc, (int)sizeof(esc), name);
out = cbm_arena_sprintf(d->a, "%s%s\"%s\"", out, p == list ? "" : ",", esc);
p += l;
p += *p ? SKIP_ONE : 0;
}
return out ? cbm_arena_sprintf(d->a, "%s]", out) : "";
}
/* ASCII case-insensitive equality (stage names, `scratch`). */
static bool dk_ieq(const char *a, const char *b) {
for (; *a && *b; a++, b++) {
@@ -195,8 +280,10 @@ static bool dk_name_char(char c) {
}
/* `text` with $X / ${X} / ${X:-d} / ${X-d} expanded from `vars`; NULL when a
* reference has no value. */
static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text) {
* reference has no value. `prov` (may be NULL) gains the ARGs whose default
* or build argument supplied a value -- an inline `${X:-d}` default counts
* only for a declared ARG X, the one thing a build argument can set. */
static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text, dk_prov_t *prov) {
size_t cap = strlen(text) + SKIP_ONE;
size_t len = 0;
char *out = cbm_arena_alloc(d->a, cap);
@@ -249,6 +336,11 @@ static const char *dk_expand(dk_t *d, const dk_var_t *vars, const char *text) {
return NULL;
}
const char *from = val ? val : dflt;
if (prov && v && val) {
dk_prov_merge(d, prov, v->prov);
} else if (prov && v && v->arg) {
prov->dflt = dk_names_add(d, prov->dflt, name, nl);
}
if (len + vl + SKIP_ONE > cap) {
size_t ncap = (len + vl + SKIP_ONE) * PAIR_LEN;
char *grown = cbm_arena_alloc(d->a, ncap);
@@ -370,6 +462,139 @@ static int dk_stage_of(const dk_t *d, const char *ref) {
return found;
}
/* A path component: [a-z0-9]+ joined by `.`, `_`, `__` or `-`+. */
static bool dk_ref_component(const char *s, size_t n) {
if (n == 0 || !isalnum((unsigned char)s[0]) || !isalnum((unsigned char)s[n - SKIP_ONE])) {
return false;
}
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (isupper(c)) {
return false;
}
if (isalnum(c)) {
continue;
}
size_t run = SKIP_ONE;
while (i + run < n && s[i + run] == c) {
run++;
}
if (!((c == '.' && run == SKIP_ONE) || (c == '_' && run <= PAIR_LEN) || c == '-')) {
return false;
}
i += run - SKIP_ONE;
}
return true;
}
/* A registry host: DNS labels (`.`-joined), or an IPv6 literal, with an
* optional :port. */
static bool dk_ref_domain(const char *s, size_t n) {
const char *colon = memchr(s, ':', n);
if (n && s[0] == '[') {
const char *close = memchr(s, ']', n);
colon = close && (size_t)(close - s) + SKIP_ONE < n ? close + SKIP_ONE : NULL;
if (!close) {
return false;
}
}
size_t hl = colon ? (size_t)(colon - s) : n;
if (hl == 0) {
return false;
}
if (s[0] != '[') {
for (size_t i = 0; i < hl; i++) {
unsigned char c = (unsigned char)s[i];
if (!isalnum(c) && c != '.' && c != '-') {
return false;
}
}
}
for (size_t i = hl + SKIP_ONE; colon && i < n; i++) {
if (!isdigit((unsigned char)s[i])) {
return false;
}
}
return !colon || hl + SKIP_ONE < n;
}
bool cbm_image_ref_valid(const char *ref) {
if (!ref || !ref[0]) {
return false;
}
size_t n = strlen(ref);
const char *at = strchr(ref, '@');
if (at) {
const char *colon = strchr(at, ':');
if (!colon || colon == at + SKIP_ONE || strlen(colon + SKIP_ONE) < CBM_SZ_32) {
return false;
}
for (const char *p = at + SKIP_ONE; p < colon; p++) {
if (!isalnum((unsigned char)*p) && !strchr("+.-_", *p)) {
return false;
}
}
for (const char *p = colon + SKIP_ONE; *p; p++) {
if (!isxdigit((unsigned char)*p)) {
return false;
}
}
n = (size_t)(at - ref);
}
/* the tag: after the last ':' that follows the last '/' */
size_t name_end = n;
for (size_t i = n; i > 0; i--) {
if (ref[i - SKIP_ONE] == '/') {
break;
}
if (ref[i - SKIP_ONE] == ':') {
name_end = i - SKIP_ONE;
break;
}
}
if (name_end < n) {
size_t tl = n - name_end - SKIP_ONE;
const char *tag = ref + name_end + SKIP_ONE;
if (tl == 0 || tl > CBM_SZ_128 || !(isalnum((unsigned char)tag[0]) || tag[0] == '_')) {
return false;
}
for (size_t i = 0; i < tl; i++) {
if (!isalnum((unsigned char)tag[i]) && !strchr("_.-", tag[i])) {
return false;
}
}
}
const char *p = ref;
const char *end = ref + name_end;
const char *slash = memchr(p, '/', (size_t)(end - p));
if (slash) {
size_t fl = (size_t)(slash - p);
bool host = memchr(p, '.', fl) || memchr(p, ':', fl) ||
(fl == strlen("localhost") && memcmp(p, "localhost", fl) == 0);
if (host) {
if (!dk_ref_domain(p, fl)) {
return false;
}
p = slash + SKIP_ONE;
}
}
if (p >= end) {
return false;
}
while (p < end) {
const char *s = memchr(p, '/', (size_t)(end - p));
size_t cl = s ? (size_t)(s - p) : (size_t)(end - p);
if (!dk_ref_component(p, cl)) {
return false;
}
p += cl + (s ? SKIP_ONE : 0);
if (s && p >= end) {
return false;
}
}
return true;
}
int64_t cbm_pipeline_image_node(cbm_pipeline_ctx_t *ctx, const char *ref) {
char repo[CBM_SZ_512];
char reg[CBM_SZ_512];
@@ -438,19 +663,101 @@ int64_t cbm_pipeline_env_setter(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *
return id;
}
/* DEPENDS_ON from the Dockerfile to the Image of resolved reference `ref`. */
static void dk_link(dk_t *d, const char *ref, const char *via, int line) {
/* Record the Image of resolved reference `ref`, linked by dk_refs_link. */
static void dk_link(dk_t *d, const char *ref, const char *via, int line, dk_prov_t prov) {
int64_t img = cbm_pipeline_image_node(d->ctx, ref);
if (img <= 0) {
return;
}
char esc_ref[CBM_SZ_1K];
cbm_json_escape(esc_ref, (int)sizeof(esc_ref), ref);
char props[CBM_SZ_2K];
snprintf(props, sizeof(props), "{\"via\":\"%s\",\"image\":\"%s\",\"stage\":%d,\"line\":%d}",
via, esc_ref, d->stage, line);
cbm_gbuf_insert_edge(d->ctx->gbuf, d->module, img, "DEPENDS_ON", props);
d->edges++;
if (d->nrefs == d->refs_cap) {
int cap = d->refs_cap ? d->refs_cap * PAIR_LEN : DK_MAX_STAGES;
dk_ref_t *grown = cbm_arena_alloc(d->a, (size_t)cap * sizeof(*grown));
if (!grown) {
return;
}
if (d->nrefs) {
memcpy(grown, d->refs, (size_t)d->nrefs * sizeof(*grown));
}
d->refs = grown;
d->refs_cap = cap;
}
d->refs[d->nrefs++] = (dk_ref_t){img, ref, via, d->stage, line, prov};
}
/* The names a default supplies to the references of Image `img`: their union,
* or NULL when any of them names it without a default (a literal reference
* wins -- the edges to one Image are one edge, so every one of them carries
* the same mark). */
static const char *dk_refs_defaults(dk_t *d, int64_t img) {
const char *names = NULL;
for (int i = 0; i < d->nrefs; i++) {
if (d->refs[i].img != img) {
continue;
}
if (!d->refs[i].prov.dflt) {
return NULL;
}
names = dk_names_union(d, names, d->refs[i].prov.dflt);
}
return names;
}
/* The Dockerfile's own view: DEPENDS_ON from its Module to each Image FROM /
* COPY --from names {via, image, stage, line[, default_of: the ARGs whose
* defaults named it]}. */
static void dk_refs_link(dk_t *d) {
for (int i = 0; i < d->nrefs; i++) {
const dk_ref_t *r = &d->refs[i];
char esc_ref[CBM_SZ_1K];
cbm_json_escape(esc_ref, (int)sizeof(esc_ref), r->ref);
const char *dflt = dk_names_member(d, "default_of", dk_refs_defaults(d, r->img));
const char *props =
cbm_arena_sprintf(d->a, "{\"via\":\"%s\",\"image\":\"%s\",\"stage\":%d,\"line\":%d%s}",
r->via, esc_ref, r->stage, r->line, dflt);
if (props) {
cbm_gbuf_insert_edge(d->ctx->gbuf, d->module, r->img, "DEPENDS_ON", props);
d->edges++;
}
}
}
/* A build's view: each Image a reference the build's arguments changed names
* -- the first reference to it, its build arguments unioned --
* DEPENDS_ON from the Image the build makes {via, image, override_of[,
* default_of], dockerfile, stage, line, tool, file, build_line}. A reference
* no build argument reached is the Dockerfile's own edge already. */
static void dk_refs_link_build(dk_t *d) {
for (int i = 0; i < d->nrefs; i++) {
const dk_ref_t *r = &d->refs[i];
bool first = r->prov.ovr != NULL && r->img != d->built;
for (int j = 0; first && j < i; j++) {
first = !(d->refs[j].img == r->img && d->refs[j].prov.ovr);
}
if (!first) {
continue;
}
dk_prov_t all = {0};
for (int j = i; j < d->nrefs; j++) {
if (d->refs[j].img == r->img && d->refs[j].prov.ovr) {
dk_prov_merge(d, &all, d->refs[j].prov);
}
}
char esc_ref[CBM_SZ_1K];
char esc_file[CBM_SZ_1K];
cbm_json_escape(esc_ref, (int)sizeof(esc_ref), r->ref);
cbm_json_escape(esc_file, (int)sizeof(esc_file), d->file);
const char *props = cbm_arena_sprintf(
d->a,
"{\"via\":\"%s\",\"image\":\"%s\"%s%s,\"dockerfile\":\"%s\",\"stage\":%d,"
"\"line\":%d,%s}",
r->via, esc_ref, dk_names_member(d, "override_of", all.ovr),
dk_names_member(d, "default_of", all.dflt), esc_file, r->stage, r->line,
d->build_props);
if (props) {
cbm_gbuf_insert_edge(d->ctx->gbuf, d->built, r->img, "DEPENDS_ON", props);
d->edges++;
}
}
}
static int dk_line(TSNode n) {
@@ -467,19 +774,33 @@ static void dk_arg(dk_t *d, TSNode ins) {
if (!name) {
return;
}
if (d->stage < 0) {
dk_set(d, &d->global, name, ts_node_is_null(df) ? NULL : dk_value(d, df));
size_t nl = strlen(name);
dk_var_t **list = d->stage < 0 ? &d->global : &d->local;
/* A build argument sets the ARG it names, in every stage declaring it,
* whatever its default. */
const dk_var_t *set = dk_find(d->build_args, name, nl);
if (set) {
dk_set(d, list, name, set->value, true,
(dk_prov_t){.ovr = dk_names_add(d, NULL, name, nl)});
return;
}
const char *value = NULL;
dk_prov_t prov = {0};
if (!ts_node_is_null(df)) {
/* A default may name ARGs declared before it (ARG BASE=golang:${GO}). */
const char *raw = dk_value(d, df);
value = raw ? dk_expand(d, d->local, raw) : NULL;
} else {
const dk_var_t *g = dk_find(d->global, name, strlen(name));
value = g ? g->value : NULL;
value = raw ? dk_expand(d, *list, raw, &prov) : NULL;
if (value) {
prov.dflt = dk_names_add(d, prov.dflt, name, nl);
}
} else if (d->stage >= 0) {
const dk_var_t *g = dk_find(d->global, name, nl);
if (g) {
value = g->value;
prov = g->prov;
}
}
dk_set(d, &d->local, name, value);
dk_set(d, list, name, value, true, prov);
}
static void dk_env(dk_t *d, TSNode ins) {
@@ -498,7 +819,9 @@ static void dk_env(dk_t *d, TSNode ins) {
}
const char *raw = ts_node_is_null(vl) ? NULL : dk_value(d, vl);
const char *name = dk_text(d, nm);
dk_set(d, &d->local, name, raw ? dk_expand(d, d->local, raw) : NULL);
dk_prov_t prov = {0};
const char *value = raw ? dk_expand(d, d->local, raw, &prov) : NULL;
dk_set(d, &d->local, name, value, false, prov);
dk_envkey_t *k = d->stage < DK_MAX_STAGES ? cbm_arena_alloc(d->a, sizeof(*k)) : NULL;
if (k && name) {
*k = (dk_envkey_t){name, dk_line(pair), d->st[d->stage].envs};
@@ -515,7 +838,9 @@ static void dk_from(dk_t *d, TSNode ins) {
spec = ts_node_named_child(ins, i);
}
}
const char *ref = ts_node_is_null(spec) ? NULL : dk_expand(d, d->global, dk_text(d, spec));
dk_prov_t prov = {0};
const char *ref =
ts_node_is_null(spec) ? NULL : dk_expand(d, d->global, dk_text(d, spec), &prov);
d->stage++;
d->local = NULL;
d->workdir = "/";
@@ -523,7 +848,7 @@ static void dk_from(dk_t *d, TSNode ins) {
d->cmd.set = false;
bool stage = ref && dk_is_stage(d, ref);
if (ref && !stage) {
dk_link(d, ref, "from", dk_line(ins));
dk_link(d, ref, "from", dk_line(ins), prov);
}
if (d->stage < DK_MAX_STAGES) {
d->st[d->stage].parent = stage ? dk_stage_of(d, ref) : -1;
@@ -605,7 +930,7 @@ static int dk_copy_json(dk_t *d, const char *p, const char **out, int max) {
if (yyjson_is_arr(arr)) {
for (yyjson_arr_iter_init(arr, &it); n < max && (v = yyjson_arr_iter_next(&it));) {
const char *s = yyjson_get_str(v);
out[n++] = s ? dk_expand(d, d->local, cbm_arena_strdup(d->a, s)) : NULL;
out[n++] = s ? dk_expand(d, d->local, cbm_arena_strdup(d->a, s), NULL) : NULL;
}
}
if (doc) {
@@ -639,11 +964,12 @@ static void dk_copy(dk_t *d, TSNode ins) {
if (l > sizeof(FROM) - SKIP_ONE && strncmp(p, FROM, sizeof(FROM) - SKIP_ONE) == 0) {
const char *v =
cbm_arena_strndup(d->a, p + sizeof(FROM) - SKIP_ONE, l - (sizeof(FROM) - SKIP_ONE));
const char *ref = v ? dk_expand(d, d->local, v) : NULL;
dk_prov_t prov = {0};
const char *ref = v ? dk_expand(d, d->local, v, &prov) : NULL;
c->has_from = true;
c->from = ref;
if (ref && ref[0] && !dk_is_stage(d, ref)) {
dk_link(d, ref, "copy-from", dk_line(ins));
dk_link(d, ref, "copy-from", dk_line(ins), prov);
}
}
p += l;
@@ -652,7 +978,7 @@ static void dk_copy(dk_t *d, TSNode ins) {
while (!json && *p && nw < DK_MAX_WORDS) {
size_t l = strcspn(p, " \t");
const char *w = cbm_arena_strndup(d->a, p, l);
words[nw++] = w && strncmp(w, "<<", PAIR_LEN) != 0 ? dk_expand(d, d->local, w) : NULL;
words[nw++] = w && strncmp(w, "<<", PAIR_LEN) != 0 ? dk_expand(d, d->local, w, NULL) : NULL;
p += l;
p += strspn(p, " \t");
}
@@ -1023,8 +1349,8 @@ static void dk_go_builds(dk_t *d, const char **w, int n) {
if (!b) {
return;
}
*b = (dk_build_t){.out = out ? dk_expand(d, d->local, out) : NULL,
.pkg = dk_expand(d, d->local, pkg ? pkg : "."),
*b = (dk_build_t){.out = out ? dk_expand(d, d->local, out, NULL) : NULL,
.pkg = dk_expand(d, d->local, pkg ? pkg : ".", NULL),
.workdir = d->workdir,
.next = d->st[d->stage].builds};
d->st[d->stage].builds = b;
@@ -1066,7 +1392,7 @@ static void dk_workdir(dk_t *d, TSNode ins) {
continue;
}
const char *raw = dk_text(d, pn);
const char *v = raw ? dk_expand(d, d->local, raw) : NULL;
const char *v = raw ? dk_expand(d, d->local, raw, NULL) : NULL;
const char *w = v ? v : raw;
if (w) {
d->workdir = dk_absolute(d, w, d->workdir);
@@ -1431,11 +1757,31 @@ static void dk_file(dk_t *d, TSNode root) {
dk_entry(d, ins, &d->cmd);
}
}
dk_refs_link(d);
dk_spawn(d);
dk_envs(d);
dk_image_set(d);
}
/* A build's view of the Dockerfile: only what decides its image references
* (ARG, ENV, FROM, COPY --from) is read, under the build's arguments. */
static void dk_file_build(dk_t *d, TSNode root) {
for (uint32_t i = 0; i < ts_node_named_child_count(root); i++) {
TSNode ins = ts_node_named_child(root, i);
if (dk_type(ins, "arg_instruction")) {
dk_arg(d, ins);
} else if (dk_type(ins, "env_instruction")) {
dk_env(d, ins);
} else if (dk_type(ins, "from_instruction")) {
dk_from(d, ins);
} else if ((dk_type(ins, "copy_instruction") || dk_type(ins, "add_instruction")) &&
d->stage >= 0) {
dk_copy(d, ins);
}
}
dk_refs_link_build(d);
}
static char *dk_read(const char *path, size_t *len) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
@@ -1545,12 +1891,15 @@ static void dk_build_site(const cbm_build_site_t *s, void *ud) {
char esc_img[CBM_SZ_1K];
cbm_json_escape(esc_file, (int)sizeof(esc_file), b->file);
cbm_json_escape(esc_img, (int)sizeof(esc_img), s->image);
char props[CBM_SZ_4K];
snprintf(props, sizeof(props),
"{\"via\":\"build\",\"tool\":\"%s\",\"file\":\"%s\",\"image\":\"%s\",\"line\":%d}",
s->via, esc_file, esc_img, s->line);
cbm_gbuf_insert_edge(b->ctx->gbuf, img, mod->id, "DEPENDS_ON", props);
b->edges++;
const char *props = cbm_arena_sprintf(
b->a,
"{\"via\":\"build\",\"tool\":\"%s\",\"file\":\"%s\",\"image\":\"%s\",\"line\":%d%s%s}",
s->via, esc_file, esc_img, s->line, s->build_args ? ",\"build_args\":" : "",
s->build_args ? s->build_args : "");
if (props) {
cbm_gbuf_insert_edge(b->ctx->gbuf, img, mod->id, "DEPENDS_ON", props);
b->edges++;
}
}
typedef struct {
@@ -1599,6 +1948,41 @@ static void dk_compose_connects(dk_compose_t *c, const cbm_compose_service_t *s,
* DEFINES; it DEPENDS_ON the services it depends on / links, USAGE
* {ref:image} its image, DEPENDS_ON {via:build} the Dockerfile it builds and
* CONNECTS_TO the services its environment addresses. */
/* The image a service runs: USAGE {ref: image, image[, default_of]} -- the
* variables whose inline default named it. When the project's .env replaced
* a default, the edge carries {override_of, override_source: ".env"}, and
* the image the defaults alone name is kept: on the same Image as
* `default_image`, else its own USAGE {ref: image_default, default_of}, a
* reference the service does not run. */
static void dk_compose_image(dk_compose_t *c, const cbm_compose_service_t *s, int64_t self) {
int64_t img = cbm_pipeline_image_node(c->ctx, s->image);
int64_t dimg = s->default_image ? cbm_pipeline_image_node(c->ctx, s->default_image) : 0;
char esc[CBM_SZ_1K];
char desc[CBM_SZ_1K];
cbm_json_escape(esc, (int)sizeof(esc), s->image);
cbm_json_escape(desc, (int)sizeof(desc), s->default_image ? s->default_image : "");
const char *props =
cbm_arena_sprintf(c->a, "{\"ref\":\"image\",\"image\":\"%s\"%s%s%s%s%s%s%s}", esc,
s->image_default_of ? ",\"default_of\":" : "",
s->image_default_of ? s->image_default_of : "",
s->image_override_of ? ",\"override_of\":" : "",
s->image_override_of ? s->image_override_of : "",
s->image_override_of ? ",\"override_source\":\".env\"" : "",
dimg > 0 && dimg == img ? ",\"default_image\":\"" : "",
dimg > 0 && dimg == img ? cbm_arena_sprintf(c->a, "%s\"", desc) : "");
if (props) {
dk_compose_edge(c, self, img, "USAGE", props);
}
if (dimg > 0 && dimg != img && s->image_override_of) {
const char *dprops = cbm_arena_sprintf(
c->a, "{\"ref\":\"image_default\",\"image\":\"%s\",\"default_of\":%s}", desc,
s->image_override_of);
if (dprops) {
dk_compose_edge(c, self, dimg, "USAGE", dprops);
}
}
}
static void dk_compose_service(const cbm_compose_service_t *s, void *ud) {
dk_compose_t *c = ud;
const char *qn = dk_compose_qn(c, s->name);
@@ -1635,11 +2019,7 @@ static void dk_compose_service(const cbm_compose_service_t *s, void *ud) {
}
dk_compose_connects(c, s, self->id);
if (s->image) {
char esc[CBM_SZ_1K];
cbm_json_escape(esc, (int)sizeof(esc), s->image);
char props[CBM_SZ_2K];
snprintf(props, sizeof(props), "{\"ref\":\"image\",\"image\":\"%s\"}", esc);
dk_compose_edge(c, self->id, cbm_pipeline_image_node(c->ctx, s->image), "USAGE", props);
dk_compose_image(c, s, self->id);
}
if (s->dockerfile) {
const char *mqn = cbm_fqn_module(c->a, c->ctx->project_name, s->dockerfile);
@@ -1706,6 +2086,152 @@ static int dk_link_builds(cbm_pipeline_ctx_t *ctx, CBMArena *a) {
return b.edges;
}
static bool dk_prop(const char *props, const char *key, char *out, size_t out_sz);
static void *dk_grow(CBMArena *a, void *v, int n, int *cap, size_t size);
/* A build that passes build arguments (compose build.args, docker build
* --build-arg; on its build edge as `build_args`). */
typedef struct {
int64_t img;
int64_t module;
const char *dockerfile;
const dk_var_t *args;
const char *props; /* "tool":..,"file":..,"build_line":N */
} dk_argbuild_t;
/* The build arguments of build edge properties `props` (NULL: none). */
static const dk_var_t *dk_build_args_of(CBMArena *a, const char *props) {
if (!props || !strstr(props, "\"build_args\"")) {
return NULL;
}
yyjson_doc *doc = yyjson_read(props, strlen(props), 0);
yyjson_val *args = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), "build_args") : NULL;
dk_var_t *list = NULL;
yyjson_obj_iter it;
yyjson_val *k;
if (yyjson_is_obj(args)) {
yyjson_obj_iter_init(args, &it);
while ((k = yyjson_obj_iter_next(&it))) {
const char *v = yyjson_get_str(yyjson_obj_iter_get_val(k));
dk_var_t *var = v ? cbm_arena_alloc(a, sizeof(*var)) : NULL;
if (var) {
*var = (dk_var_t){.name = cbm_arena_strdup(a, yyjson_get_str(k)),
.value = cbm_arena_strdup(a, v),
.arg = true,
.next = list};
list = var;
}
}
}
if (doc) {
yyjson_doc_free(doc);
}
return list;
}
/* Every build edge (Image -DEPENDS_ON {via: build}-> Dockerfile) that passes
* build arguments, into out (arena); returns how many. */
static int dk_argbuilds(cbm_pipeline_ctx_t *ctx, CBMArena *a, dk_argbuild_t **out) {
const cbm_gbuf_node_t **imgs = NULL;
int ni = 0;
*out = NULL;
if (cbm_gbuf_find_by_label(ctx->gbuf, "Image", &imgs, &ni) != 0) {
return 0;
}
int n = 0;
int cap = 0;
for (int i = 0; i < ni; i++) {
const cbm_gbuf_edge_t **es = NULL;
int ne = 0;
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, imgs[i]->id, "DEPENDS_ON", &es, &ne) !=
0) {
continue;
}
for (int j = 0; j < ne; j++) {
const char *p = es[j]->properties_json;
char via[CBM_SZ_32];
const dk_var_t *args = NULL;
if (!dk_prop(p, "via", via, sizeof(via)) || strcmp(via, "build") != 0 ||
!(args = dk_build_args_of(a, p))) {
continue;
}
const cbm_gbuf_node_t *mod = cbm_gbuf_find_by_id(ctx->gbuf, es[j]->target_id);
char tool[CBM_SZ_64];
char file[CBM_SZ_1K];
char esc[CBM_SZ_2K];
if (!mod || !mod->file_path || !dk_prop(p, "tool", tool, sizeof(tool)) ||
!dk_prop(p, "file", file, sizeof(file))) {
continue;
}
const char *line = strstr(p, "\"line\":");
if (n == cap) {
*out = dk_grow(a, *out, n, &cap, sizeof(**out));
if (!*out) {
return 0;
}
}
cbm_json_escape(esc, (int)sizeof(esc), file);
(*out)[n++] = (dk_argbuild_t){
.img = imgs[i]->id,
.module = mod->id,
.dockerfile = cbm_arena_strdup(a, mod->file_path),
.args = args,
.props = cbm_arena_sprintf(a, "\"tool\":\"%s\",\"file\":\"%s\",\"build_line\":%d",
tool, esc, line ? atoi(line + strlen("\"line\":")) : 0)};
}
}
return n;
}
/* Each build passing build arguments reads its Dockerfile in its own view
* (dk_file_build): what those arguments change, it builds FROM. Image-sourced
* DEPENDS_ON edges, recomputed whole every run (CBM_BUILD_EDGE_SOURCE). */
static int dk_link_build_args(cbm_pipeline_ctx_t *ctx, CBMArena *a) {
dk_argbuild_t *bs = NULL;
int n = ctx->repo_path ? dk_argbuilds(ctx, a, &bs) : 0;
if (n == 0) {
return 0;
}
TSParser *parser = ts_parser_new();
if (!parser || !ts_parser_set_language(parser, cbm_ts_language(CBM_LANG_DOCKERFILE))) {
if (parser) {
ts_parser_delete(parser);
}
return 0;
}
int edges = 0;
for (int i = 0; i < n; i++) {
char path[CBM_SZ_4K];
snprintf(path, sizeof(path), "%s/%s", ctx->repo_path, bs[i].dockerfile);
size_t len = 0;
char *src = dk_read(path, &len);
if (!src) {
continue;
}
dk_uncomment(src, len);
TSTree *tree = ts_parser_parse_string(parser, NULL, src, (uint32_t)len);
if (tree) {
dk_t d = {.ctx = ctx,
.a = a,
.src = src,
.file = bs[i].dockerfile,
.module = bs[i].module,
.stage = -1,
.dir = "",
.workdir = "/",
.build_args = bs[i].args,
.built = bs[i].img,
.build_props = bs[i].props};
dk_file_build(&d, ts_tree_root_node(tree));
edges += d.edges;
ts_tree_delete(tree);
}
cbm_free(CBM_MEM_CLASS_OTHER, src);
}
ts_parser_delete(parser);
return edges;
}
static bool dk_node_before(const cbm_gbuf_node_t *a, const cbm_gbuf_node_t *b) {
int c = strcmp(a->file_path, b->file_path);
return c < 0 || (c == 0 && a->start_line < b->start_line);
@@ -1891,17 +2417,19 @@ static void dk_hub_push(CBMArena *a, dk_hub_edges_t *l, const cbm_gbuf_edge_t *e
* USAGE {ref:image}, or DEPENDS_ON {via:from|copy-from}? */
static bool dk_hub_ref(const char *type, const char *p) {
char v[CBM_SZ_32];
if (strcmp(type, "USAGE") == 0) {
if (strcmp(type, "USAGE") == 0 || strcmp(type, "DEPLOYS") == 0) {
return dk_prop(p, "ref", v, sizeof(v)) && strcmp(v, "image") == 0;
}
return dk_prop(p, "via", v, sizeof(v)) &&
(strcmp(v, "from") == 0 || strcmp(v, "copy-from") == 0);
}
/* The edges of Image `img`: build edges out (DEPENDS_ON {via:build}) and
* references in, each with the tag of its `image` reference. */
/* The edges of Image `img`: build edges out (DEPENDS_ON {via:build}), the
* base images a build's arguments chose out (DEPENDS_ON {via: from |
* copy-from, build_line}: they follow their build) and references in, each
* with the tag of its `image` reference. */
static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img,
dk_hub_edges_t *builds, dk_hub_edges_t *refs) {
dk_hub_edges_t *builds, dk_hub_edges_t *refs, dk_hub_edges_t *follows) {
const cbm_gbuf_edge_t **es = NULL;
int n = 0;
char ref[CBM_SZ_1K];
@@ -1909,8 +2437,13 @@ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img,
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, img, "DEPENDS_ON", &es, &n) == 0) {
for (int i = 0; i < n; i++) {
char via[CBM_SZ_32];
if (!dk_prop(es[i]->properties_json, "via", via, sizeof(via)) ||
strcmp(via, "build") != 0) {
if (!dk_prop(es[i]->properties_json, "via", via, sizeof(via))) {
continue;
}
if (strcmp(via, "build") != 0) {
if (strstr(es[i]->properties_json, "\"build_line\":")) {
dk_hub_push(a, follows, es[i], "");
}
continue;
}
tag[0] = '\0';
@@ -1921,7 +2454,7 @@ static void dk_hub_collect(cbm_pipeline_ctx_t *ctx, CBMArena *a, int64_t img,
}
atomic_fetch_add_explicit(&g_dk_hub_edges, (uint64_t)n, memory_order_relaxed);
}
static const char *const REF_TYPES[] = {"USAGE", "DEPENDS_ON"};
static const char *const REF_TYPES[] = {"USAGE", "DEPENDS_ON", "DEPLOYS"};
for (size_t t = 0; t < sizeof(REF_TYPES) / sizeof(REF_TYPES[0]); t++) {
if (cbm_gbuf_find_edges_by_target_type(ctx->gbuf, img, REF_TYPES[t], &es, &n) != 0) {
continue;
@@ -2122,6 +2655,27 @@ static int dk_hub_move(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_hub_edge_t
return SKIP_ONE;
}
static int dk_prop_int(const char *props, const char *key) {
char pat[CBM_SZ_64];
snprintf(pat, sizeof(pat), "\"%s\":", key);
const char *p = props ? strstr(props, pat) : NULL;
return p ? atoi(p + strlen(pat)) : -SKIP_ONE;
}
/* Does base-image edge `f` come from build edge `b`: one source, and its
* tool / file / build_line those of the build? */
static bool dk_hub_follows(const dk_hub_edge_t *f, const dk_hub_edge_t *b) {
char ft[CBM_SZ_64];
char bt[CBM_SZ_64];
char ff[CBM_SZ_1K];
char bf[CBM_SZ_1K];
return f->src == b->src && dk_prop(f->props, "tool", ft, sizeof(ft)) &&
dk_prop(b->props, "tool", bt, sizeof(bt)) && strcmp(ft, bt) == 0 &&
dk_prop(f->props, "file", ff, sizeof(ff)) && dk_prop(b->props, "file", bf, sizeof(bf)) &&
strcmp(ff, bf) == 0 &&
dk_prop_int(f->props, "build_line") == dk_prop_int(b->props, "line");
}
/* One repository's Images (imgs[0..n)): split it by component when it is
* built from several Dockerfiles, else keep (or bring back) everything on
* the repository Image. Returns the edges moved. */
@@ -2129,10 +2683,11 @@ static int dk_hub_repo(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_img_t *img
dk_drop_t *drop) {
dk_hub_edges_t builds = {0};
dk_hub_edges_t refs = {0};
dk_hub_edges_t follows = {0};
bool has_sub = false;
for (int i = 0; i < n; i++) {
has_sub = has_sub || strcmp(imgs[i].name, imgs[i].repo) != 0;
dk_hub_collect(ctx, a, imgs[i].id, &builds, &refs);
dk_hub_collect(ctx, a, imgs[i].id, &builds, &refs, &follows);
}
const char **comps = NULL;
size_t pre = 0;
@@ -2158,6 +2713,11 @@ static int dk_hub_repo(cbm_pipeline_ctx_t *ctx, CBMArena *a, const dk_img_t *img
int k = builds.v[i].comp;
int64_t src = sub && k >= 0 && sub[k] > 0 ? sub[k] : repo_img;
moved += dk_hub_move(ctx, a, &builds.v[i], src, builds.v[i].tgt, drop);
for (int f = 0; f < follows.n; f++) {
if (dk_hub_follows(&follows.v[f], &builds.v[i])) {
moved += dk_hub_move(ctx, a, &follows.v[f], src, follows.v[f].tgt, drop);
}
}
}
for (int i = 0; i < refs.n; i++) {
int k = sub ? dk_hub_match(refs.v[i].tag, comps, nc) : -1;
@@ -2274,6 +2834,7 @@ void cbm_pipeline_pass_docker(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fi
cbm_ht_free(ix.dirs);
}
edges += dk_link_builds(ctx, &arena);
edges += dk_link_build_args(ctx, &arena);
edges += dk_image_hubs(ctx, &arena);
cbm_arena_destroy(&arena);
if (edges > 0) {
+1420 -50
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -3433,7 +3433,10 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
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());
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));
/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
* service signal lives in the callee_name. The registry can mis-resolve
+40
View File
@@ -2115,6 +2115,37 @@ static const cbm_gbuf_node_t *resolve_sibling_file(const cbm_pipeline_ctx_t *ctx
return found;
}
/* The File an explicit relative import (`./x`, `../x`) names exactly, for
* files whose File QN differs from the module QN Strategy 1 tries
* (`../styles/global.css`, `../layouts/Layout.astro`, #2558). Resolved in
* stack buffers by cbm_repo_rel_join, which refuses a path above the
* repository root. */
static const cbm_gbuf_node_t *resolve_relative_file(const cbm_pipeline_ctx_t *ctx,
const char *source_rel, const char *module_path,
bool php_only) {
if (!source_rel || !module_path || module_path[0] != '.' ||
!(module_path[1] == '/' || (module_path[1] == '.' && module_path[2] == '/'))) {
return NULL;
}
char dir[PKGMAP_PATH_BUF];
int dir_len = snprintf(dir, sizeof(dir), "%s", source_rel);
if (dir_len < 0 || (size_t)dir_len >= sizeof(dir)) {
return NULL;
}
char *slash = strrchr(dir, '/');
if (slash) {
*slash = '\0';
} else {
dir[0] = '\0';
}
char path[PKGMAP_PATH_BUF];
if (!cbm_repo_rel_join(dir, module_path, path, sizeof(path))) {
return NULL;
}
const cbm_gbuf_node_t *file = cbm_pipeline_file_node(ctx->gbuf, ctx->project_name, path);
return file && (!php_only || node_in_php_file(file)) ? file : NULL;
}
/* ── PHP PSR-4 class imports (#1186) ──────────────────────────────── */
typedef enum {
@@ -2389,6 +2420,15 @@ static const cbm_gbuf_node_t *resolve_import_strategies(
}
}
/* Strategy 1c: relative non-module files can have a File QN that does not
* match the module QN above. Resolve the exact file after module and
* sibling lookup. */
const cbm_gbuf_node_t *rel_file =
resolve_relative_file(ctx, source_rel, imp->module_path, php_only);
if (rel_file) {
return rel_file;
}
/* Strategy 2: namespace map. `using App.Utils`, `import com.example.Foo`,
* `use App\Utils\Helper` name a NAMESPACE (or a member of it) that the
* path-based QN cannot express. Try the full module path and progressively
+154 -18
View File
@@ -376,10 +376,12 @@ typedef struct {
const char *ci; /* static: the CI running the command; NULL: none */
const char *job; /* pass-arena: its job; NULL: none */
bool chart;
bool spawn; /* SPAWNS the script `tgt` / the Process `ext`, not DEPLOYS */
bool make; /* SPAWNS the rule of target `ext` that makefile `path` reads */
bool build; /* the image `ext` is built from the Dockerfile `path` */
bool http; /* HTTP_CALLS the URL `ext` (scheme://host/path) with method `path` */
bool spawn; /* SPAWNS the script `tgt` / the Process `ext`, not DEPLOYS */
bool make; /* SPAWNS the rule of target `ext` that makefile `path` reads */
bool build; /* the image `ext` is built from the Dockerfile `path` */
bool http; /* HTTP_CALLS the URL `ext` (scheme://host/path) with method `path` */
const char *build_args; /* pass-arena, build: --build-arg as a JSON object; NULL: none */
const char *default_of; /* pass-arena, build: JSON array of the ${X:-d} defaults naming `ext` */
} sh_hit_t;
typedef struct {
@@ -413,9 +415,10 @@ typedef struct {
const char *s; /* scalar value; NULL: unknown */
const char **items; /* array items (an item NULL: unknown); NULL: no array */
int n;
const char *dflt_of; /* JSON array: the variables whose ${X:-d} default made it */
} sh_val_t;
static const sh_val_t SH_UNKNOWN_VAL = {NULL, NULL, 0};
static const sh_val_t SH_UNKNOWN_VAL = {NULL, NULL, 0, NULL};
typedef enum { SH_FREE, SH_AT, SH_LOST } sh_cwd_mode_t;
@@ -464,8 +467,38 @@ typedef struct {
sh_cwd_t *dirs; /* pushd stack */
int ndirs;
int cap_dirs;
/* While set, an evaluation adds to dflt_acc (a JSON array) the variables
* whose ${X:-d} default supplied its value. */
bool dflt_track;
const char *dflt_acc;
} sh_t;
/* JSON array `list` (NULL: empty) with identifier `name` appended once. */
static const char *sh_names_add(CBMArena *a, const char *list, const char *name, size_t nl) {
if (!list) {
return cbm_arena_sprintf(a, "[\"%.*s\"]", (int)nl, name);
}
char quoted[CBM_SZ_256];
snprintf(quoted, sizeof(quoted), "\"%.*s\"", (int)nl, name);
if (strstr(list, quoted)) {
return list;
}
return cbm_arena_sprintf(a, "%.*s,%s]", (int)(strlen(list) - SKIP_ONE), list, quoted);
}
/* JSON array `a` with the names of JSON array `b` appended once. */
static const char *sh_names_union(CBMArena *ar, const char *a, const char *b) {
for (const char *p = b ? strchr(b, '"') : NULL; p;) {
const char *e = strchr(p + SKIP_ONE, '"');
if (!e) {
break;
}
a = sh_names_add(ar, a, p + SKIP_ONE, (size_t)(e - p - SKIP_ONE));
p = strchr(e + SKIP_ONE, '"');
}
return a;
}
static void sh_set(sh_t *s, const char *name, const sh_val_t *v, bool local) {
if (!sh_grow((void **)&s->undo, &s->cap_undo, s->nundo, sizeof(*s->undo))) {
return;
@@ -622,14 +655,14 @@ static const sh_val_t *sh_lookup(sh_t *s, const char *name, bool *found) {
}
sh_val_t *v = cbm_arena_alloc(s->a, sizeof(*v));
if (v) {
*v = (sh_val_t){s->script, NULL, 0};
*v = (sh_val_t){s->script, NULL, 0, NULL};
}
return v ? v : &SH_UNKNOWN_VAL;
}
if (strcmp(name, "PWD") == 0) {
sh_val_t *v = cbm_arena_alloc(s->a, sizeof(*v));
if (v) {
*v = (sh_val_t){sh_cwd_value(s), NULL, 0};
*v = (sh_val_t){sh_cwd_value(s), NULL, 0, NULL};
}
return v ? v : &SH_UNKNOWN_VAL;
}
@@ -745,20 +778,29 @@ static const char *sh_eval_expansion(sh_t *s, TSNode n) {
val = v->n > 0 ? v->items[0] : "";
}
if (!op) {
if (found && s->dflt_track) {
s->dflt_acc = sh_names_union(s->a, s->dflt_acc, v->dflt_of);
}
return found ? val : NULL;
}
bool colon = op[0] == ':';
bool use_default = !found || (colon && val && !val[0]);
if (!use_default) {
if (s->dflt_track) {
s->dflt_acc = sh_names_union(s->a, s->dflt_acc, v->dflt_of);
}
return val;
}
sh_str_t b = {0};
sh_concat(s, &b, n, op_end, close, true);
const char *d = sh_str_done(&b);
if (s->dflt_track) {
s->dflt_acc = sh_names_add(s->a, s->dflt_acc, nm, strlen(nm));
}
if (d && strchr(op, '=')) {
sh_val_t *nv = cbm_arena_alloc(s->a, sizeof(*nv));
if (nv) {
*nv = (sh_val_t){d, NULL, 0};
*nv = (sh_val_t){d, NULL, 0, sh_names_add(s->a, NULL, nm, strlen(nm))};
sh_set(s, nm, nv, false);
}
}
@@ -943,6 +985,9 @@ static const char *sh_eval(sh_t *s, TSNode n) {
}
bool found = false;
const sh_val_t *val = sh_lookup(s, nm, &found);
if (found && s->dflt_track) {
s->dflt_acc = sh_names_union(s->a, s->dflt_acc, val->dflt_of);
}
return found ? val->s : NULL;
}
if (strcmp(t, "expansion") == 0) {
@@ -1319,6 +1364,8 @@ static void sh_hit_push(sh_t *s, TSNode cmd, sh_hit_t x) {
x.line = (int)ts_node_start_point(cmd).row + SKIP_ONE + s->line_base;
x.ext = x.ext ? cbm_arena_strdup(&h->arena, x.ext) : NULL;
x.path = x.path ? cbm_arena_strdup(&h->arena, x.path) : NULL;
x.build_args = x.build_args ? cbm_arena_strdup(&h->arena, x.build_args) : NULL;
x.default_of = x.default_of ? cbm_arena_strdup(&h->arena, x.default_of) : NULL;
x.ci = s->ci;
x.job = s->job ? cbm_arena_strdup(&h->arena, s->job) : NULL;
h->items[h->count++] = x;
@@ -2268,7 +2315,11 @@ static void sh_assign(sh_t *s, TSNode n, bool local) {
if (!nm || !v) {
return;
}
*v = (sh_val_t){"", NULL, 0};
*v = (sh_val_t){"", NULL, 0, NULL};
bool track = s->dflt_track;
const char *acc = s->dflt_acc;
s->dflt_track = true;
s->dflt_acc = NULL;
if (sh_type(value, "array")) {
uint32_t cnt = ts_node_named_child_count(value);
const char **items = cbm_arena_alloc(s->a, (cnt ? cnt : SKIP_ONE) * sizeof(*items));
@@ -2282,10 +2333,13 @@ static void sh_assign(sh_t *s, TSNode n, bool local) {
items[k++] = sh_eval_word(s, c);
}
}
*v = (sh_val_t){k > 0 ? items[0] : "", items, k};
*v = (sh_val_t){k > 0 ? items[0] : "", items, k, NULL};
} else if (!ts_node_is_null(value)) {
v->s = sh_eval(s, value);
}
v->dflt_of = s->dflt_acc;
s->dflt_track = track;
s->dflt_acc = acc;
bool append = false;
for (uint32_t i = 0; i < ts_node_child_count(n); i++) {
TSNode c = ts_node_child(n, i);
@@ -2434,6 +2488,64 @@ static const char *sh_image_repo(sh_t *s, TSNode cmd, const sh_argv_t *a, int i)
return sh_str_done(&out);
}
/* JSON object `json` (NULL: none) with build argument `kv` ("K=V") added; a
* `K` alone (the environment's value), an unknown word or a secret (by name
* or value -- never kept in the graph, never an image) adds nothing. */
static const char *sh_build_arg(sh_t *s, const char *json, const char *kv) {
const char *eq = kv ? strchr(kv, '=') : NULL;
if (!eq || eq == kv) {
return json;
}
for (const char *p = kv; p < eq; p++) {
if (!isalnum((unsigned char)*p) && *p != '_') {
return json;
}
}
const char *key = cbm_arena_strndup(s->a, kv, (size_t)(eq - kv));
if (!key || cbm_is_secret_binding(key, eq + SKIP_ONE)) {
return json;
}
/* JSON escaping writes at most 6 bytes a byte: nothing is cut */
int vn = (int)(strlen(eq + SKIP_ONE) * 6 + SKIP_ONE);
char *ev = cbm_arena_alloc(s->a, (size_t)vn);
if (!ev) {
return json;
}
cbm_json_escape(ev, vn, eq + SKIP_ONE);
const char *pair = cbm_arena_sprintf(s->a, "\"%.*s\":\"%s\"", (int)(eq - kv), kv, ev);
if (!pair) {
return json;
}
return json ? cbm_arena_sprintf(s->a, "%.*s,%s}", (int)(strlen(json) - SKIP_ONE), json, pair)
: cbm_arena_sprintf(s->a, "{%s}", pair);
}
/* The variables whose ${X:-d} default supplied argv word `i` (a JSON array,
* NULL: none): the word evaluated once more with the defaults recorded. */
static const char *sh_word_defaults(sh_t *s, TSNode cmd, const sh_argv_t *a, int i) {
uint32_t b = a->span[(size_t)i * PAIR_LEN];
uint32_t e = a->span[(size_t)i * PAIR_LEN + SKIP_ONE];
if (!memchr(s->src + b, '$', e - b)) {
return NULL;
}
TSNode w = ts_node_descendant_for_byte_range(cmd, b, e);
while (!ts_node_is_null(w) && (ts_node_start_byte(w) != b || ts_node_end_byte(w) != e)) {
w = ts_node_parent(w);
}
if (ts_node_is_null(w)) {
return NULL;
}
bool track = s->dflt_track;
const char *acc = s->dflt_acc;
s->dflt_track = true;
s->dflt_acc = NULL;
(void)sh_eval_word(s, w);
const char *out = s->dflt_acc;
s->dflt_track = track;
s->dflt_acc = acc;
return out;
}
/* docker / podman / nerdctl [buildx | image | builder] build, buildah bud:
* each tagged image is built from the Dockerfile -f names (against the cwd),
* else <context>/Dockerfile -- one file of the repository. */
@@ -2467,17 +2579,20 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) {
cbm_ht_set(s->hits->declarers, s->file, (void *)s->file);
}
int tags[SH_BUILD_TAGS];
int tag_words[SH_BUILD_TAGS];
const char *tag_vals[SH_BUILD_TAGS];
int nt = 0;
const char *dfile = NULL;
bool dfile_known = true;
const char *ctx = NULL;
bool ctx_seen = false;
const char *args = NULL;
for (; i < a->n; i++) {
const char *w = a->v[i];
if (w && (sh_eq(w, "-t") || sh_eq(w, "--tag")) && i + SKIP_ONE < a->n) {
if (nt < SH_BUILD_TAGS) {
tags[nt] = i + SKIP_ONE;
tag_words[nt] = i + SKIP_ONE;
tag_vals[nt++] = a->v[i + SKIP_ONE];
}
i++;
@@ -2485,9 +2600,15 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) {
dfile = a->v[i + SKIP_ONE];
dfile_known = dfile != NULL;
i++;
} else if (w && sh_eq(w, "--build-arg") && i + SKIP_ONE < a->n) {
args = sh_build_arg(s, args, a->v[i + SKIP_ONE]);
i++;
} else if (w && strncmp(w, "--build-arg=", strlen("--build-arg=")) == 0) {
args = sh_build_arg(s, args, w + strlen("--build-arg="));
} else if (w && strncmp(w, "--tag=", strlen("--tag=")) == 0) {
if (nt < SH_BUILD_TAGS && w[strlen("--tag=")]) {
tags[nt] = -SKIP_ONE;
tag_words[nt] = i;
tag_vals[nt++] = w + strlen("--tag=");
}
} else if (w && strncmp(w, "--file=", strlen("--file=")) == 0) {
@@ -2513,7 +2634,12 @@ static void sh_build(sh_t *s, TSNode cmd, const sh_argv_t *a) {
}
if (img && img[0]) {
sh_hit_push(s, cmd,
(sh_hit_t){.via = "docker-build", .ext = img, .path = hit, .build = true});
(sh_hit_t){.via = "docker-build",
.ext = img,
.path = hit,
.build = true,
.build_args = args,
.default_of = sh_word_defaults(s, cmd, a, tag_words[k])});
}
}
}
@@ -3305,8 +3431,8 @@ static void sh_makefile(sh_pass_t *p, const char *rel, const char *src, size_t l
sh_val_t *root = cbm_arena_alloc(&a, sizeof(*root));
sh_val_t *cur = cbm_arena_alloc(&a, sizeof(*cur));
if (root && cur) {
*root = (sh_val_t){SH_ROOT, NULL, 0};
*cur = (sh_val_t){sh_anchor(&a, s.sdir), NULL, 0};
*root = (sh_val_t){SH_ROOT, NULL, 0, NULL};
*cur = (sh_val_t){sh_anchor(&a, s.sdir), NULL, 0, NULL};
cbm_ht_set(s.env, "__cbm_root", root);
if (m.makefile) {
cbm_ht_set(s.env, "__cbm_curdir", cur);
@@ -3647,15 +3773,25 @@ static int sh_link_build(cbm_pipeline_ctx_t *ctx, const sh_hit_t *x, CBMHashTabl
char esc_file[CBM_SZ_1K];
char esc_img[CBM_SZ_1K];
char ci[CBM_SZ_1K];
char props[CBM_SZ_4K];
cbm_json_escape(esc_file, (int)sizeof(esc_file), x->file);
cbm_json_escape(esc_img, (int)sizeof(esc_img), x->ext);
sh_ci_props(x, ci, sizeof(ci));
snprintf(props, sizeof(props),
/* sized to what it holds: default_of and build_args are never cut */
size_t pn = strlen(esc_file) + strlen(esc_img) + strlen(ci) +
(x->default_of ? strlen(x->default_of) : 0) +
(x->build_args ? strlen(x->build_args) : 0) + CBM_SZ_256;
char *props = cbm_alloc(CBM_MEM_CLASS_OTHER, pn);
if (!props) {
return 0;
}
snprintf(props, pn,
"{\"via\":\"build\",\"tool\":\"docker-build\",\"file\":\"%s\",\"image\":\"%s\"%s,"
"\"line\":%d}",
esc_file, esc_img, ci, x->line);
"\"line\":%d%s%s%s%s}",
esc_file, esc_img, ci, x->line, x->default_of ? ",\"default_of\":" : "",
x->default_of ? x->default_of : "", x->build_args ? ",\"build_args\":" : "",
x->build_args ? x->build_args : "");
cbm_gbuf_insert_edge(ctx->gbuf, img, mod, "DEPENDS_ON", props);
cbm_free(CBM_MEM_CLASS_OTHER, props);
return SKIP_ONE;
}
@@ -3852,7 +3988,7 @@ typedef struct {
static void sh_ci_var(sh_t *s, const char *name, const char *value) {
sh_val_t *v = value ? cbm_arena_alloc(s->a, sizeof(*v)) : NULL;
if (v) {
*v = (sh_val_t){value, NULL, 0};
*v = (sh_val_t){value, NULL, 0, NULL};
}
sh_set(s, name, v ? v : &SH_UNKNOWN_VAL, false);
}
+520
View File
@@ -22,13 +22,16 @@
#include "foundation/log.h"
#include "foundation/mem_core.h"
#include "foundation/str_util.h"
#include "foundation/json_writer.h" /* cbm_json_writer_allocator */
#include "graph_buffer/graph_buffer.h"
#include "pipeline/helm_template.h"
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "cbm.h"
#include "helpers.h"
#include "lang_specs.h"
#include <ctype.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
@@ -249,6 +252,522 @@ static bool tf_plain_name(const char *s) {
}
/* One `resource "helm_release" "<name>" { ... }` block. */
/* ── helm_release values (for pass_k8s.c's release images) ────────── */
/* The expression inside wrapper nodes. */
static TSNode tf_unwrap(TSNode n) {
while (tf_type(n, "expression") || tf_type(n, "literal_value") ||
tf_type(n, "collection_value") || tf_type(n, "template_expr")) {
if (ts_node_named_child_count(n) != SKIP_ONE) {
break;
}
n = ts_node_named_child(n, 0);
}
return n;
}
/* A heredoc's text (a `<<-` one dedented); NULL when it interpolates. */
static char *tf_heredoc(const tf_t *t, TSNode n) {
TSNode open = {0};
TSNode close = {0};
bool dedent = false;
for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (tf_type(c, "heredoc_start")) {
uint32_t b = ts_node_start_byte(c);
dedent = ts_node_end_byte(c) - b >= 3 && t->src[b + PAIR_LEN] == '-';
} else if (tf_type(c, "heredoc_identifier")) {
if (ts_node_is_null(open)) {
open = c;
} else {
close = c;
}
} else if (!tf_type(c, "template_literal")) {
return NULL; /* an interpolation or a directive: no literal */
}
}
if (ts_node_is_null(open) || ts_node_is_null(close)) {
return NULL;
}
const char *b = t->src + ts_node_end_byte(open);
const char *e = t->src + ts_node_start_byte(close);
const char *nl = memchr(b, '\n', (size_t)(e - b));
b = nl ? nl + SKIP_ONE : e;
while (e > b && e[-1] != '\n') {
e--; /* the closing line's indentation */
}
size_t indent = (size_t)-1;
for (const char *p = b; dedent && p < e;) {
const char *le = memchr(p, '\n', (size_t)(e - p));
le = le ? le : e;
size_t sp = 0;
while (p + sp < le && (p[sp] == ' ' || p[sp] == '\t')) {
sp++;
}
if (p + sp < le && sp < indent) {
indent = sp;
}
p = le + (le < e ? SKIP_ONE : 0);
}
if (!dedent || indent == (size_t)-1) {
indent = 0;
}
char *out = cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)(e - b) + SKIP_ONE);
size_t k = 0;
for (const char *p = b; out && p < e;) {
const char *le = memchr(p, '\n', (size_t)(e - p));
le = le ? le + SKIP_ONE : e;
size_t skip = 0;
while (skip < indent && p + skip < le && (p[skip] == ' ' || p[skip] == '\t')) {
skip++;
}
memcpy(out + k, p + skip, (size_t)(le - p) - skip);
k += (size_t)(le - p) - skip;
p = le;
}
if (out) {
out[k] = '\0';
}
return out;
}
/* HCL number literal s (digits, a fraction, an exponent) as Terraform turns a
* number into a string: exact decimal, no exponent, no trailing zeros (cty's
* big.Float Text('f', -1); 1.10 -> "1.1", 2e3 -> "2000"). False when it is
* no such literal or does not fit out. */
static bool tf_number_text(const char *s, size_t n, char *out, size_t sz) {
enum { TF_MAX_EXP = 4096 };
char dig[CBM_SZ_256];
size_t nd = 0;
long exp = 0;
bool frac = false;
size_t i = 0;
for (; i < n && (isdigit((unsigned char)s[i]) || s[i] == '.'); i++) {
if (s[i] == '.') {
if (frac) {
return false;
}
frac = true;
continue;
}
if (nd + SKIP_ONE >= sizeof(dig)) {
return false;
}
dig[nd++] = s[i];
exp -= frac ? SKIP_ONE : 0;
}
if (i < n) {
if (s[i] != 'e' && s[i] != 'E') {
return false;
}
i++;
bool neg = i < n && s[i] == '-';
i += i < n && (s[i] == '-' || s[i] == '+');
long e = 0;
if (i == n) {
return false;
}
for (; i < n; i++) {
if (!isdigit((unsigned char)s[i]) || e > TF_MAX_EXP) {
return false;
}
e = e * 10 + (s[i] - '0');
}
exp += neg ? -e : e;
}
size_t b = 0;
while (b < nd && dig[b] == '0') {
b++;
}
if (b == nd) {
return snprintf(out, sz, "0") == SKIP_ONE; /* every digit 0 (or none) */
}
size_t e = nd;
while (dig[e - SKIP_ONE] == '0') { /* stops at dig[b], which is no 0 */
e--;
exp++;
}
size_t m = e - b; /* significant digits: dig[b, e) x 10^exp */
size_t o = 0;
long point = (long)m + exp; /* digits before the decimal point */
size_t need = exp >= 0 ? m + (size_t)exp
: point > 0 ? m + SKIP_ONE
: m + PAIR_LEN + (size_t)-point;
if (need + SKIP_ONE > sz) {
return false;
}
if (point <= 0) {
out[o++] = '0';
out[o++] = '.';
for (long z = 0; z < -point; z++) {
out[o++] = '0';
}
}
for (size_t k = 0; k < m; k++) {
if (point > 0 && (long)k == point) {
out[o++] = '.';
}
out[o++] = dig[b + k];
}
for (long z = 0; z < exp; z++) {
out[o++] = '0';
}
out[o] = '\0';
return true;
}
/* An HCL literal as JSON: a string without interpolation, a number, a bool,
* null, a tuple or object of those; NULL: anything else. `num_text`: a number
* as the string Terraform makes of it (a helm_release set value is a string),
* else a float64. */
static yyjson_mut_val *tf_json_v(const tf_t *t, yyjson_mut_doc *doc, TSNode n, bool num_text) {
n = tf_unwrap(n);
if (tf_type(n, "string_lit") || tf_type(n, "quoted_template")) {
char buf[CBM_SZ_4K];
size_t len = 0;
buf[0] = '\0';
for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (tf_type(c, "quoted_template_start") || tf_type(c, "quoted_template_end")) {
continue;
}
if (!tf_type(c, "template_literal")) {
return NULL;
}
uint32_t b = ts_node_start_byte(c);
if (!tf_add(buf, sizeof(buf), &len, t->src + b, ts_node_end_byte(c) - b)) {
return NULL;
}
}
size_t k = 0; /* \" \\ \n \t */
for (size_t i = 0; i < len; i++) {
if (buf[i] == '\\' && i + SKIP_ONE < len) {
char c = buf[++i];
buf[k++] = c == 'n' ? '\n' : c == 't' ? '\t' : c;
} else {
buf[k++] = buf[i];
}
}
return yyjson_mut_strncpy(doc, buf, k);
}
if (tf_type(n, "heredoc_template")) {
char *s = tf_heredoc(t, n);
yyjson_mut_val *v = s ? yyjson_mut_strcpy(doc, s) : NULL;
cbm_free(CBM_MEM_CLASS_OTHER, s);
return v;
}
uint32_t b = ts_node_start_byte(n);
uint32_t e = ts_node_end_byte(n);
if (tf_type(n, "numeric_lit")) {
char buf[CBM_SZ_256];
if (num_text) {
return tf_number_text(t->src + b, e - b, buf, sizeof(buf)) ? yyjson_mut_strcpy(doc, buf)
: NULL;
}
snprintf(buf, sizeof(buf), "%.*s", (int)(e - b), t->src + b);
return yyjson_mut_real(doc, strtod(buf, NULL));
}
if (tf_type(n, "bool_lit")) {
return yyjson_mut_bool(doc, e - b == strlen("true") && strncmp(t->src + b, "true", 4) == 0);
}
if (tf_type(n, "null_lit")) {
return yyjson_mut_null(doc);
}
if (tf_type(n, "tuple")) {
yyjson_mut_val *arr = yyjson_mut_arr(doc);
for (uint32_t i = 0; arr && i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (tf_type(c, "tuple_start") || tf_type(c, "tuple_end")) {
continue;
}
yyjson_mut_val *v = tf_json_v(t, doc, c, num_text);
if (!v) {
return NULL;
}
yyjson_mut_arr_append(arr, v);
}
return arr;
}
if (tf_type(n, "object")) {
yyjson_mut_val *obj = yyjson_mut_obj(doc);
for (uint32_t i = 0; obj && i < ts_node_named_child_count(n); i++) {
TSNode c = ts_node_named_child(n, i);
if (!tf_type(c, "object_elem")) {
continue;
}
TSNode kn = tf_unwrap(ts_node_child_by_field_name(c, "key", (uint32_t)strlen("key")));
TSNode vn = ts_node_child_by_field_name(c, "val", (uint32_t)strlen("val"));
yyjson_mut_val *key = NULL;
if (tf_type(kn, "variable_expr") && ts_node_named_child_count(kn) == SKIP_ONE) {
TSNode id = ts_node_named_child(kn, 0);
uint32_t kb = ts_node_start_byte(id);
key = yyjson_mut_strncpy(doc, t->src + kb, ts_node_end_byte(id) - kb);
} else {
key = tf_json_v(t, doc, kn, false);
}
yyjson_mut_val *val = tf_json_v(t, doc, vn, num_text);
if (!key || !yyjson_mut_is_str(key) || !val) {
return NULL;
}
(void)yyjson_mut_obj_put(obj, key, val);
}
return obj;
}
return NULL;
}
static yyjson_mut_val *tf_json(const tf_t *t, yyjson_mut_doc *doc, TSNode n) {
return tf_json_v(t, doc, n, false);
}
static char *tf_read(const char *path, size_t *len);
/* A repository file's text (cbm_alloc; capped and padded like every file
* read; an empty file is empty text), NULL when unreadable -- or when it is
* no file discovery took in: a symlink, or a path through one, is never
* followed out of the repository. */
static char *tf_read_repo(const tf_t *t, const char *path, size_t *len) {
if (!cbm_pipeline_file_node(t->ctx->gbuf, t->ctx->project_name, path)) {
return NULL;
}
char full[CBM_SZ_4K];
int n = snprintf(full, sizeof(full), "%s/%s", t->ctx->repo_path, path);
if (n <= 0 || (size_t)n >= sizeof(full)) {
return NULL;
}
char *buf = tf_read(full, len);
cbm_path_info_t info;
if (!buf && cbm_path_info_utf8(full, &info) == CBM_PATH_INFO_OK && info.is_regular &&
!info.is_symlink && info.size == 0) {
enum { TF_EMPTY_PAD = 16 };
buf = cbm_calloc(CBM_MEM_CLASS_OTHER, TF_EMPTY_PAD);
*len = 0;
}
return buf;
}
/* A `values = [...]` item as values: a YAML string / heredoc, file(<path>)
* of a repository file, yamlencode / jsonencode of a literal; NULL: none of
* those. */
static yyjson_mut_val *tf_values_item(const tf_t *t, yyjson_mut_doc *doc, TSNode item, CBMArena *a,
const char ***files, int *nfiles) {
TSNode n = tf_unwrap(item);
if (tf_type(n, "function_call")) {
TSNode fn = ts_node_named_child(n, 0);
TSNode args = {0};
for (uint32_t i = 0; i < ts_node_named_child_count(n); i++) {
if (tf_type(ts_node_named_child(n, i), "function_arguments")) {
args = ts_node_named_child(n, i);
}
}
if (ts_node_is_null(fn) || ts_node_is_null(args) ||
ts_node_named_child_count(args) != SKIP_ONE) {
return NULL;
}
uint32_t fb = ts_node_start_byte(fn);
size_t fl = ts_node_end_byte(fn) - fb;
TSNode arg = ts_node_named_child(args, 0);
if ((fl == strlen("yamlencode") && strncmp(t->src + fb, "yamlencode", fl) == 0) ||
(fl == strlen("jsonencode") && strncmp(t->src + fb, "jsonencode", fl) == 0)) {
return tf_json(t, doc, arg);
}
if (fl == strlen("file") && strncmp(t->src + fb, "file", fl) == 0) {
char v[CBM_SZ_1K];
char path[CBM_SZ_1K];
if (!t->ctx->repo_path || !tf_string(t->src, t->mdir, arg, v, sizeof(v)) ||
!tf_repo_path(t->mdir, v, path, sizeof(path))) {
return NULL;
}
const char **grown = cbm_arena_alloc(a, (size_t)(*nfiles + SKIP_ONE) * sizeof(*grown));
if (grown) {
if (*nfiles) {
memcpy(grown, *files, (size_t)*nfiles * sizeof(*grown));
}
grown[(*nfiles)++] = cbm_arena_strdup(a, path);
*files = grown;
}
size_t len = 0;
char *buf = tf_read_repo(t, path, &len);
yyjson_mut_val *out = buf ? cbm_helm_values_parse(doc, buf, len) : NULL;
cbm_free(CBM_MEM_CLASS_OTHER, buf);
return out;
}
return NULL;
}
yyjson_mut_val *s = tf_json(t, doc, n);
return yyjson_mut_is_str(s)
? cbm_helm_values_parse(doc, yyjson_mut_get_str(s), yyjson_mut_get_len(s))
: NULL;
}
/* A `set` entry (name / value / type; read with numbers as text) applied to
* root; false when it is no literal. */
static bool tf_set_entry(yyjson_mut_doc *doc, yyjson_mut_val *root, yyjson_mut_val *entry) {
yyjson_mut_val *nm = yyjson_mut_obj_get(entry, "name");
yyjson_mut_val *val = yyjson_mut_obj_get(entry, "value");
yyjson_mut_val *ty = yyjson_mut_obj_get(entry, "type");
if (!yyjson_mut_is_str(nm) || !val) {
return false;
}
const char *v = NULL;
if (yyjson_mut_is_str(val)) {
v = yyjson_mut_get_str(val);
} else if (yyjson_mut_is_bool(val)) {
v = yyjson_mut_get_bool(val) ? "true" : "false";
}
bool as_string = yyjson_mut_is_str(ty) && strcmp(yyjson_mut_get_str(ty), "string") == 0;
return v && cbm_helm_values_set(doc, root, yyjson_mut_get_str(nm), v, as_string);
}
/* A `set` block's attributes as an object; NULL when one is no literal. */
static yyjson_mut_val *tf_set_block(const tf_t *t, yyjson_mut_doc *doc, TSNode block) {
yyjson_mut_val *entry = yyjson_mut_obj(doc);
for (uint32_t j = 0; entry && j < ts_node_named_child_count(block); j++) {
TSNode sb = ts_node_named_child(block, j);
if (!tf_type(sb, "body")) {
continue;
}
for (uint32_t k = 0; k < ts_node_named_child_count(sb); k++) {
TSNode at = ts_node_named_child(sb, k);
if (!tf_type(at, "attribute") || ts_node_named_child_count(at) < PAIR_LEN) {
continue;
}
TSNode key = ts_node_named_child(at, 0);
uint32_t b = ts_node_start_byte(key);
yyjson_mut_val *v = tf_json_v(t, doc, ts_node_named_child(at, SKIP_ONE), true);
if (!v) {
return NULL;
}
yyjson_mut_obj_add(entry,
yyjson_mut_strncpy(doc, t->src + b, ts_node_end_byte(key) - b), v);
}
}
return entry;
}
/* Is block `a` a `dynamic "set"` / `"set_list"` / `"set_sensitive"` /
* `"postrender"` -- made by a for_each, never literal? */
static bool tf_dynamic_set(const tf_t *t, TSNode a) {
TSNode kw = ts_node_named_child(a, 0);
TSNode label = ts_node_named_child(a, SKIP_ONE);
if (!tf_type(a, "block") || ts_node_is_null(kw) || ts_node_is_null(label)) {
return false;
}
uint32_t kb = ts_node_start_byte(kw);
if (ts_node_end_byte(kw) - kb != strlen("dynamic") ||
strncmp(t->src + kb, "dynamic", strlen("dynamic")) != 0) {
return false;
}
uint32_t lb = ts_node_start_byte(label);
size_t ll = ts_node_end_byte(label) - lb;
return (ll == strlen("\"set\"") && strncmp(t->src + lb, "\"set\"", ll) == 0) ||
(ll == strlen("\"set_list\"") && strncmp(t->src + lb, "\"set_list\"", ll) == 0) ||
(ll == strlen("\"set_sensitive\"") &&
strncmp(t->src + lb, "\"set_sensitive\"", ll) == 0) ||
(ll == strlen("\"postrender\"") && strncmp(t->src + lb, "\"postrender\"", ll) == 0);
}
/* Is node n an identifier spelling `word`? */
static bool tf_word(const tf_t *t, TSNode n, const char *word) {
if (ts_node_is_null(n)) {
return false;
}
uint32_t b = ts_node_start_byte(n);
size_t l = ts_node_end_byte(n) - b;
return l == strlen(word) && strncmp(t->src + b, word, l) == 0;
}
/* The values a helm_release body gives its chart -- `values` items
* coalesced in order, then each `set` -- as JSON (cbm_alloc); NULL when a
* part is no literal, is made by a `dynamic` block, is sensitive (its value
* is never read), or a postrender -- a block, an attribute or a dynamic one
* -- may rewrite what it deploys. */
static char *tf_release_values(const tf_t *t, TSNode body, const char ***files, int *nfiles) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(cbm_json_writer_allocator());
yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL;
bool ok = root != NULL;
for (int pass = 0; ok && pass < PAIR_LEN; pass++) {
for (uint32_t i = 0; ok && i < ts_node_named_child_count(body); i++) {
TSNode a = ts_node_named_child(body, i);
TSNode key = ts_node_named_child(a, 0);
uint32_t kb = ts_node_is_null(key) ? 0 : ts_node_start_byte(key);
size_t kl = ts_node_is_null(key) ? 0 : ts_node_end_byte(key) - kb;
bool is_set = kl == strlen("set") && strncmp(t->src + kb, "set", kl) == 0;
bool is_values = kl == strlen("values") && strncmp(t->src + kb, "values", kl) == 0;
/* set_list, set_sensitive and dynamic set values are not read:
* the release's values would be partial; a postrender may
* rewrite its images */
if (tf_word(t, key, "set_list") || tf_word(t, key, "set_sensitive") ||
tf_word(t, key, "postrender") || tf_dynamic_set(t, a)) {
ok = false;
break;
}
if (pass == 0 && is_values && tf_type(a, "attribute")) {
TSNode list = tf_unwrap(ts_node_named_child(a, SKIP_ONE));
ok = tf_type(list, "tuple");
for (uint32_t j = 0; ok && j < ts_node_named_child_count(list); j++) {
TSNode it = ts_node_named_child(list, j);
if (tf_type(it, "tuple_start") || tf_type(it, "tuple_end")) {
continue;
}
yyjson_mut_val *v = tf_values_item(t, doc, it, t->arena, files, nfiles);
ok = v && yyjson_mut_is_obj(v);
root = ok ? cbm_helm_values_coalesce(doc, root, v) : root;
}
} else if (pass == SKIP_ONE && is_set && tf_type(a, "block")) {
yyjson_mut_val *entry = tf_set_block(t, doc, a);
ok = entry && tf_set_entry(doc, root, entry);
} else if (pass == SKIP_ONE && is_set && tf_type(a, "attribute")) {
/* provider v3: set = [{ name, value, type }, ...] */
yyjson_mut_val *list = tf_json_v(t, doc, ts_node_named_child(a, SKIP_ONE), true);
ok = yyjson_mut_is_arr(list);
size_t idx, max;
yyjson_mut_val *entry;
yyjson_mut_arr_foreach(list, idx, max, entry) {
ok = ok && tf_set_entry(doc, root, entry);
}
}
}
}
char *out = NULL;
if (ok) {
size_t len = 0;
out = yyjson_mut_val_write_opts(root, 0, cbm_json_writer_allocator(), &len, NULL);
}
if (doc) {
yyjson_mut_doc_free(doc);
}
return out;
}
static void tf_release_push(tf_t *t, int64_t src, int64_t chart, TSNode body) {
cbm_pipeline_ctx_t *ctx = t->ctx;
if (ctx->tf_release_n == ctx->tf_release_cap) {
int cap = ctx->tf_release_cap ? ctx->tf_release_cap * PAIR_LEN : CBM_SZ_16;
cbm_tf_release_t *g =
cbm_realloc(CBM_MEM_CLASS_OTHER, ctx->tf_releases, (size_t)cap * sizeof(*g));
if (!g) {
return;
}
ctx->tf_releases = g;
ctx->tf_release_cap = cap;
}
const char **files = NULL;
int nfiles = 0;
char *values = tf_release_values(t, body, &files, &nfiles);
ctx->tf_releases[ctx->tf_release_n++] = (cbm_tf_release_t){src, chart, values};
/* the values files it reads, like a kustomization's valuesFile: an edit of
* one alone renders the release again (pipeline_incremental.c) */
for (int i = 0; i < nfiles; i++) {
const char *mqn = cbm_fqn_module(t->arena, ctx->project_name, files[i]);
const cbm_gbuf_node_t *mod = mqn ? cbm_gbuf_find_by_qn(ctx->gbuf, mqn) : NULL;
if (mod && mod->id != src) {
cbm_gbuf_insert_edge(ctx->gbuf, src, mod->id, "IMPORTS", "{\"local_name\":\"values\"}");
t->edges++;
}
}
}
static void tf_helm_release(tf_t *t, TSNode block, const char *name) {
TSNode body = {0};
for (uint32_t i = 0; i < ts_node_named_child_count(block); i++) {
@@ -320,6 +839,7 @@ static void tf_helm_release(tf_t *t, TSNode block, const char *name) {
external ? ",\"external\":true" : "", (int)ts_node_start_point(block).row + SKIP_ONE);
cbm_gbuf_insert_edge(t->ctx->gbuf, src, tgt, "DEPLOYS", props);
t->edges++;
tf_release_push(t, src, tgt, body);
}
/* The text of a string_lit label: "helm_release" -> helm_release. */
+2
View File
@@ -525,6 +525,8 @@ const char *cbm_registry_value_target(const cbm_registry_t *r, const char *qn);
int cbm_registry_find_by_name(const cbm_registry_t *r, const char *name, const char ***out,
int *count);
bool cbm_registry_has_type_named(const cbm_registry_t *r, const char *name);
/* Return total number of entries. */
int cbm_registry_size(const cbm_registry_t *r);
+42 -3
View File
@@ -2468,8 +2468,9 @@ static bool closure_dir_has_compose(const char *repo_path, const char *rel, cons
return found;
}
/* The nearest directory at or above `rel`'s that holds a Chart.yaml, into out
* ("" for the repository root); false when none does. */
/* The nearest directory at or above `rel`'s that holds a Chart.yaml, or is a
* werf `.helm` chart, into out ("" for the repository root); false when none
* is. */
static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *out, size_t sz) {
snprintf(out, sz, "%s", rel);
char *cut = strrchr(out, '/');
@@ -2487,6 +2488,9 @@ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *o
return true;
}
}
if (out[0] && cbm_werf_helm_dir(repo_path, out)) {
return true;
}
if (!out[0]) {
return false;
}
@@ -2494,6 +2498,33 @@ static bool closure_chart_dir_of(const char *repo_path, const char *rel, char *o
}
}
/* Is `rel` a file a release reads as values (an IMPORTS {local_name:
* valuesFile | values} edge reaches its node)? */
static bool closure_release_values(cbm_store_t *store, const char *project, const char *rel) {
cbm_node_t *nodes = NULL;
int count = 0;
if (cbm_store_find_nodes_by_file(store, project, rel, &nodes, &count) != CBM_STORE_OK) {
return false;
}
bool hit = false;
for (int i = 0; i < count && !hit; i++) {
cbm_edge_t *edges = NULL;
int ne = 0;
if (cbm_store_find_edges_by_target_type(store, nodes[i].id, "IMPORTS", &edges, &ne) !=
CBM_STORE_OK) {
continue;
}
for (int j = 0; j < ne && !hit; j++) {
const char *p = edges[j].properties_json;
hit = p && (strstr(p, "\"local_name\":\"valuesFile\"") ||
strstr(p, "\"local_name\":\"values\""));
}
cbm_store_free_edges(edges, ne);
}
cbm_store_free_nodes(nodes, count);
return hit;
}
/* Kubernetes inputs reach past their own file (pass_k8s.c): a kustomization
* decides which manifests deploy together and in which namespace, a
* Chart.yaml links other charts, and a manifest's Services select workloads
@@ -2510,7 +2541,9 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project,
if (cbm_is_kustomize_file(base) || strcmp(base, "Kustomization") == 0) {
return "k8s_bundle_input_changed";
}
if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0) {
/* A werf.yaml names the chart of the `.helm` dir beside it. */
if (strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0 ||
strcmp(base, "werf.yaml") == 0 || strcmp(base, "werf.yml") == 0) {
return "helm_chart_changed";
}
/* A chart's values file CONFIGURES it and its templates/ are what it
@@ -2530,6 +2563,12 @@ static const char *closure_k8s_decline(cbm_store_t *store, const char *project,
if (strcmp(base, ".env") == 0 && closure_dir_has_compose(repo_path, rel, base)) {
return "compose_env_changed";
}
/* A release's values file -- a kustomization's valuesFile, a Terraform
* helm_release's file(...) -- feeds the images the release renders,
* which a repair of the values file alone does not redraw. */
if (closure_release_values(store, project, rel)) {
return "helm_release_values_changed";
}
cbm_node_t *nodes = NULL;
int count = 0;
if (cbm_store_find_nodes_by_file(store, project, rel, &nodes, &count) != CBM_STORE_OK) {
+36 -2
View File
@@ -407,6 +407,16 @@ typedef struct {
int reader_count;
} cbm_envjoin_base_t;
/* A Terraform helm_release (pass_terraform.c): the chart it DEPLOYS and its
* values -- `values = [...]` coalesced in order, then `set` -- as JSON; NULL
* when a part is not a literal (an interpolation, a variable). pass_k8s.c
* renders the chart's images with them. */
typedef struct cbm_tf_release {
int64_t src; /* the resource.helm_release node */
int64_t chart; /* the Chart node */
char *values; /* owned (cbm_free); NULL: unknown */
} cbm_tf_release_t;
/* Shared context passed to each pass function.
* Derived from cbm_pipeline_t fields during run. */
typedef struct {
@@ -513,6 +523,12 @@ typedef struct {
const struct cbm_doclink_scope *doc_link_base;
int doc_link_base_count;
bool doc_links_failed;
/* The run's Terraform helm_releases (pass_terraform.c fills them, the K8s
* pass reads and frees them right after). */
cbm_tf_release_t *tf_releases;
int tf_release_n;
int tf_release_cap;
} cbm_pipeline_ctx_t;
/* Origin-aware LSP returns only success/failure. Never retain partial output
@@ -1097,6 +1113,9 @@ bool cbm_is_k8s_manifest(const char *name, const char *content);
/* A Helm values file name: values.yaml / .yml, values-<x>.yaml / .yml
* (pass_k8s.c; beside Chart.yaml it CONFIGURES the chart). */
bool cbm_helm_values_name(const char *base);
/* Is `dir` (repository relative) a werf chart: a `.helm` directory without a
* Chart.yaml whose parent holds werf.yaml (pass_k8s.c)? */
bool cbm_werf_helm_dir(const char *repo_path, const char *dir);
/* Secret detection */
bool cbm_is_secret_binding(const char *key, const char *value);
@@ -1296,6 +1315,7 @@ typedef struct {
const char *via; /* "skaffold" / "compose" */
const char *image; /* the reference, variables expanded */
const char *dockerfile; /* repository path */
const char *build_args; /* compose build.args as a JSON object; NULL: none */
int line;
} cbm_build_site_t;
typedef void (*cbm_build_site_fn)(const cbm_build_site_t *site, void *ud);
@@ -1306,10 +1326,17 @@ typedef void (*cbm_build_site_fn)(const cbm_build_site_t *site, void *ud);
* depends_on and links list, and the names of the environment variables its
* environment sets (map keys / list items' names -- never a value), with
* their 1-based lines and the service name each value addresses (a bare
* name, `name:port`, a URL's host; NULL: none -- cbm_env_address). */
* name, `name:port`, a URL's host; NULL: none -- cbm_env_address).
* image_default_of: the variables whose inline default (${X:-d}) named the
* image; image_override_of: those whose default the project's .env replaced
* (JSON arrays, NULL: none), and default_image the image those defaults
* name. */
typedef struct {
const char *name;
const char *image;
const char *image_default_of;
const char *image_override_of;
const char *default_image;
const char *dockerfile;
const char **deps;
int ndeps;
@@ -1377,6 +1404,12 @@ bool cbm_repo_rel_join(const char *dir, const char *v, char *out, size_t sz);
* for an official image; registry "unknown" when the reference names no
* host -- none is invented), made when new; 0 for scratch or no image. */
int64_t cbm_pipeline_image_node(cbm_pipeline_ctx_t *ctx, const char *ref);
/* Does `ref` follow the image reference grammar (distribution/reference):
* [host[:port]/]path[:tag][@algo:hex], path components lowercase alphanumerics
* joined by `.`, `_`, `__` or dashes, a tag of word characters, `.` and `-`
* (128 at most)? A value rendered from a template is checked before it names
* an Image (pass_docker.c). */
bool cbm_image_ref_valid(const char *ref);
/* A chart repository URL as an identity (pass_k8s.c): `url` trimmed, its
* scheme and host lower-cased, trailing `/` and `.git` dropped. False for no
@@ -1767,7 +1800,8 @@ int cbm_delta_drop_edges_from_label(cbm_store_t *store, const char *project, con
const char *label);
/* The edges that keep a file-less shared node alive. */
#define SH_PROCESS_EDGES ((const char *const[]){"SPAWNS", NULL})
#define DK_IMAGE_EDGES ((const char *const[]){"DEPENDS_ON", "USAGE", NULL})
/* An Image a release DEPLOYS (pass_k8s.c release images) may have no other edge. */
#define DK_IMAGE_EDGES ((const char *const[]){"DEPENDS_ON", "USAGE", "DEPLOYS", NULL})
/* Pre-seed proxy nodes with their REAL database ids and move the gbuf id
* watermark above MAX(id); returns that max id, or -1 on failure. */
int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *gbuf);
+15
View File
@@ -1732,6 +1732,21 @@ const char *cbm_registry_unique_field_qn(const cbm_registry_t *r, const char *me
return only;
}
bool cbm_registry_has_type_named(const cbm_registry_t *r, const char *name) {
if (!r || !name || !name[0]) {
return false;
}
const char **qns = NULL;
int count = 0;
cbm_registry_find_by_name(r, name, &qns, &count);
for (int i = 0; i < count; i++) {
if (cbm_label_is_type_like(cbm_registry_label_of(r, qns[i]))) {
return true;
}
}
return false;
}
int cbm_registry_size(const cbm_registry_t *r) {
return r ? (int)cbm_ht_count(r->exact) : 0;
}
+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;
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v2
name: cx
version: 0.1.0
appVersion: "1.0"
@@ -0,0 +1,4 @@
{{- define "cx.image" -}}
{{- $reg := .registry -}}
{{- printf "%s/%s:%s" $reg .repository .tag -}}
{{- end -}}
@@ -0,0 +1,14 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: cx
spec:
template:
spec:
containers:
- name: main
image: "{{ include "cx.image" .Values.image }}"
{{- range .Values.sidecars }}
- name: {{ .name }}
image: "{{ .image.repository }}:{{ .image.tag }}"
{{- end }}
+12
View File
@@ -0,0 +1,12 @@
common: &common
pullPolicy: IfNotPresent
image:
<<: *common
registry: ghcr.io
repository: acme/cx
tag: "1.0"
sidecars:
- name: a
image: {repository: ghcr.io/acme/cx-side, tag: "2.0"}
- name: b
image: {repository: ghcr.io/acme/cx-side, tag: "2.1"}
+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.
+25
View File
@@ -1338,6 +1338,30 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) {
PASS();
}
/* augment() is awaited inline inside 'tool.execute.after', so a hook-augment
* that never exits would stall every grep/glob/read completion in the host
* session until the agent is restarted. The generated plugin must bound the
* wait: SIGKILL the child after AUGMENT_TIMEOUT_MS and resolve with '' (fail
* open). The promise keeps the first resolution, so the kill-triggered
* 'close' event arriving late cannot smuggle in partial output. Stream
* errors fire on child.stdin, not the child — the emitted listener and the
* guarded end() keep a failed write (EPIPE, ENOENT) from crashing the host
* as an unhandled 'error' event (reproduced under Bun 1.4 with a >64KB
* payload and a non-reading child). */
TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "const AUGMENT_TIMEOUT_MS = 10000;"));
ASSERT_NOT_NULL(strstr(js, "const timer = setTimeout("));
ASSERT_NOT_NULL(strstr(js, "child.kill('SIGKILL');"));
/* error and close must both stop the timer once they own the outcome. */
ASSERT_NOT_NULL(strstr(js, "clearTimeout(timer);"));
/* stream-level failures must not escape as unhandled 'error' events. */
ASSERT_NOT_NULL(strstr(js, "child.stdin.on('error', () => {});"));
free(js);
PASS();
}
/* Empty/NULL inputs must not produce a module at all. */
TEST(client_adapter_rejects_missing_binary_path) {
ASSERT_NULL(cbm_client_adapter_pi(NULL));
@@ -1385,5 +1409,6 @@ SUITE(agent_clients) {
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction);
RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077);
RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child);
RUN_TEST(client_adapter_rejects_missing_binary_path);
}
+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
+111
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,86 @@ 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();
}
TEST(cypher_exec_where_in) {
cbm_store_t *s = setup_cypher_store();
cbm_cypher_result_t r = {0};
@@ -6243,6 +6349,11 @@ 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_exec_where_in);
RUN_TEST(cypher_exec_where_not_in);
RUN_TEST(cypher_exec_where_is_null);
+88 -2
View File
@@ -2892,6 +2892,91 @@ TEST(daemon_ipc_local_participants_overlap_and_allow_modern_startup) {
PASS();
}
TEST(daemon_ipc_windows_local_transition_release_reports_live_gate_contention) {
#ifndef _WIN32
PASS();
#else
static const char key[] = "6060717182829393";
char parent[TEST_PATH_CAP] = {0};
char canonical_parent[TEST_PATH_CAP] = {0};
char runtime_dir[TEST_PATH_CAP] = {0};
char legacy_pipe[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0};
char legacy_startup[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0};
cbm_daemon_ipc_endpoint_t *endpoint = NULL;
cbm_daemon_ipc_local_transition_t *transition = NULL;
cbm_daemon_ipc_startup_lock_t *startup = NULL;
bool parent_ok = ipc_test_parent_new(parent, "local-release-busy") &&
ipc_test_full_path(canonical_parent, parent);
bool names_ok = parent_ok && cbm_daemon_ipc_windows_legacy_names(canonical_parent, key,
legacy_pipe, legacy_startup);
if (names_ok) {
endpoint = cbm_daemon_ipc_endpoint_new(key, parent);
}
if (endpoint) {
ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint));
}
int acquired =
endpoint ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &transition) : -1;
int sealed = acquired == 1 ? cbm_daemon_ipc_local_transition_seal_legacy(transition) : -1;
int lifetime =
sealed == 1 ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition) : -1;
bool begun = lifetime == 0 && cbm_daemon_ipc_local_transition_begin_work(transition);
int startup_acquired = begun ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1;
int blocked = cbm_daemon_ipc_local_transition_try_release(&transition);
bool retained_while_busy = transition != NULL;
bool boolean_blocked = cbm_daemon_ipc_local_transition_release(&transition);
/* Keep the already-validated named mutex alive after the startup owner
* exits. Holding just this mutex exercises the second contention path. */
wchar_t *legacy_name = names_ok ? cbm_utf8_to_wide(legacy_startup) : NULL;
HANDLE legacy_mutex = startup_acquired == 1 && legacy_name
? OpenMutexW(SYNCHRONIZE | MUTEX_MODIFY_STATE, FALSE, legacy_name)
: NULL;
free(legacy_name);
bool startup_released = cbm_daemon_ipc_startup_lock_release(&startup);
DWORD mutex_wait =
startup_released && legacy_mutex ? WaitForSingleObject(legacy_mutex, 0) : WAIT_FAILED;
bool mutex_owned = mutex_wait == WAIT_OBJECT_0 || mutex_wait == WAIT_ABANDONED;
int legacy_blocked =
mutex_owned ? cbm_daemon_ipc_local_transition_try_release(&transition) : -1;
bool retained_while_legacy_busy = transition != NULL;
bool mutex_released = mutex_owned && ReleaseMutex(legacy_mutex);
if (legacy_mutex) {
(void)CloseHandle(legacy_mutex);
}
int legacy_retained = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1;
int released = cbm_daemon_ipc_local_transition_try_release(&transition);
bool consumed = transition == NULL;
int legacy_after = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1;
(void)cbm_daemon_ipc_startup_lock_release(&startup);
(void)cbm_daemon_ipc_local_transition_release(&transition);
cbm_daemon_ipc_endpoint_free(endpoint);
ipc_test_remove_tree(runtime_dir, parent);
ASSERT_TRUE(parent_ok);
ASSERT_TRUE(names_ok);
ASSERT_EQ(acquired, 1);
ASSERT_EQ(sealed, 1);
ASSERT_EQ(lifetime, 0);
ASSERT_TRUE(begun);
ASSERT_EQ(startup_acquired, 1);
ASSERT_EQ(blocked, 0);
ASSERT_TRUE(retained_while_busy);
ASSERT_FALSE(boolean_blocked);
ASSERT_TRUE(startup_released);
ASSERT_EQ(mutex_wait, WAIT_OBJECT_0);
ASSERT_EQ(legacy_blocked, 0);
ASSERT_TRUE(retained_while_legacy_busy);
ASSERT_TRUE(mutex_released);
ASSERT_EQ(legacy_retained, 1);
ASSERT_EQ(released, 1);
ASSERT_TRUE(consumed);
ASSERT_EQ(legacy_after, 0);
PASS();
#endif
}
TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex) {
#ifndef _WIN32
PASS();
@@ -2919,7 +3004,7 @@ TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex) {
if (begun) {
cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(1);
}
bool first_release = cbm_daemon_ipc_local_transition_release(&transition);
int first_release = cbm_daemon_ipc_local_transition_try_release(&transition);
bool retained_after_failure = transition != NULL;
bool retry_release = cbm_daemon_ipc_local_transition_release(&transition);
cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(0);
@@ -2935,7 +3020,7 @@ TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex) {
ASSERT_EQ(sealed, 1);
ASSERT_EQ(lifetime, 0);
ASSERT_TRUE(begun);
ASSERT_FALSE(first_release);
ASSERT_EQ(first_release, -1);
ASSERT_TRUE(retained_after_failure);
ASSERT_TRUE(retry_release);
ASSERT_NULL(transition);
@@ -5792,6 +5877,7 @@ SUITE(daemon_ipc) {
RUN_TEST(daemon_ipc_activation_probe_ignores_matching_startup_claim_only);
RUN_TEST(daemon_ipc_local_transition_coexists_with_active_daemon_lifetime);
RUN_TEST(daemon_ipc_local_participants_overlap_and_allow_modern_startup);
RUN_TEST(daemon_ipc_windows_local_transition_release_reports_live_gate_contention);
RUN_TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex);
RUN_TEST(daemon_ipc_windows_startup_release_retains_retry_authority);
RUN_TEST(daemon_ipc_frame_timeout_poisons_connection);
+31
View File
@@ -2122,6 +2122,34 @@ static CBMLanguage discover_lang_of(const cbm_file_info_t *files, int count, con
return CBM_LANG_COUNT;
}
/* #1803: a symlink is skipped by policy on every walk. A bounded count must
* skip it too, not abort: aborting made auto-index admission refuse every
* repository containing any symlink ("unsafe_or_unavailable_path"), while
* an explicit index of the same tree succeeded. */
TEST(discover_count_bounded_survives_a_symlink) {
#ifdef _WIN32
SKIP_PLATFORM("Windows: symlinks need admin / symlink() unavailable");
#else
char *base = th_mktempdir("cbm_disc_link");
ASSERT(base != NULL);
th_mkdir_p(TH_PATH(base, ".git"));
th_write_file(TH_PATH(base, "main.go"), "package main\n");
th_write_file(TH_PATH(base, "sub/helper.go"), "package sub\n");
ASSERT_EQ(symlink("sub", TH_PATH(base, "linkdir")), 0);
cbm_discover_opts_t opts = {0};
int count = -1;
/* deadline 0 = no deadline: only the symlink can decide this count. */
cbm_discover_status_t status = cbm_discover_count_bounded(base, &opts, 50000, 0, &count);
ASSERT_EQ(status, CBM_DISCOVER_OK);
ASSERT_EQ(count, 2);
th_cleanup(base);
PASS();
#endif
}
/* Write raw bytes (may contain embedded NUL) to base/rel. Parent must exist. */
static int write_bytes_file(const char *path, const void *data, size_t n) {
FILE *f = cbm_fopen(path, "wb");
@@ -2507,4 +2535,7 @@ SUITE(discover) {
RUN_TEST(discover_nested_gitignore_below_ancestor_gitignore_issue1973);
RUN_TEST(discover_nested_gitignore_negation_overrides_ancestor_pattern);
RUN_TEST(discover_nested_gitignore_three_levels_deeper_file_wins);
/* A symlink must not abort a bounded count (auto-index admission, #1803) */
RUN_TEST(discover_count_bounded_survives_a_symlink);
}
+98
View File
@@ -267,6 +267,102 @@ static int ei_edge_present(const EILangFile *files, int nfiles, const char *edge
return got >= floor;
}
typedef struct {
const char *importer;
const char *target;
} EIFileEdgeExpect;
static int ei_file_edges_match(const EILangFile *files, int nfiles, const EIFileEdgeExpect *want,
int nwant, int expected_count) {
EILangProj lp;
cbm_store_t *store = ei_index_files(&lp, files, nfiles);
int ok = store && cbm_store_count_edges_by_type(store, lp.project, "IMPORTS") == expected_count;
if (store && !ok) {
int got = cbm_store_count_edges_by_type(store, lp.project, "IMPORTS");
fprintf(stderr, " [IMPORTS] FAIL count=%d expected==%d\n", got, expected_count);
}
for (int i = 0; store && i < nwant; i++) {
int64_t source_id = ei_node_id_for_file_label(store, lp.project, want[i].importer, "File");
cbm_edge_t *edges = NULL;
int edge_count = 0;
int found = 0;
if (source_id > 0 &&
cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count) ==
CBM_STORE_OK) {
for (int j = 0; j < edge_count; j++) {
cbm_node_t target;
memset(&target, 0, sizeof(target));
if (cbm_store_find_node_by_id(store, edges[j].target_id, &target) == CBM_STORE_OK) {
found = target.file_path && strcmp(target.file_path, want[i].target) == 0;
cbm_node_free_fields(&target);
}
if (found) {
break;
}
}
}
cbm_store_free_edges(edges, edge_count);
if (!found) {
fprintf(stderr, " [IMPORTS] FAIL %s -> %s\n", want[i].importer, want[i].target);
ok = 0;
}
}
ei_cleanup(&lp, store);
return ok;
}
TEST(ei_relative_astro_css_imports_issue2558) {
static const EILangFile f[] = {
{"Badge.astro", "<span>outside</span>\n"},
{"src/components/shared/Button.astro",
"---\nexport function buttonLabel(t: string): string { return t; }\n---\n<button />\n"},
{"src/components/Badge.astro", "<span>badge</span>\n"},
{"src/components/Card.astro",
"---\nimport Badge from './Badge.astro';\n"
"import Button from './shared/Button.astro';\n---\n<div><Badge /><Button /></div>\n"},
{"src/layouts/local.css", "body { margin: 0; }\n"},
{"src/styles/global.css", "body { color: black; }\n"},
{"src/layouts/Layout.astro",
"---\nimport '../styles/global.css';\nimport './local.css';\n---\n"
"<html><body><slot /></body></html>\n"},
{"src/lib/format.ts", "export function formatTitle(t: string): string { return t.trim(); }\n"},
{"src/lib/registry.ts",
"import Badge from '../components/Badge.astro';\n"
"import '../styles/global.css';\n"
"import { formatTitle } from './format';\n"
"export function registry(): unknown[] { return [Badge, formatTitle]; }\n"},
{"src/lib/sub/deep.ts",
"import { formatTitle } from '../format';\n"
"import { registry } from '../registry.ts';\n"
"export function deep(): string { registry(); return formatTitle('x'); }\n"},
{"src/pages/index.astro",
"---\nimport Layout from '../layouts/Layout.astro';\n"
"import Card from '../components/Card.astro';\n"
"import Outside from '../../../Badge.astro';\n"
"import { formatTitle } from '../lib/format';\n"
"const title = formatTitle(' Home ');\n---\n"
"<Layout><h1>{title}</h1><Card /></Layout>\n"},
};
static const EIFileEdgeExpect want[] = {
{"src/components/Card.astro", "src/components/Badge.astro"},
{"src/components/Card.astro", "src/components/shared/Button.astro"},
{"src/layouts/Layout.astro", "src/styles/global.css"},
{"src/layouts/Layout.astro", "src/layouts/local.css"},
{"src/lib/registry.ts", "src/components/Badge.astro"},
{"src/lib/registry.ts", "src/styles/global.css"},
{"src/lib/registry.ts", "src/lib/format.ts"},
{"src/lib/sub/deep.ts", "src/lib/format.ts"},
{"src/lib/sub/deep.ts", "src/lib/registry.ts"},
{"src/pages/index.astro", "src/layouts/Layout.astro"},
{"src/pages/index.astro", "src/components/Card.astro"},
{"src/pages/index.astro", "src/lib/format.ts"},
};
ASSERT_TRUE(ei_file_edges_match(f, (int)(sizeof(f) / sizeof(f[0])), want,
(int)(sizeof(want) / sizeof(want[0])), 12));
PASS();
}
/* ═══════════════════════════════════════════════════════════════════════════
* GREEN GUARD — Python
*
@@ -2299,6 +2395,8 @@ TEST(ei_php_psr4_missing_class_leaves_type_evidence_unknown) {
* ═══════════════════════════════════════════════════════════════════════════ */
SUITE(edge_imports) {
RUN_TEST(ei_relative_astro_css_imports_issue2558);
/* ── GREEN GUARDS — Python (must stay passing) ── */
RUN_TEST(ei_python_relative_from_import);
RUN_TEST(ei_python_absolute_import);
+139
View File
@@ -1777,6 +1777,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 =
@@ -12525,6 +12552,116 @@ TEST(python_namespace_facts_string_statements_and_future_imports) {
PASS();
}
/* A top-level call of a same-module function is a namespace effect, unless the
* callee provably cannot rebind the caller's names: sqlalchemy's
* `sql/__init__.py` ends with `__go(locals())`. Every submodule its body
* imports becomes a package attribute, so those names are shadowed at the
* call; everything else of the namespace survives. */
static bool py_facts_has_effect(const CBMFileResult *r) {
for (int i = 0; r && i < r->py_namespace.count; i++) {
if (r->py_namespace.items[i].kind == CBM_PY_NS_UNKNOWN &&
r->py_namespace.items[i].reason == CBM_PY_NS_REASON_EFFECT)
return true;
}
return false;
}
static int py_facts_count_kind(const CBMFileResult *r, CBMPyNamespaceFactKind kind) {
int n = 0;
for (int i = 0; r && i < r->py_namespace.count; i++)
n += r->py_namespace.items[i].kind == kind;
return n;
}
static bool py_facts_valid_complete(const CBMFileResult *r) {
return r && !r->has_error && r->py_namespace.status == CBM_PY_NS_COMPLETE &&
cbm_py_namespace_facts_valid(&r->py_namespace);
}
TEST(python_namespace_facts_init_call_allowlist) {
static const char go[] = "from typing import TYPE_CHECKING, Any\n"
"from .impl import base\n"
"from .elements import cast\n"
"def __go(lcls: Any) -> None:\n"
" from .. import util as _sa_util\n"
" from . import base\n"
" from . import coercions, elements as _el\n"
" if not TYPE_CHECKING:\n"
" base.coercions = _el.coercions = coercions\n"
" from .annotation import _prepare_annotations\n"
" _prepare_annotations(cast, cast)\n"
" _sa_util.preloaded.import_prefix(\"sqlalchemy.sql\")\n"
"\n"
"__go(locals())\n";
CBMFileResult *r = extract(go, CBM_LANG_PYTHON, "t", "sql/__init__.py");
bool ok = py_facts_valid_complete(r) && !py_facts_has_effect(r) && r->py_namespace.count == 9;
if (ok) {
const CBMPyNamespaceFact *f = r->py_namespace.items;
static const char *const shadowed[] = {"base", "coercions", "elements", "annotation"};
ok = f[2].kind == CBM_PY_NS_IMPORT_NAME && strcmp(f[2].local_name, "base") == 0 &&
f[4].kind == CBM_PY_NS_OWN_DEF && strcmp(f[4].local_name, "__go") == 0;
for (int i = 0; ok && i < 4; i++) {
ok = f[5 + i].kind == CBM_PY_NS_SHADOW && f[5 + i].local_name &&
strcmp(f[5 + i].local_name, shadowed[i]) == 0;
}
}
if (!ok && r) {
for (int i = 0; i < r->py_namespace.count; i++) {
const CBMPyNamespaceFact *f = &r->py_namespace.items[i];
printf(" __go fact %d: kind %d reason %d name %s\n", i, (int)f->kind, (int)f->reason,
f->local_name ? f->local_name : "-");
}
}
if (r)
cbm_free_result(r);
ASSERT_TRUE(ok); /* RED while every top-level call withdraws the namespace */
/* A plain module is no package: the body's imports set attributes of its
* parent package, never of the module itself. */
r = extract("def __go(lcls):\n from . import base\n\n__go(locals())\n", CBM_LANG_PYTHON, "t",
"sql/mod.py");
ok = py_facts_valid_complete(r) && !py_facts_has_effect(r) &&
py_facts_count_kind(r, CBM_PY_NS_SHADOW) == 0;
if (r)
cbm_free_result(r);
ASSERT_TRUE(ok);
/* Each of these can rebind a name of the caller's namespace (or is not
* provably a same-module function): the call stays a whole-namespace
* effect. */
static const char *const effects[][2] = {
{"param", "def __go(lcls):\n lcls['base'] = 1\n\n__go(locals())\n"},
{"global", "def __go(lcls):\n global base\n base = 1\n\n__go(locals())\n"},
{"nested global", "def __go(lcls):\n def inner():\n global base\n"
" base = 1\n inner()\n\n__go(locals())\n"},
{"globals()", "def __go(lcls):\n globals()['base'] = 1\n\n__go(locals())\n"},
{"sys.modules", "def __go(lcls):\n import sys\n sys.modules[__name__].base = 1\n"
"\n__go(locals())\n"},
{"imported callee", "from .setup import __go\n__go(locals())\n"},
{"own package name", "def __go(lcls):\n from .. import sql\n sql.base = 1\n"
"\n__go(locals())\n"},
{"same-module helper", "def helper():\n pass\n\ndef __go(lcls):\n helper()\n"
"\n__go(locals())\n"},
{"rebound callee", "def __go(lcls):\n pass\n\n__go = other\n__go(locals())\n"},
{"call before def", "__go(locals())\n\ndef __go(lcls):\n pass\n"},
{"argument effect", "def __go(lcls):\n pass\n\n__go(make())\n"},
{"rebound locals", "def locals():\n return {}\n\ndef __go(lcls):\n pass\n"
"\n__go(locals())\n"},
};
for (size_t i = 0; i < sizeof(effects) / sizeof(effects[0]); i++) {
char src[512];
snprintf(src, sizeof(src), "from .impl import base\n%s", effects[i][1]);
r = extract(src, CBM_LANG_PYTHON, "t", "sql/__init__.py");
ok = py_facts_valid_complete(r) && py_facts_has_effect(r);
if (!ok)
printf(" control '%s' is no namespace effect\n", effects[i][0]);
if (r)
cbm_free_result(r);
ASSERT_TRUE(ok);
}
PASS();
}
TEST(python_namespace_facts_distinguish_all_operations_and_unknowns) {
static const char source[] = "__all__ = ['old']; __all__ = []\n"
"__all__ = dynamic\n"
@@ -12915,6 +13052,7 @@ SUITE(extraction) {
RUN_TEST(python_namespace_facts_parse_errors_are_complete_unknown);
RUN_TEST(python_namespace_facts_type_checking_alternatives);
RUN_TEST(python_namespace_facts_string_statements_and_future_imports);
RUN_TEST(python_namespace_facts_init_call_allowlist);
RUN_TEST(python_namespace_facts_capture_and_copy_fail_atomically);
RUN_TEST(python_namespace_facts_validation_rejects_partial_and_unknown_schema);
RUN_TEST(python_namespace_facts_compact_spill_and_copy_keep_owned_payload);
@@ -13066,6 +13204,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);
+811 -2
View File
@@ -4445,6 +4445,7 @@ static char *gpf_chart_edges(const GpfFile *files, int nfiles) {
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT CASE WHEN "
"s.label='File' THEN s.file_path ELSE s.name END || ' -> ' || t.label || ':' || "
"t.name || ' (' || e.type || coalesce(' ' || json_extract(e.properties,'$.ref'), '') "
"|| coalesce(' ' || json_extract(e.properties,'$.phase'), '') "
"|| ')' AS x FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON "
"t.id=e.target_id WHERE e.project=?1 AND ((s.label='Chart' AND e.type='DEPLOYS') OR "
"(t.label='Chart' AND e.type='CONFIGURES')) ORDER BY x)";
@@ -4462,8 +4463,8 @@ static char *gpf_chart_edges(const GpfFile *files, int nfiles) {
/* A chart deploys what its templates render: Chart -DEPLOYS {via: chart}->
* each object of its templates/ (a subchart's objects are the subchart's),
* and each Image those objects name literally ({ref: image}) -- a templated
* image names none, and no values default is read. Its values files
* and each Image those objects use ({ref: image}) -- a templated one rendered
* from values.yaml (`nginx`: host-less, kept as written). Its values files
* (values.yaml, values-*.yaml beside Chart.yaml) CONFIGURE it; a values file
* elsewhere (ci/) does not. */
TEST(probe_helm_chart_deploys_its_objects) {
@@ -4492,6 +4493,7 @@ TEST(probe_helm_chart_deploys_its_objects) {
"db -> Image:ghcr.io/acme/db (DEPLOYS image)\n"
"db -> Resource:StatefulSet/db (DEPLOYS)\n"
"web -> Image:ghcr.io/acme/sidecar (DEPLOYS image)\n"
"web -> Image:nginx (DEPLOYS image)\n"
"web -> Resource:Deployment/web (DEPLOYS)\n"
"web -> Resource:Service/web (DEPLOYS)";
if (!got || strcmp(got, want) != 0) {
@@ -4503,6 +4505,793 @@ TEST(probe_helm_chart_deploys_its_objects) {
PASS();
}
/* A chart also deploys the CRDs of its crds/ ({phase: crds}), which Helm
* installs before its templates. werf deploys the `.helm` dir beside its
* werf.yaml as a chart without Chart.yaml, named after the werf project
* (templated werf.yaml lines before it are skipped); a `.helm` with its own
* Chart.yaml stays that chart, and a werf.yaml without `.helm` makes none. */
TEST(probe_helm_chart_crds_and_werf_chart) {
static const GpfFile files[] = {
{"charts/op/Chart.yaml", "apiVersion: v2\nname: op\nversion: 1.0.0\n"},
{"charts/op/crds/widget.yaml",
"apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n"
" name: widgets.acme.io\nspec:\n group: acme.io\n names:\n kind: Widget\n"
" plural: widgets\n scope: Namespaced\n"},
{"charts/op/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: op\n"},
{"site/werf.yaml", "{{- $_ := set . \"Env\" (.Env | default \"prod\") }}\n\n"
"project: site-web\nconfigVersion: 1\n---\nimage: web\nfrom: nginx\n"},
{"site/.helm/values.yaml", "replicas: 1\n"},
{"site/.helm/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: site\nspec:\n"
" replicas: {{ .Values.replicas }}\n"},
{"plain/werf.yaml", "project: plain\nconfigVersion: 1\n"},
{"own/werf.yaml", "project: own-werf\nconfigVersion: 1\n"},
{"own/.helm/Chart.yaml", "apiVersion: v2\nname: own\nversion: 0.1.0\n"},
{"own/.helm/templates/cm.yaml",
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: own\n"}};
char *got = gpf_chart_edges(files, 10);
static const char want[] =
"op -> Resource:CustomResourceDefinition/widgets.acme.io (DEPLOYS crds)\n"
"op -> Resource:Deployment/op (DEPLOYS)\n"
"own -> Resource:ConfigMap/own (DEPLOYS)\n"
"site-web -> Resource:Deployment/site (DEPLOYS)\n"
"site/.helm/values.yaml -> Chart:site-web (CONFIGURES)";
if (!got || strcmp(got, want) != 0) {
fprintf(stderr, "chart edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
free(got);
FAIL("crds/ and werf chart edge set");
}
free(got);
PASS();
}
/* Every edge into an Image (or out of one into an Image), one line each:
* `source -> Image:name TYPE image=.. [via=..] [ref=..] [default_of=..]
* [override_of=..] [inferred=..] [values_key=..]`, sorted. */
static char *gpf_image_edges(const GpfFile *files, int nfiles) {
GpfProj lp;
cbm_store_t *store = gpf_index_files(&lp, files, nfiles);
char *out = NULL;
if (store) {
sqlite3_stmt *st = NULL;
const char *sql =
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name "
"|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || "
"coalesce(json_extract(e.properties,'$.image'),'-') || "
"coalesce(' via=' || json_extract(e.properties,'$.via'), '') || "
"coalesce(' ref=' || json_extract(e.properties,'$.ref'), '') || "
"coalesce(' default_of=' || json_extract(e.properties,'$.default_of'), '') || "
"coalesce(' override_of=' || json_extract(e.properties,'$.override_of'), '') || "
"coalesce(' inferred=' || json_extract(e.properties,'$.inferred'), '') || "
"coalesce(' values_key=' || json_extract(e.properties,'$.values_key'), '') AS x "
"FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id "
"WHERE e.project=?1 AND t.label='Image' ORDER BY x)";
if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) {
out = strdup((const char *)sqlite3_column_text(st, 0));
}
}
sqlite3_finalize(st);
}
gpf_cleanup(&lp, store);
return out;
}
static bool gpf_expect_image_edges(const GpfFile *files, int n, const char *want) {
char *got = gpf_image_edges(files, n);
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
return ok;
}
/* An image a default value names keeps its Image, its edge marked
* default_of with the ARGs / variables whose default supplied it (an ARG
* chain names each); a literal FROM is unmarked. A build's arguments (compose
* build.args, docker build --build-arg) make the Image it builds DEPEND_ON
* the base they choose {override_of}; compose's .env replacing an inline
* default is the effective edge's override_of, the default's image kept as
* {ref: image_default} -- one the service does not run. */
TEST(probe_docker_default_images_marked_and_overrides) {
static const GpfFile files[] = {
{"src/web/Dockerfile", "ARG GO=1.22\nARG BASE=golang:${GO}\nFROM ${BASE} AS build\n"
"RUN go build -o /app .\nFROM alpine:3.20\n"
"COPY --from=build /app /app\n"},
{"src/api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{".env", "REG=ghcr.io/acme\nAPI_IMG=ghcr.io/acme/api-custom\n"},
{"compose.yaml", "services:\n web:\n image: ${REG}/web:${TAG:-dev}\n build:\n"
" context: ./src/web\n args:\n GO: \"1.23\"\n"
" api:\n image: ${API_IMG:-ghcr.io/acme/api}\n build: ./src/api\n"},
{"scripts/build.sh", "#!/bin/sh\nIMAGE=${IMAGE:-ghcr.io/acme/api-ci}\n"
"docker build --build-arg PY=3.13 -t \"$IMAGE:1\" src/api\n"}};
static const char want[] =
"Image:ghcr.io/acme/api-ci -> Image:python DEPENDS_ON image=python:3.13-slim via=from "
"override_of=[\"PY\"]\n"
"Image:ghcr.io/acme/web -> Image:golang DEPENDS_ON image=golang:1.23 via=from "
"default_of=[\"BASE\"] override_of=[\"GO\"]\n"
"Module:src/api/Dockerfile -> Image:python DEPENDS_ON image=python:3.12-slim via=from "
"default_of=[\"PY\"]\n"
"Module:src/web/Dockerfile -> Image:alpine DEPENDS_ON image=alpine:3.20 via=from\n"
"Module:src/web/Dockerfile -> Image:golang DEPENDS_ON image=golang:1.22 via=from "
"default_of=[\"GO\",\"BASE\"]\n"
"Resource:Service/api -> Image:ghcr.io/acme/api USAGE image=ghcr.io/acme/api "
"ref=image_default default_of=[\"API_IMG\"]\n"
"Resource:Service/api -> Image:ghcr.io/acme/api-custom USAGE image=ghcr.io/acme/api-custom "
"ref=image override_of=[\"API_IMG\"]\n"
"Resource:Service/web -> Image:ghcr.io/acme/web USAGE image=ghcr.io/acme/web:dev "
"ref=image default_of=[\"TAG\"]";
if (!gpf_expect_image_edges(files, 5, want)) {
FAIL("default-valued images and their in-repo overrides");
}
PASS();
}
/* The chart a Helm template renders (helm_template.c): the container image
* from the chart family's defaults -- an include helper (bitnami
* common.images.image, a dict argument, .Chart.AppVersion), a `with`
* binding, the taken branch of an if / else, a subchart under its alias with
* the parent's values and global. The object USES it {default_of: the values
* keys read}; the chart DEPLOYS it. A value that does not render -- a
* .Release field, lookup, a missing define, a field of a missing map -- names
* no Image. */
TEST(probe_helm_template_images_render_with_defaults) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.2.3\nappVersion: \"2.0.1\"\ndependencies:\n"
" - name: db\n version: 0.1.0\n alias: database\n"},
{"charts/app/values.yaml",
"image:\n registry: ghcr.io\n repository: acme/app\n tag: \"\"\n digest: \"\"\n"
"sidecar:\n image: { repository: docker.io/library/busybox, tag: 1.36 }\nglobal: {}\n"
"database:\n image:\n tag: \"17\"\n"},
{"charts/app/templates/_helpers.tpl",
"{{- define \"common.images.image\" -}}\n"
"{{- $registryName := default .imageRoot.registry ((.global).imageRegistry) -}}\n"
"{{- $termination := .imageRoot.tag | toString -}}\n"
"{{- if not .imageRoot.tag }}\n {{- if .chart }}\n"
" {{- $termination = .chart.AppVersion | toString -}}\n {{- end -}}\n{{- end -}}\n"
"{{- if $registryName }}\n"
"{{- printf \"%s/%s:%s\" $registryName .imageRoot.repository $termination -}}\n"
"{{- else -}}\n{{- printf \"%s:%s\" .imageRoot.repository $termination -}}\n"
"{{- end -}}\n{{- end -}}\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n image: {{ include "
"\"common.images.image\" (dict \"imageRoot\" .Values.image \"global\" .Values.global "
"\"chart\" .Chart) }}\n - name: side\n {{- with .Values.sidecar.image }}\n"
" image: \"{{ .repository }}:{{ .tag }}\"\n {{- end }}\n"
" - name: rel\n image: \"ghcr.io/acme/{{ .Release.Name }}:1\"\n"
" - name: miss\n image: {{ include \"nope\" . }}\n"
" - name: deep\n image: \"{{ .Values.absent.repository }}\"\n"},
{"charts/app/charts/db/Chart.yaml",
"apiVersion: v2\nname: db\nversion: 0.1.0\nappVersion: \"16.4\"\n"},
{"charts/app/charts/db/values.yaml",
"image:\n repository: bitnami/postgresql\n tag: \"\"\n digest: \"\"\n"},
{"charts/app/charts/db/templates/sts.yaml",
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: db\nspec:\n template:\n"
" spec:\n containers:\n - name: db\n {{- if .Values.image.digest }}\n"
" image: \"{{ .Values.global.imageRegistry | default \"docker.io\" }}/"
"{{ .Values.image.repository }}@{{ .Values.image.digest }}\"\n {{- else }}\n"
" image: \"{{ .Values.global.imageRegistry | default \"docker.io\" }}/"
"{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}\"\n"
" {{- end }}\n"}};
static const char want[] =
"Chart:app -> Image:docker.io/library/busybox DEPLOYS image=- via=chart ref=image "
"default_of=[\"sidecar.image.repository\",\"sidecar.image.tag\"]\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.registry\",\"global.imageRegistry\",\"image.tag\","
"\"image.repository\"]\n"
"Chart:db -> Image:docker.io/bitnami/postgresql DEPLOYS image=- via=chart ref=image "
"default_of=[\"global.imageRegistry\",\"image.repository\",\"image.tag\"]\n"
"Resource:Deployment/app -> Image:docker.io/library/busybox USAGE "
"image=docker.io/library/busybox:1.36 ref=image "
"default_of=[\"sidecar.image.repository\",\"sidecar.image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:2.0.1 "
"ref=image default_of=[\"image.registry\",\"global.imageRegistry\",\"image.tag\","
"\"image.repository\"]\n"
"Resource:StatefulSet/db -> Image:docker.io/bitnami/postgresql USAGE "
"image=docker.io/bitnami/postgresql:17 ref=image "
"default_of=[\"global.imageRegistry\",\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 7, want)) {
FAIL("Helm template images rendered with the chart family's defaults");
}
PASS();
}
/* A release renders its chart with its values over the defaults: of a chart
* in the repository each image that differs is the release's own {via,
* override_of} -- a Flux HelmRelease's spec.values (a Git source), a
* kustomize helmCharts entry's valuesFile + valuesInline (chartHome charts/),
* a Terraform helm_release's values + set. Of an external chart the values
* name images by convention -- `repository` (+ tag / registry) naming a
* registry host -- {inferred: values}; a host-less repository names none. */
TEST(probe_helm_release_images) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"},
{"flux/app.yaml",
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: app\nspec:\n"
" chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n values:\n image:\n"
" tag: \"9.9\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: sonarr\n"
"spec:\n chart:\n spec:\n chart: app-template\n sourceRef:\n"
" kind: HelmRepository\n name: bjw-s\n values:\n controllers:\n"
" main:\n containers:\n main:\n image:\n"
" repository: ghcr.io/home-operations/sonarr\n tag: 4.0.15\n"
" side:\n image:\n repository: busybox\n"
" tag: \"1.36\"\n"},
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesFile: values-prod.yaml\n"
" valuesInline:\n image:\n tag: \"5.5\"\n"},
{"kz/values-prod.yaml", "image:\n repository: quay.io/acme/app\n tag: \"4.4\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"},
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = \"7.7\"\n }\n}\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS "
"image=ghcr.io/acme/app:7.7 via=terraform ref=image override_of=[\"image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:HelmRelease/app -> Image:ghcr.io/acme/app DEPLOYS image=ghcr.io/acme/app:9.9 "
"via=helmrelease ref=image override_of=[\"image.tag\"]\n"
"Resource:HelmRelease/sonarr -> Image:ghcr.io/home-operations/sonarr DEPLOYS "
"image=ghcr.io/home-operations/sonarr:4.0.15 via=helmrelease ref=image inferred=values "
"values_key=controllers.main.containers.main.image\n"
"Resource:Kustomization -> Image:quay.io/acme/app DEPLOYS image=quay.io/acme/app:5.5 "
"via=helmcharts ref=image override_of=[\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 10, want)) {
FAIL("release images: local renders and the external values convention");
}
PASS();
}
/* The chart every release test below deploys: an image from two values. */
#define GPF_APP_CHART \
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"}, \
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"}, \
{ \
"charts/app/templates/deploy.yaml", \
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n" \
" spec:\n containers:\n - name: app\n" \
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n" \
}
/* The edges of the chart's defaults (every test below has them). */
#define GPF_APP_DEFAULTS \
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image " \
"default_of=[\"image.repository\",\"image.tag\"]\n" \
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 " \
"ref=image default_of=[\"image.repository\",\"image.tag\"]"
/* A Terraform number set on a helm_release reaches the chart as the string
* Terraform makes of it -- its exact decimal (1.1, 1.10 -> "1.1", 2e3 ->
* "2000"), never a float's %.17g ("1.1000000000000001"). */
TEST(probe_helm_release_terraform_number_set) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = 1.10\n }\n}\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Class:resource.helm_release.app -> Image:ghcr.io/acme/app DEPLOYS "
"image=ghcr.io/acme/app:1.1 via=terraform ref=image override_of=[\"image.tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]";
if (!gpf_expect_image_edges(files, 4, want)) {
FAIL("a Terraform number set is its exact decimal");
}
PASS();
}
/* A Flux HelmRelease whose values also come from a source not read --
* valuesFrom, the chart's valuesFiles, a postRenderer that may patch the
* images -- renders no image of its own: its values are not known whole. */
TEST(probe_helm_release_flux_unread_sources) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"flux/app.yaml",
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: files\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n valuesFiles:\n"
" - charts/app/values-prod.yaml\n sourceRef:\n kind: GitRepository\n"
" name: repo\n values:\n image:\n tag: \"9.9\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: from\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n valuesFrom:\n"
" - kind: ConfigMap\n name: app-values\n values:\n image:\n"
" tag: \"8.8\"\n---\n"
"apiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nmetadata:\n name: post\n"
"spec:\n chart:\n spec:\n chart: ./charts/app\n sourceRef:\n"
" kind: GitRepository\n name: repo\n postRenderers:\n"
" - kustomize:\n images:\n - name: ghcr.io/acme/app\n"
" newName: quay.io/acme/app\n values:\n image:\n tag: \"7.7\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a HelmRelease with a source not read has no image of its own");
}
PASS();
}
/* A kustomize helmCharts entry with additionalValuesFiles, or a valuesMerge
* other than the default override, renders no image of its own. */
TEST(probe_helm_release_kustomize_unread_sources) {
static const GpfFile files[] = {
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: more\n additionalValuesFiles:\n"
" - extra.yaml\n valuesInline:\n image:\n tag: \"5.5\"\n"
" - name: app\n releaseName: merged\n valuesMerge: merge\n"
" valuesInline:\n image:\n tag: \"6.6\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a helmCharts entry with a part not read has no image of its own");
}
PASS();
}
/* A Terraform helm_release with a `dynamic "set"` block (entries made by a
* for_each) renders no image of its own, whatever its literal sets say. */
TEST(probe_helm_release_terraform_dynamic_set) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n dynamic \"set\" {\n"
" for_each = var.sets\n content {\n name = set.key\n"
" value = set.value\n }\n }\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a helm_release with a dynamic set has no image of its own");
}
PASS();
}
/* A set_sensitive's value is never read: any set_sensitive leaves the
* release without an image of its own, whatever its name -- image.tag or
* db.password (which kept its edge in round 3). A postrender may rewrite
* what a release deploys: no image of its own either. */
TEST(probe_helm_release_terraform_sensitive_and_postrender) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf",
"resource \"helm_release\" \"tagged\" {\n name = \"tagged\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n"
" value = \"quay.io/acme/app\"\n }\n set_sensitive {\n name = \"image.tag\"\n"
" value = var.tag\n }\n}\n"
"resource \"helm_release\" \"pw\" {\n name = \"pw\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.repository\"\n"
" value = \"quay.io/acme/pw\"\n }\n set_sensitive {\n name = \"db.password\"\n"
" value = var.pw\n }\n}\n"
"resource \"helm_release\" \"post\" {\n name = \"post\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n postrender {\n binary_path = \"./kustomize.sh\"\n }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("any set_sensitive and a postrender make release images unknown");
}
PASS();
}
/* A set_sensitive whose name an image reads on another line ($tag, before
* the image line): the release still has no image of its own -- never one
* with the chart's default tag. */
TEST(probe_helm_release_terraform_sensitive_read_earlier) {
static const GpfFile files[] = {
{"charts/web/Chart.yaml", "apiVersion: v2\nname: web\nversion: 1.0.0\nappVersion: \"1.0\"\n"},
{"charts/web/values.yaml", "image:\n repository: ghcr.io/acme/web\n"},
{"charts/web/templates/deploy.yaml",
"{{- $tag := .Values.image.tag | default .Chart.AppVersion }}\n"
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: web\nspec:\n template:\n"
" spec:\n containers:\n - name: web\n"
" image: \"{{ .Values.image.repository }}:{{ $tag }}\"\n"},
{"tf/main.tf",
"resource \"helm_release\" \"web\" {\n name = \"web\"\n"
" chart = \"../charts/web\"\n set {\n name = \"image.repository\"\n"
" value = \"quay.io/acme/web\"\n }\n set_sensitive {\n name = \"image.tag\"\n"
" value = var.tag\n }\n}\n"}};
static const char want[] =
"Chart:web -> Image:ghcr.io/acme/web DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\"]\n"
"Resource:Deployment/web -> Image:ghcr.io/acme/web USAGE image=ghcr.io/acme/web:1.0 "
"ref=image default_of=[\"image.repository\"]";
if (!gpf_expect_image_edges(files, 4, want)) {
FAIL("a set_sensitive read on another line makes the release images unknown");
}
PASS();
}
/* A postrender as a dynamic block or as an attribute (provider v3) may
* rewrite what a release deploys, as a block does: no image of its own. */
TEST(probe_helm_release_terraform_postrender_forms) {
static const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf",
"resource \"helm_release\" \"dyn\" {\n name = \"dyn\"\n"
" chart = \"../charts/app\"\n set {\n name = \"image.tag\"\n"
" value = \"3.3\"\n }\n dynamic \"postrender\" {\n for_each = var.post\n"
" content {\n binary_path = postrender.value\n }\n }\n}\n"
"resource \"helm_release\" \"attr\" {\n name = \"attr\"\n"
" chart = \"../charts/app\"\n set = [{ name = \"image.tag\", value = \"4.4\" }]\n"
" postrender = { binary_path = \"./kustomize.sh\" }\n}\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a dynamic or attribute postrender makes release images unknown");
}
PASS();
}
/* A subchart included under an alias (redis as `cache`) has .Template.BasePath
* app/charts/cache/templates, as Helm sets it (an image here shows it). Its
* checksum of cm.yaml names its own file, which changes no map -- not app's
* cm.yaml, which sets a value: its image stays known, as does app's (which
* checksums its Secret).
*
* NOT a cross-file guarantee: app's cm.yaml sets .Values.image.tag, and Helm
* renders a chart's files with one shared .Values (a subchart's is the
* parent's .Values.<alias>). Each file renders alone here, so app's image is
* known only because nothing models that set reaching deploy.yaml -- the
* known limit (review round 4, R4-5), whose exact fix is to run the family's
* files in Helm's order with one .Values (the run-included-files follow-up). */
TEST(probe_helm_checksum_include_by_base_path) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n"
" - name: redis\n version: 1.0.0\n alias: cache\n"},
{"charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/cm.yaml", "{{- $_ := set .Values.image \"tag\" \"2.0\" }}\n"
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n"},
{"charts/app/templates/secret.yaml", "apiVersion: v1\nkind: Secret\nmetadata:\n name: app\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" metadata:\n annotations:\n checksum/secret: "
"{{ include (print $.Template.BasePath \"/secret.yaml\") . | sha256sum }}\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"},
{"charts/app/charts/redis/Chart.yaml", "apiVersion: v2\nname: redis\nversion: 1.0.0\n"},
{"charts/app/charts/redis/values.yaml",
"image:\n repository: ghcr.io/acme/redis\n tag: \"7.0\"\n"},
{"charts/app/charts/redis/templates/cm.yaml",
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: redis\n"},
{"charts/app/charts/redis/templates/sts.yaml",
"apiVersion: apps/v1\nkind: StatefulSet\nmetadata:\n name: redis\nspec:\n template:\n"
" metadata:\n annotations:\n checksum/config: "
"{{ include (print $.Template.BasePath \"/cm.yaml\") . | sha256sum }}\n"
" spec:\n containers:\n - name: redis\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"
" - name: path\n image: \"ghcr.io/{{ .Template.BasePath }}:1\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Chart:redis -> Image:ghcr.io/acme/redis DEPLOYS image=- via=chart ref=image "
"default_of=[\"image.repository\",\"image.tag\"]\n"
"Chart:redis -> Image:ghcr.io/app/charts/cache/templates DEPLOYS image=- via=chart "
"ref=image\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:StatefulSet/redis -> Image:ghcr.io/acme/redis USAGE "
"image=ghcr.io/acme/redis:7.0 ref=image default_of=[\"image.repository\",\"image.tag\"]\n"
"Resource:StatefulSet/redis -> Image:ghcr.io/app/charts/cache/templates USAGE "
"image=ghcr.io/app/charts/cache/templates:1 ref=image";
if (!gpf_expect_image_edges(files, 9, want)) {
FAIL("a checksum include by .Template.BasePath names the chart's own file");
}
PASS();
}
/* A kustomization that also transforms what it inflates (images:, patches)
* has no release image of its own: the transformer may change it. */
TEST(probe_helm_release_kustomize_transformers) {
static const GpfFile files[] = {
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesInline:\n image:\n"
" tag: \"1.5\"\nimages:\n - name: ghcr.io/acme/app\n newTag: \"2.0\"\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
if (!gpf_expect_image_edges(files, 4, GPF_APP_DEFAULTS)) {
FAIL("a kustomization's own transformers make its release images unknown");
}
PASS();
}
/* An empty values.yaml is values {} (Helm's), not values not read: the
* subchart under it still renders its image. */
TEST(probe_helm_empty_values_file_renders) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.0.0\nappVersion: \"1.0\"\n"},
{"charts/app/values.yaml", ""},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image";
if (!gpf_expect_image_edges(files, 3, want)) {
FAIL("an empty values.yaml renders with {}");
}
PASS();
}
/* A subchart a dependency aliases renders with .Chart.Name its alias (Helm's
* getAliasDependency), so an image naming the chart names the alias. */
TEST(probe_helm_aliased_subchart_chart_name) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml",
"apiVersion: v2\nname: app\nversion: 1.0.0\ndependencies:\n - name: worker\n"
" version: 0.1.0\n alias: worker-high\n"},
{"charts/app/values.yaml", "worker-high: {}\n"},
{"charts/app/charts/worker/Chart.yaml",
"apiVersion: v2\nname: worker\nversion: 0.1.0\nappVersion: \"1.0\"\n"},
{"charts/app/charts/worker/values.yaml", "{}\n"},
{"charts/app/charts/worker/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: worker\nspec:\n template:\n"
" spec:\n containers:\n - name: worker\n"
" image: \"ghcr.io/acme/{{ .Chart.Name }}:{{ .Chart.AppVersion }}\"\n"}};
static const char want[] =
"Chart:worker -> Image:ghcr.io/acme/worker-high DEPLOYS image=- via=chart ref=image\n"
"Resource:Deployment/worker -> Image:ghcr.io/acme/worker-high USAGE "
"image=ghcr.io/acme/worker-high:1.0 ref=image";
if (!gpf_expect_image_edges(files, 5, want)) {
FAIL("an aliased subchart's .Chart.Name is the alias");
}
PASS();
}
/* An image read from a values list keeps its mark on the chart's edge:
* default_of names the list paths (images[0].repository) whole. */
TEST(probe_helm_list_path_mark_on_chart_edge) {
static const GpfFile files[] = {
{"charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"charts/app/values.yaml",
"images:\n - repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ (index .Values.images 0).repository }}:"
"{{ (index .Values.images 0).tag }}\"\n"}};
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart ref=image "
"default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0 "
"ref=image default_of=[\"images\",\"images[0].repository\",\"images[0].tag\"]";
if (!gpf_expect_image_edges(files, 3, want)) {
FAIL("a list path's mark is kept whole on the chart edge");
}
PASS();
}
#ifndef _WIN32
/* gpf_image_edges with symlinks laid in after the files: links[i] names a
* repository path and the absolute or relative target it points at (POSIX
* symlinks: the tests using it skip on Windows). */
static char *gpf_image_edges_linked(const GpfFile *files, int nfiles, const GpfFile *links,
int nlinks) {
GpfProj lp;
memset(&lp, 0, sizeof(lp));
snprintf(lp.tmpdir, sizeof(lp.tmpdir), "/tmp/cbm_gpf_XXXXXX");
if (!cbm_mkdtemp(lp.tmpdir)) {
return NULL;
}
gpf_to_fwd_slashes(lp.tmpdir);
for (int i = 0; i < nfiles + nlinks; i++) {
const GpfFile *f = i < nfiles ? &files[i] : &links[i - nfiles];
char path[700];
snprintf(path, sizeof(path), "%s/%s", lp.tmpdir, f->name);
char *slash = strrchr(path, '/');
if (slash && slash > path + strlen(lp.tmpdir)) {
*slash = '\0';
cbm_mkdir_p(path, 0755);
*slash = '/';
}
if (i >= nfiles) {
if (symlink(f->content, path) != 0) {
return NULL;
}
continue;
}
FILE *fp = fopen(path, "wb");
if (!fp) {
return NULL;
}
fputs(f->content, fp);
fclose(fp);
}
cbm_store_t *store = gpf_open_indexed(&lp);
char *out = NULL;
if (store) {
sqlite3_stmt *st = NULL;
const char *sql =
"SELECT coalesce(group_concat(x, char(10)), '') FROM (SELECT s.label || ':' || s.name "
"|| ' -> Image:' || t.name || ' ' || e.type || ' image=' || "
"coalesce(json_extract(e.properties,'$.image'),'-') || "
"coalesce(' via=' || json_extract(e.properties,'$.via'), '') AS x "
"FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON t.id=e.target_id "
"WHERE e.project=?1 AND t.label='Image' ORDER BY x)";
if (sqlite3_prepare_v2(cbm_store_get_db(store), sql, -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_text(st, 1, lp.project, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) {
out = strdup((const char *)sqlite3_column_text(st, 0));
}
}
sqlite3_finalize(st);
}
gpf_cleanup(&lp, store);
return out;
}
#endif
/* A values file a chart, a kustomization or a Terraform file() names by path
* is read only when it is a file of the repository: a symlink out of it --
* to a file holding an image -- is never followed. The chart whose values
* file is such a link renders nothing (its values are unknown, not empty);
* the releases reading one have no image of their own. */
TEST(probe_helm_values_symlink_not_followed) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX symlink contract");
#else
char outside[256];
snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
char leak[300];
snprintf(leak, sizeof(leak), "%s/values.yaml", outside);
FILE *lf = fopen(leak, "wb");
ASSERT_NOT_NULL(lf);
fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf);
fclose(lf);
const GpfFile files[] = {
GPF_APP_CHART,
{"linked/Chart.yaml", "apiVersion: v2\nname: linked\nversion: 1.0.0\n"},
{"linked/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: linked\nspec:\n template:\n"
" spec:\n containers:\n - name: linked\n"
" image: \"ghcr.io/acme/linked:{{ .Values.tag | default \"latest\" }}\"\n"},
{"kz/kustomization.yaml",
"apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nhelmCharts:\n"
" - name: app\n releaseName: kapp\n valuesFile: prod.yaml\n"},
{"kz/charts/app/Chart.yaml", "apiVersion: v2\nname: app\nversion: 1.0.0\n"},
{"kz/charts/app/values.yaml", "image:\n repository: ghcr.io/acme/app\n tag: \"1.0\"\n"},
{"kz/charts/app/templates/deploy.yaml",
"apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: kapp\nspec:\n template:\n"
" spec:\n containers:\n - name: app\n"
" image: \"{{ .Values.image.repository }}:{{ .Values.image.tag }}\"\n"}};
const GpfFile links[] = {{"linked/values.yaml", leak}, {"kz/prod.yaml", leak}};
char *got = gpf_image_edges_linked(files, 9, links, 2);
th_rmtree(outside);
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0\n"
"Resource:Deployment/kapp -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0";
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
if (!ok) {
FAIL("no values read through a symlink out of the repository");
}
PASS();
#endif
}
/* A Terraform helm_release reading its values with file() of a symlink out
* of the repository has no image of its own: the link is never followed. */
TEST(probe_helm_terraform_values_symlink_not_followed) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX symlink contract");
#else
char outside[256];
snprintf(outside, sizeof(outside), "/tmp/cbm_gpf_out_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
char leak[300];
snprintf(leak, sizeof(leak), "%s/vals.yaml", outside);
FILE *lf = fopen(leak, "wb");
ASSERT_NOT_NULL(lf);
fputs("image:\n repository: ghcr.io/evil/leak\n tag: \"6.6\"\n", lf);
fclose(lf);
const GpfFile files[] = {
GPF_APP_CHART,
{"tf/main.tf", "resource \"helm_release\" \"app\" {\n name = \"app\"\n"
" chart = \"../charts/app\"\n values = [file(\"vals.yaml\")]\n}\n"}};
const GpfFile links[] = {{"tf/vals.yaml", leak}};
char *got = gpf_image_edges_linked(files, 4, links, 1);
th_rmtree(outside);
static const char want[] =
"Chart:app -> Image:ghcr.io/acme/app DEPLOYS image=- via=chart\n"
"Resource:Deployment/app -> Image:ghcr.io/acme/app USAGE image=ghcr.io/acme/app:1.0";
bool ok = got && strcmp(got, want) == 0;
if (!ok) {
fprintf(stderr, "image edges got:\n%s\nwant:\n%s\n", got ? got : "(null)", want);
}
free(got);
if (!ok) {
FAIL("no Terraform values read through a symlink out of the repository");
}
PASS();
#endif
}
/* A build argument holding a secret -- by its name (*_TOKEN) or its value (a
* GitHub token) -- is never kept on a build edge; the others still choose the
* base image they set. */
TEST(probe_docker_build_args_keep_no_secret) {
static const GpfFile files[] = {
{"web/Dockerfile", "ARG GO=1.22\nFROM golang:${GO}\n"},
{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{".env", "NPM_TOKEN=npm-secret-value\n"},
{"compose.yaml", "services:\n web:\n image: ghcr.io/acme/web\n build:\n"
" context: ./web\n args:\n NPM_TOKEN: ${NPM_TOKEN}\n"
" GO: \"1.23\"\n"},
{"scripts/build.sh",
"#!/bin/sh\ndocker build --build-arg CI_KEY=ghp_abcdefghijklmnopqrstuvwxyz0123456789AB "
"--build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n"}};
int secrets = gpf_query_int(files, 5,
"SELECT count(*) FROM edges WHERE project=?1 AND "
"(properties LIKE '%npm-secret-value%' OR properties LIKE "
"'%ghp_abcdefghij%' OR properties LIKE '%NPM_TOKEN%')");
int overrides = gpf_query_int(files, 5,
"SELECT count(*) FROM edges WHERE project=?1 AND "
"json_extract(properties,'$.override_of') IS NOT NULL");
ASSERT_EQ(secrets, 0);
ASSERT_EQ(overrides, 2);
PASS();
}
/* Build arguments of any length are kept whole on the build edge (never cut
* to malformed JSON): the one choosing the base still makes its edge. */
TEST(probe_docker_long_build_args_kept_whole) {
enum { ARGS = 40, VAL = 150, CAP = ARGS * (VAL + 40) + 512 };
char *script = malloc(CAP);
ASSERT_NOT_NULL(script);
int o = snprintf(script, CAP, "#!/bin/sh\ndocker build");
char val[VAL + 1];
memset(val, 'v', VAL);
val[VAL] = '\0';
for (int i = 0; i < ARGS; i++) {
o += snprintf(script + o, (size_t)(CAP - o), " --build-arg LABEL%d=%s", i, val);
}
snprintf(script + o, (size_t)(CAP - o), " --build-arg PY=3.13 -t ghcr.io/acme/api:1 api\n");
const GpfFile files[] = {{"api/Dockerfile", "ARG PY=3.12\nFROM python:${PY}-slim\n"},
{"scripts/build.sh", script}};
int overrides =
gpf_query_int(files, 2,
"SELECT count(*) FROM edges e JOIN nodes t ON t.id=e.target_id WHERE "
"e.project=?1 AND t.name='python' AND "
"json_extract(e.properties,'$.override_of') IS NOT NULL");
free(script);
ASSERT_EQ(overrides, 1);
PASS();
}
/* A template's `include "name"` calls the define of that name in its chart
* family -- the chart and its subcharts share one template namespace -- never
* a same-named define of an unrelated chart; a define may also be written in
@@ -6313,6 +7102,26 @@ SUITE(grammar_probe_f) {
RUN_TEST(probe_terraform_helm_release_deploys_its_chart);
RUN_TEST(probe_helm_templated_objects_are_resources);
RUN_TEST(probe_helm_chart_deploys_its_objects);
RUN_TEST(probe_helm_chart_crds_and_werf_chart);
RUN_TEST(probe_docker_default_images_marked_and_overrides);
RUN_TEST(probe_helm_template_images_render_with_defaults);
RUN_TEST(probe_helm_release_images);
RUN_TEST(probe_helm_release_terraform_number_set);
RUN_TEST(probe_helm_release_flux_unread_sources);
RUN_TEST(probe_helm_release_kustomize_unread_sources);
RUN_TEST(probe_helm_release_terraform_dynamic_set);
RUN_TEST(probe_helm_release_terraform_sensitive_and_postrender);
RUN_TEST(probe_helm_release_terraform_sensitive_read_earlier);
RUN_TEST(probe_helm_release_terraform_postrender_forms);
RUN_TEST(probe_helm_checksum_include_by_base_path);
RUN_TEST(probe_helm_release_kustomize_transformers);
RUN_TEST(probe_helm_empty_values_file_renders);
RUN_TEST(probe_helm_aliased_subchart_chart_name);
RUN_TEST(probe_helm_list_path_mark_on_chart_edge);
RUN_TEST(probe_helm_values_symlink_not_followed);
RUN_TEST(probe_helm_terraform_values_symlink_not_followed);
RUN_TEST(probe_docker_build_args_keep_no_secret);
RUN_TEST(probe_docker_long_build_args_kept_whole);
RUN_TEST(probe_helm_include_resolves_in_the_chart_family);
RUN_TEST(probe_k8s_selector_stays_in_its_directory);
RUN_TEST(probe_k8s_selector_within_kustomize_build);
-1
View File
@@ -45,7 +45,6 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg
job "changes" success
job "security / security-static" success
job "security / license-gate" skipped
job "security / codeql-gate" skipped
job "lint / lint" success
job "lint / lint-mem" success
job "test / setup-matrix" success
+2 -2
View File
@@ -156,10 +156,10 @@ require("test_selection: ${{ needs.select-tests.result == 'success' && "
MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_test.yml",
"unix-macos-intel": "_test.yml", "windows": "_test.yml", "tsan-x86": "_test.yml",
"tsan-arm": "_test.yml", "tsan-mac": "_test.yml", "smoke-ubuntu": "pr.yml",
"smoke-mac": "pr.yml", "smoke-win": "pr.yml"}
"smoke-mac": "pr.yml", "smoke-win": "pr.yml", "codeql-cpp": "pr.yml",
"codeql-js": "pr.yml", "codeql-py": "pr.yml", "codeql-actions": "pr.yml"}
SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"),
"license-gate": ("_security.yml", "license-gate"),
"codeql-gate": ("_security.yml", "codeql-gate"),
"pkg-wrappers": ("_test.yml", "test-package-wrappers"),
"diag": ("_test.yml", "test-diag"), "msan": ("_test.yml", "test-msan"),
"lsan-macos": ("_test.yml", "test-lsan-macos"),
+54
View File
@@ -2463,6 +2463,59 @@ TEST(server_handle_unknown_method) {
PASS();
}
/* Issue #2433: a modern client probes server/discover before initialize.
* The -32601 stays so it falls back; only that probe is logged as info. */
TEST(server_handle_discover_probe_logs_info) {
mcp_log_buf[0] = '\0';
CBMLogLevel prev_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"code\":-32601"));
ASSERT_NOT_NULL(strstr(resp, "Method not found"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=server/discover"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "level=info"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok"));
ASSERT_NULL(strstr(mcp_log_buf, "level=warn"));
ASSERT_NULL(strstr(mcp_log_buf, "status=error"));
free(resp);
mcp_log_buf[0] = '\0';
resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"unknown/method\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "-32601"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=unknown/method"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "level=warn"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=error"));
free(resp);
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"initialize\","
"\"params\":{\"protocolVersion\":\"2026-07-28\"}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "2025-11-25"));
ASSERT_NULL(strstr(resp, "2026-07-28"));
free(resp);
resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"resources/list\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"resources\":[]"));
ASSERT_NULL(strstr(resp, "-32601"));
free(resp);
cbm_mcp_server_free(srv);
cbm_log_set_sink(NULL);
cbm_log_set_level(prev_level);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* TOOL HANDLERS (via server_handle)
* ══════════════════════════════════════════════════════════════════ */
@@ -25013,6 +25066,7 @@ SUITE(mcp) {
RUN_TEST(server_handle_prompts_get_validates_arguments);
RUN_TEST(server_handle_logs_request_without_params);
RUN_TEST(server_handle_unknown_method);
RUN_TEST(server_handle_discover_probe_logs_info);
/* Server handle — edge cases */
RUN_TEST(server_handle_invalid_json);
+1
View File
@@ -1476,6 +1476,7 @@ static const char *const JSX_AMP_BODIES[] = {
"<p title=\"a&\">x</p>", /* '&' right before the quote */
"<p>Tom &Jerry</p>", /* '&' + letter in JSX text */
"<p>Tom & Jerry</p>", /* '&' + space in JSX text */
"<th className=\"p-4 font-semibold\">Role & Dept</th>", /* #2481 report reproduction */
"<p>x &1 y</p>", /* '&' + digit in JSX text */
/* controls: references keep parsing; '& ' / '&1' in attributes */
"<p title=\"x&amp;y&#38;z & w &1\">A&amp;B &#38; C</p>",
+1246 -1
View File
File diff suppressed because it is too large Load Diff
+168 -57
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# The code-scanning gate must FAIL CLOSED.
# The code-scanning gate must FAIL CLOSED -- and CodeQL must run where it gates.
#
# History. `codeql-gate` in .github/workflows/_security.yml counted open alerts
# with:
# History. `codeql-gate` in .github/workflows/_security.yml (since moved into
# .github/workflows/codeql.yml as the analyze job's alert check) counted open
# alerts with:
#
# ALERTS=$(gh api '.../code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
#
@@ -21,6 +22,18 @@
# A security gate that cannot read its input must stop the build, not wave it
# through. This test pins both halves of that: the permission that makes the
# read possible, and the fail-closed handling for when it still is not.
#
# It also pins where CodeQL runs. The former PR gate polled up to 150 min per
# PR for a separate CodeQL run and starved PR CI of runners; now:
# - no pull_request trigger and no job that polls for a CodeQL run;
# - PR CI calls codeql.yml as its own job (needed by ci-ok), one leg per
# selected codeql-* lane; each leg analyses its language's whole
# first-party scope (never narrowed by the diff); c-cpp compiles the
# vendored objects before tracing, then traces the first-party build plus
# the vendored files the PR changes;
# - push to main, the dry run and the release run the full scan, and the
# release's verify needs it;
# - superseded push and PR runs are cancelled, dispatched ones never.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -28,6 +41,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT" <<'PY'
from __future__ import annotations
import json
import pathlib
import re
import sys
@@ -41,25 +55,48 @@ def require(condition: bool, message: str) -> None:
failures.append(message)
workflow = (root / ".github" / "workflows" / "_security.yml").read_text(encoding="utf-8")
workflow_dir = root / ".github" / "workflows"
# Isolate the codeql-gate job so a permission granted to some OTHER job cannot
# satisfy this test.
gate = re.search(r"\n codeql-gate:\n(?P<body>(?: .*\n|\n)*)", workflow)
require(gate is not None, "_security.yml no longer defines a codeql-gate job")
body = gate.group("body") if gate else ""
def text(name: str) -> str:
return (workflow_dir / name).read_text(encoding="utf-8")
def job_body(source: str, job: str) -> str | None:
m = re.search(rf"\n {re.escape(job)}:\n(?P<body>(?: .*\n|\n)*)", source)
return m.group("body") if m else None
def code_only(source: str) -> str:
return "\n".join(line for line in source.splitlines() if not line.lstrip().startswith("#"))
def steps(body: str) -> list[tuple[str, str]]:
"""(name, step text) per `- name:`/`- uses:` step of a job body."""
found = []
for m in re.finditer(r"(?ms)^ - (?P<step>.*?)(?=^ - |\Z)", body):
step = m.group("step")
name = re.search(r"(?m)^(?:name: |\s+name: )(.*)$", step)
found.append((name.group(1).strip() if name else step.splitlines()[0], step))
return found
workflow = text("codeql.yml")
gate = job_body(workflow, "analyze")
require(gate is not None, "codeql.yml no longer defines an analyze job")
body = gate or ""
# 1. The read has to be possible at all.
require(
re.search(r"^ permissions:\s*$", body, re.M) is not None,
"codeql-gate must declare its own permissions block -- inheriting the "
"analyze must declare its own permissions block -- inheriting the "
"workflow default leaves it without security-events access and the alert "
"API answers 403",
)
require(
re.search(r"^ security-events:\s*read\s*$", body, re.M) is not None,
"codeql-gate must request security-events: read, or the code-scanning "
"alert query 403s and the gate cannot see any alert",
re.search(r"^ security-events:\s*(read|write)\s*$", body, re.M) is not None,
"analyze must hold security-events (write implies read), or the "
"code-scanning alert query 403s and the gate cannot see any alert",
)
# 2. The failure direction. `|| echo "0"` on the alert query is the specific
@@ -71,11 +108,11 @@ require(
)
require(
"BLOCKED: cannot read code scanning alerts" in body,
"codeql-gate must fail with a named error when the alert API cannot be read",
"the alert check must fail with a named error when the alert API cannot be read",
)
require(
re.search(r"alert count was not a number", body) is not None,
"codeql-gate must reject a non-numeric alert count instead of comparing it",
"the alert check must reject a non-numeric alert count instead of comparing it",
)
# 3. The count is produced in a command substitution, so a bare `exit` inside
@@ -92,53 +129,127 @@ require(
"inside a command substitution does not stop the caller on its own",
)
# 4. Every caller must GRANT what the job requests. A reusable workflow cannot
# request more permission than its caller holds: asking for
# security-events:read from a caller that grants only contents:read does not
# downgrade, it fails the entire run with a startup_failure before a single
# job executes. Checking only the called workflow is how this test passed
# while the PR pipeline never started.
# The caller list is DERIVED, never hardcoded. An earlier revision listed
# ("pr.yml", "release.yml") by hand and stayed green while dry-run.yml -- a
# third caller nobody had listed -- failed every run at startup. A test that
# only checks the callers you remembered cannot catch the one you forgot.
workflow_dir = root / ".github" / "workflows"
# 4. Triggers: never per pull request (PR CI calls the workflow instead), on
# push to main, callable by PR CI, the dry run and the release.
on = re.search(r"(?ms)^on:\n(?P<body>.*?)(?=^\S)", workflow)
triggers = set(re.findall(r"(?m)^ ([a-z_]+):", on.group("body"))) if on else set()
require("pull_request" not in triggers and "pull_request_target" not in triggers,
"codeql.yml must not trigger on pull requests (pr.yml calls it as a job)")
require({"push", "workflow_call"} <= triggers,
f"codeql.yml must run on push to main and be callable, has {sorted(triggers)}")
require(re.search(r"(?m)^ push:\n branches: \[main\]$", workflow) is not None,
"codeql.yml's push trigger must be main")
# 5. Nothing waits for a separate CodeQL run, and _security.yml has no CodeQL.
for path in sorted(workflow_dir.glob("*.yml")):
require("workflows/codeql.yml/runs" not in code_only(path.read_text(encoding="utf-8")),
f"{path.name} polls for a CodeQL run -- CodeQL runs as a job of the caller")
require("codeql" not in code_only(text("_security.yml")).lower(),
"_security.yml must not run or wait for CodeQL")
# 6. Superseded runs are cancelled (push to main, a PR), dispatched never;
# legs of one run never cancel each other.
concurrency = re.search(r"(?ms)^ concurrency:\n(.*?)(?=^ \S)", body)
conc = concurrency.group(1) if concurrency else ""
require("matrix.language" in conc, "the concurrency group must be per language")
require("github.event_name == 'push' && github.ref" in conc,
"push runs must share a ref-scoped group (only the newest main commit is scanned)")
require("github.event.pull_request.number" in conc,
"PR scans must share a per-PR group")
require("cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}" in conc,
"only push and PR scans may be cancelled -- a dry run or release tests one commit")
# 7. The PR scan: analysis never narrowed by the diff; c-cpp prebuilds the
# vendored objects untraced and traces first-party + changed vendored.
names = [name for name, _ in steps(body)]
by_name = dict(steps(body))
def index(fragment: str) -> int:
hits = [i for i, n in enumerate(names) if fragment in n]
return hits[0] if hits else -1
init, analyze = index("Initialize CodeQL"), index("Perform CodeQL Analysis")
prebuild, retrace = index("Build third-party objects"), index("Vendored files this PR changes")
light_build, full_build = index("PR scan, first-party"), index("Build for CodeQL analysis (full)")
require(min(init, analyze, prebuild, retrace, light_build, full_build) >= 0,
f"codeql.yml analyze lost a step, has {names}")
require(-1 < prebuild < init and -1 < retrace < init,
"the vendored prebuild and the retrace list must run BEFORE Initialize CodeQL (untraced)")
require(init < light_build < analyze and init < full_build < analyze,
"the traced builds must run between Initialize CodeQL and the analysis")
for name in names:
if "Initialize CodeQL" in name or "Perform CodeQL Analysis" in name or "open code scanning alerts" in name:
require(not re.search(r"(?m)^\s+if:", by_name[name]),
f"step '{name}' must never be skipped -- a selected leg always analyses")
light_step = by_name.get(names[light_build], "") if light_build >= 0 else ""
require("scripts/build.sh --reuse-vendored --retrace-from" in light_step,
"the PR c-cpp build must reuse the untraced vendored objects and retrace the changed ones")
require("scripts/ci/codeql-retrace-vendored.sh" in (by_name.get(names[retrace], "") if retrace >= 0 else ""),
"the retrace list must come from scripts/ci/codeql-retrace-vendored.sh")
require("scripts/build.sh --vendored-only" in (by_name.get(names[prebuild], "") if prebuild >= 0 else ""),
"the PR c-cpp prebuild must be scripts/build.sh --vendored-only")
require(re.search(r"(?m)^\s+run: scripts/build\.sh\s*$", by_name.get(names[full_build], "") if full_build >= 0 else "") is not None,
"the full c-cpp scan must trace the whole production build (vendored C included)")
require(re.search(r'(?m)^ CBM_NO_CCACHE: "1"$', body) is not None,
"the CodeQL job must disable the compiler cache -- a cache hit compiles nothing to trace")
require("config-file: ./.github/codeql/codeql-config.yml" in body,
"every scan must use the first-party scope in .github/codeql/codeql-config.yml")
# 8. One configuration per language: the full matrix and PR CI's legs agree.
default = re.search(r"fromJSON\(inputs\.matrix \|\| '(?P<json>\{.*?\})'\)", body)
pr = text("pr.yml")
pr_legs = re.search(r"CODEQL='(?P<json>\[.*?\])'", pr)
full_legs = json.loads(default.group("json"))["include"] if default else []
require({leg.get("language") for leg in full_legs} ==
{"c-cpp", "javascript-typescript", "python", "actions"},
"the full scan must cover c-cpp, javascript-typescript, python and actions")
require(pr_legs is not None and json.loads(pr_legs.group("json")) == full_legs,
"pr.yml's CodeQL legs must equal codeql.yml's full matrix (one configuration per language)")
# 9. Every caller GRANTS what codeql.yml requests (a called workflow cannot
# request more than its caller holds: startup failure, not a downgrade).
# The caller list is DERIVED, never hardcoded: an earlier revision listed
# callers by hand and stayed green while a third caller nobody had listed
# failed every run at startup.
callers = sorted(
path.name
for path in workflow_dir.glob("*.yml")
if "uses: ./.github/workflows/_security.yml" in path.read_text(encoding="utf-8")
if "uses: ./.github/workflows/codeql.yml" in path.read_text(encoding="utf-8")
)
require(
len(callers) >= 3,
"expected at least 3 callers of _security.yml, found: "
+ (", ".join(callers) or "none")
+ " -- if a caller was deliberately removed, lower this floor on purpose",
)
require({"pr.yml", "dry-run.yml", "release.yml"} <= set(callers),
"PR CI, the dry run and the release must call codeql.yml, callers: "
+ (", ".join(callers) or "none"))
for caller_name in callers:
caller = (workflow_dir / caller_name).read_text(encoding="utf-8")
# The next sibling may be a comment line, not a key, so the lookahead has to
# accept any 2-space-indented non-space -- matching only ` \w` silently
# failed to find release.yml's job at all.
call = re.search(
r"\n security:\n(?P<body>(?: .*\n|\n)*?)(?=\n? \S|\Z)", caller
)
require(call is not None, f"{caller_name} no longer has a security: job")
call_body = call.group("body") if call else ""
require(
"uses: ./.github/workflows/_security.yml" in call_body,
f"{caller_name}'s security job must call _security.yml",
)
require(
re.search(r"^ security-events:\s*read\s*$", call_body, re.M) is not None,
f"{caller_name} must grant security-events: read to the security job -- "
"a called workflow cannot request more than its caller grants, and the "
"run fails at startup rather than degrading",
)
require(
re.search(r"^ actions:\s*read\s*$", call_body, re.M) is not None,
f"{caller_name} must grant actions: read to the security job",
)
call_body = job_body(text(caller_name), "codeql") or ""
require("uses: ./.github/workflows/codeql.yml" in call_body,
f"{caller_name} must call codeql.yml from a job named codeql")
for perm, level in (("security-events", "write"), ("actions", "read"), ("contents", "read")):
require(re.search(rf"^ {perm}:\s*{level}\s*$", call_body, re.M) is not None,
f"{caller_name} must grant {perm}: {level} to the codeql job")
light = re.search(r"(?m)^ light: true$", call_body) is not None
require(light == (caller_name == "pr.yml"),
f"{caller_name}: only PR CI runs the PR scan (light: true); "
"the dry run and the release run the full scan")
# 10. PR CI: the codeql job runs for any selected codeql-* lane and ci-ok
# needs it; the release publishes nothing without the CodeQL pass.
pr_codeql = job_body(pr, "codeql") or ""
cond = re.search(r"(?m)^ if: (.*)$", pr_codeql)
for lane in ("codeql-cpp", "codeql-js", "codeql-py", "codeql-actions"):
require(cond is not None and f"'{lane}'" in cond.group(1),
f"pr.yml codeql must run when lane {lane} is selected")
require("matrix: ${{ needs.changes.outputs.codeql }}" in pr_codeql,
"pr.yml codeql must run the legs the changes job selected")
ci_ok = job_body(pr, "ci-ok") or ""
require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", ci_ok) is not None,
"ci-ok must need the codeql job")
verify = job_body(text("release.yml"), "verify") or ""
require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", verify) is not None,
"release.yml verify must need the codeql job")
require("needs.codeql.result == 'success'" in verify,
"release.yml verify must require needs.codeql.result == 'success'")
if failures:
print("Security gate fail-closed contract FAILED:")
+86 -1
View File
@@ -35,6 +35,7 @@ trap 'rm -rf "$WORK"' EXIT
# explicit path): a bare "bash" argv can resolve to the WSL alias on Windows.
python3 - "$BASH" "$SELECTOR" "$CASES" "$WORK" <<'PY'
import json
import os
import pathlib
import random
import subprocess
@@ -47,12 +48,14 @@ failures = []
# Every lane of the full PR pipeline, spelled out here rather than read from
# the selector, so a lane silently dropped from the selector's universe fails.
FULL = {
"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos",
"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",
"codeql-cpp", "codeql-js", "codeql-py", "codeql-actions",
}
CODEQL = {"codeql-cpp", "codeql-js", "codeql-py", "codeql-actions"}
UNIVERSE = FULL | {"contracts"}
@@ -201,6 +204,88 @@ for bad in ("many", ""): # "" = a workflow whose count went missing
failures.append(f"--expect-files {bad!r} must be a usage error (exit 2), never "
f"a silently skipped truncation check")
# 8. The CodeQL PR scans (codeql-* lanes) fail closed:
# (a) a C/C++ change -- first-party or vendored -- always selects the C scan;
# (b) an undeterminable diff (an empty or truncated list here; a diff the
# retrace helper cannot compute below) selects every language's full
# first-party scan, and so does a change to the workflows, the build or
# the CodeQL machinery;
# (c) a vendored-only change scans the changed vendored files: the C lane
# is selected and the retrace helper lists exactly those files, which
# codeql.yml then compiles under tracing.
# Tests, docs and vendored non-C select no CodeQL lane.
def lanes_for(files, args=()):
rc, out, _ = run(list(args), "".join(f + "\n" for f in files))
return set(json.loads(out)["lanes"]) if rc == 0 else None
for c_file in ("src/pipeline/pass_calls.c", "src/foundation/mem_core.c",
"internal/cbm/extract_calls.c", "internal/cbm/lsp/go_lsp.c",
"internal/cbm/cbm.h", "src/newdir/x.c",
"internal/cbm/vendored/grammars/go/parser.c",
"internal/cbm/vendored/lz4/lz4.c", "vendored/sqlite3/sqlite3.c",
"vendored/mimalloc/src/options.c"):
got = lanes_for([c_file])
if got is None or "codeql-cpp" not in got:
failures.append(f"codeql (a): a C/C++ change ({c_file}) must select codeql-cpp, "
f"got {sorted(got or [])}")
for label, files, args in (("empty file list", [], ()),
("truncated list", ["docs/a.md"], ("--expect-files", "5")),
("a workflow", [".github/workflows/stale.yml"], ()),
("Makefile.cbm", ["Makefile.cbm"], ()),
("scripts/build.sh", ["scripts/build.sh"], ()),
("the selector", ["scripts/ci/select-lanes.sh"], ()),
("the CodeQL config", [".github/codeql/codeql-config.yml"], ()),
("the retrace helper", ["scripts/ci/codeql-retrace-vendored.sh"], ())):
got = lanes_for(files, args)
if got is None or not CODEQL <= got:
failures.append(f"codeql (b): {label} must select every CodeQL language, "
f"got {sorted((got or set()) & CODEQL)}")
for quiet in ("tests/test_store.c", "tests/fixtures/x.py", "README.md", "docs/a.md",
"graph-ui/vendor/lib.js", "scripts/repro.sh"):
got = lanes_for([quiet])
if got is None or got & CODEQL:
failures.append(f"codeql: {quiet} must select no CodeQL lane, "
f"got {sorted((got or set()) & CODEQL)}")
for language, path in (("javascript-typescript", "graph-ui/src/App.tsx"),
("javascript-typescript", "pkg/npm/install.js"),
("python", "scripts/test-impact/x.py"),
("actions", ".github/workflows/pr.yml")):
rc, out, _ = run(["--codeql-scope", language], path + "\n")
if rc != 0 or out.split() != [path]:
failures.append(f"--codeql-scope {language} must keep {path}, printed {out!r}")
rc, _, err = run(["--codeql-scope", "go"], "src/main.c\n")
if rc != 2 or "Please consult --help." not in err:
failures.append("--codeql-scope with an unknown language must be a usage error (exit 2)")
retrace = pathlib.Path(selector).parent / "codeql-retrace-vendored.sh"
env = {k: v for k, v in os.environ.items() if k != "GITHUB_EVENT_NAME"}
changed = work / "codeql-changed.txt"
changed.write_text("src/main.c\ninternal/cbm/vendored/grammars/go/parser.c\n"
"vendored/sqlite3/sqlite3.c\nvendored/no-such-file.c\n"
"internal/cbm/grammar_json.c\ninternal/cbm/ts_runtime.c\n"
"graph-ui/src/App.tsx\n", encoding="utf-8")
listed = work / "codeql-retrace.txt"
proc = subprocess.run([bash, str(retrace), "--changed", str(changed), str(listed)],
capture_output=True, text=True, env=env)
got = listed.read_text(encoding="utf-8").split() if listed.exists() else None
if proc.returncode != 0 or got != ["internal/cbm/grammar_go.c",
"internal/cbm/grammar_json.c",
"internal/cbm/ts_runtime.c",
"vendored/sqlite3/sqlite3.c"]:
failures.append(f"codeql (c): the retrace helper must list the translation units for "
f"changed vendored code and wrappers, got {got} (exit {proc.returncode})")
for label, args in (("an unreadable change list", ["--changed", str(work / "missing.txt")]),
("no pull request", [])):
listed.write_text("stale\n", encoding="utf-8")
proc = subprocess.run([bash, str(retrace), *args, str(listed)],
capture_output=True, text=True, env=env)
if proc.returncode != 0 or listed.read_text(encoding="utf-8") != "" \
or "undetermined" not in proc.stdout:
failures.append(f"codeql (b): with {label} the retrace helper must succeed with an "
f"empty list (the scan still covers all first-party C), "
f"exit {proc.returncode}: {proc.stdout.strip()!r}")
if failures:
print("LANE SELECTOR CONTRACT VIOLATED:")
for failure in failures:
+110 -10
View File
@@ -12,6 +12,7 @@
#include "test_framework.h"
#include "cbm.h"
#include "lang_specs.h" /* cbm_ts_language — direct-parse GLR cap regression (#913) */
#include "lsp/ts_lsp.h"
#include <stdio.h>
#include <stdlib.h>
@@ -399,11 +400,11 @@ TEST(cpp_large_templated_header_no_crash_issue424) {
* java_resolve_calls_in_node frames (bind_lambda_args), bitcoin → SIGSEGV
* under deep c_resolve_calls_in_node frames (cbm_type_substitute via
* c_adl_resolve), microsoft/TypeScript → SIGBUS under an unbounded
* lookup_member_type cycle. The walks now carry depth guards; these
* reproductions fork a child so a regression cannot kill the test runner
* (the TS cyclic-type shape is only reachable with a real cross-file
* registry, so that one is verified at the real-repo tier; the synthetic
* cyclic fixture here guards the in-file path).
* lookup_member_type cycle. The walks now carry depth guards. Non-TS crash
* fixtures fork on POSIX and run in-process on Windows. TypeScript cycle work
* runs in-process on both platforms to assert test-only lookup counters.
* The synthetic single-file fixtures here guard the local inheritance path;
* real-repo scale extraction is checked separately.
* ═══════════════════════════════════════════════════════════════════ */
#if !defined(_WIN32)
@@ -670,9 +671,106 @@ TEST(lsp_ts_cyclic_types_no_crash) {
"interface D extends C { d: number; }\n"
"declare const a: A;\n"
"declare const c: C;\n"
"function useIt(p: C) { return p.missing_member; }\n"
/* Both lookup paths must traverse the inheritance cycle. */
"function useIt(p: C) { p.missing(); return p.missing_member; }\n"
"const y = c.also_missing;\n";
ASSERT_FALSE(so_extract_crashes(src, CBM_LANG_TYPESCRIPT, "cycle.ts"));
CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "cycle.ts");
ASSERT_NOT_NULL(r);
/* C and D each have one parent: <= 64 admitted visits per type, plus
* one attempted (possibly rejected) parent visit per admitted visit. */
ASSERT_GT(cbm_ts_lsp_test_max_member_lookup_steps(), 0);
ASSERT_GT(cbm_ts_lsp_test_max_method_lookup_steps(), 0);
ASSERT_LTE(cbm_ts_lsp_test_max_member_lookup_steps(), 2 * 64 * 2);
ASSERT_LTE(cbm_ts_lsp_test_max_method_lookup_steps(), 2 * 64 * 2);
cbm_free_result(r);
PASS();
}
TEST(lsp_ts_branching_cycle_has_bounded_work) {
const char *src =
"interface A extends B, C {}\n"
"interface B extends A, C {}\n"
"interface C extends A, B {}\n"
"function useIt(value: A) { value.missing(); return value.missing_member; }\n";
CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "branching-cycle.ts");
ASSERT_NOT_NULL(r);
/* A, B, C each have two parents: <= 64 admitted visits per type, plus
* two attempted (possibly rejected) parent visits per admitted visit. */
ASSERT_GT(cbm_ts_lsp_test_max_member_lookup_steps(), 0);
ASSERT_GT(cbm_ts_lsp_test_max_method_lookup_steps(), 0);
ASSERT_LTE(cbm_ts_lsp_test_max_member_lookup_steps(), 3 * 64 * 3);
ASSERT_LTE(cbm_ts_lsp_test_max_method_lookup_steps(), 3 * 64 * 3);
cbm_free_result(r);
PASS();
}
TEST(lsp_ts_shallower_revisit_preserves_inherited_method) {
enum { CHAIN = 62, CAP = 16384 };
char *src = malloc(CAP);
ASSERT_NOT_NULL(src);
size_t used =
(size_t)snprintf(src, CAP,
"interface Service { ping(): void; }\n"
"interface Target { inherited(): void; inheritedField: Service; }\n"
"interface Shared extends Target {}\n"
"interface Root extends D0, Shared {}\n");
for (int i = 0; i < CHAIN; i++) {
const char *parent = i + 1 < CHAIN ? "D" : "Shared";
int wrote = i + 1 < CHAIN
? snprintf(src + used, CAP - used, "interface D%d extends %s%d {}\n", i,
parent, i + 1)
: snprintf(src + used, CAP - used, "interface D%d extends Shared {}\n", i);
ASSERT_TRUE(wrote > 0 && (size_t)wrote < CAP - used);
used += (size_t)wrote;
}
int wrote = snprintf(
src + used, CAP - used,
"function useIt(value: Root) { value.inherited(); value.inheritedField.ping(); }\n");
ASSERT_TRUE(wrote > 0 && (size_t)wrote < CAP - used);
CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "shallower.ts");
free(src);
ASSERT_NOT_NULL(r);
int inherited_method_found = 0;
int inherited_field_method_found = 0;
for (int i = 0; i < r->resolved_calls.count; i++) {
const CBMResolvedCall *resolved = &r->resolved_calls.items[i];
if (resolved->confidence > 0 && resolved->caller_qn &&
strstr(resolved->caller_qn, "useIt") && resolved->callee_qn &&
strstr(resolved->callee_qn, "inherited")) {
inherited_method_found = 1;
}
if (resolved->confidence > 0 && resolved->caller_qn &&
strstr(resolved->caller_qn, "useIt") && resolved->callee_qn &&
strstr(resolved->callee_qn, "ping")) {
inherited_field_method_found = 1;
}
}
cbm_free_result(r);
ASSERT_TRUE(inherited_method_found);
ASSERT_TRUE(inherited_field_method_found);
PASS();
}
TEST(lsp_ts_inherited_method_lookup) {
const char *src = "interface Base { inherited(): void; }\n"
"interface Child extends Base {}\n"
"function useIt(c: Child) { c.inherited(); }\n";
CBMFileResult *r = extract(src, CBM_LANG_TYPESCRIPT, "test", "inherited.ts");
ASSERT_NOT_NULL(r);
int found = 0;
for (int i = 0; i < r->resolved_calls.count; i++) {
const CBMResolvedCall *resolved = &r->resolved_calls.items[i];
if (resolved->confidence > 0 && resolved->caller_qn &&
strstr(resolved->caller_qn, "useIt") && resolved->callee_qn &&
strstr(resolved->callee_qn, "inherited")) {
found = 1;
break;
}
}
cbm_free_result(r);
ASSERT_TRUE(found);
PASS();
}
@@ -777,9 +875,8 @@ TEST(lsp_kotlin_deep_nesting_no_crash) {
/* Split into three sub-suites so parallel/sharded runs are not serialized
* behind one ~4-minute suite (it was the wall-clock critical path: every
* other suite finished underneath it). Pure re-registration — the 20
* RUN_TEST entries are exactly the ones the single suite carried; the
* before/after test-count parity is asserted in the shard runner. */
* other suite finished underneath it). New regressions stay in the matching
* shard; the shard runner asserts aggregate test-count parity. */
SUITE(stack_overflow_a) {
cbm_init();
@@ -799,6 +896,9 @@ SUITE(stack_overflow_b) {
RUN_TEST(perl_glr_deep_parse_recursion_capped);
RUN_TEST(lsp_ts_cyclic_types_no_crash);
RUN_TEST(lsp_ts_branching_cycle_has_bounded_work);
RUN_TEST(lsp_ts_shallower_revisit_preserves_inherited_method);
RUN_TEST(lsp_ts_inherited_method_lookup);
RUN_TEST(lsp_python_deep_nesting_no_crash);
RUN_TEST(lsp_go_deep_nesting_no_crash);
RUN_TEST(lsp_php_deep_nesting_no_crash);
+121
View File
@@ -1156,6 +1156,125 @@ TEST(tslsp_jsx_component_resolutions_join_exact_tag_sites) {
PASS();
}
TEST(tslsp_jsx_top_level_async_arrow_callers_keep_exact_identity) {
const char *source = "function Card(): JSX.Element { return <span/>; }\n"
"const Direct = async () => <Card/>;\n"
"export const Exported = async () => <Card/>;\n"
"function Declared(): JSX.Element { return <Card/>; }\n";
const char *const declarations[] = {"const Direct", "export const Exported",
"function Declared"};
const char *const callers[] = {"test.main.Direct", "test.main.Exported", "test.main.Declared"};
CBMFileResult *r = extract_tsx(source);
const bool result_created = r != NULL;
bool exact_carrier[3] = {false};
bool exact_resolution[3] = {false};
bool resolved_invocation[3] = {false};
bool resolved_card_callee[3] = {false};
bool resolved_jsx_strategy[3] = {false};
int carrier_count[3] = {0};
int failed_cases = result_created ? 0 : 1;
if (!result_created)
printf(" JSX caller diagnostic: extraction failed\n");
if (r) {
for (int i = 0; i < 3; i++) {
const char *declaration = strstr(source, declarations[i]);
const char *site = declaration ? strstr(declaration, "<Card/>") : NULL;
if (!site) {
printf(" JSX caller diagnostic: source site missing for %s\n", callers[i]);
failed_cases++;
continue;
}
const uint32_t start = (uint32_t)(site - source);
const uint32_t end = start + (uint32_t)strlen("<Card/>");
const CBMCall *carrier = NULL;
for (int j = 0; j < r->calls.count; j++) {
const CBMCall *call = &r->calls.items[j];
if (call->callee_name && strcmp(call->callee_name, "Card") == 0 &&
call->site_start_byte == start && call->site_end_byte == end) {
carrier = call;
carrier_count[i]++;
}
}
const CBMResolvedCall *resolved = carrier ? cbm_pipeline_find_lsp_resolution(
&r->resolved_calls, carrier, false)
: NULL;
exact_carrier[i] = carrier && carrier->enclosing_func_qn &&
strcmp(carrier->enclosing_func_qn, callers[i]) == 0 &&
carrier->site_start_byte == start && carrier->site_end_byte == end &&
carrier->requires_lsp_resolution;
exact_resolution[i] = resolved && resolved->caller_qn &&
strcmp(resolved->caller_qn, callers[i]) == 0 &&
resolved->site_start_byte == start && resolved->site_end_byte == end;
resolved_invocation[i] = resolved && resolved->kind == CBM_RESOLVED_INVOCATION;
if (resolved && resolved->callee_qn) {
const size_t callee_len = strlen(resolved->callee_qn);
resolved_card_callee[i] = callee_len >= strlen(".Card") &&
strcmp(resolved->callee_qn + callee_len - strlen(".Card"),
".Card") == 0;
}
resolved_jsx_strategy[i] =
resolved && resolved->strategy && strcmp(resolved->strategy, "lsp_ts_jsx") == 0;
if (carrier_count[i] != 1 || !exact_carrier[i] || !exact_resolution[i] ||
!resolved_invocation[i] || !resolved_card_callee[i] || !resolved_jsx_strategy[i]) {
printf(" JSX caller diagnostic: expected=%s span=%u:%u carriers=%d caller=%s "
"requires_lsp=%d resolution=%s resolution_span=%u:%u kind=%d callee=%s "
"strategy=%s\n",
callers[i], start, end, carrier_count[i],
carrier && carrier->enclosing_func_qn ? carrier->enclosing_func_qn : "(null)",
carrier ? carrier->requires_lsp_resolution : 0,
resolved && resolved->caller_qn ? resolved->caller_qn : "(null)",
resolved ? resolved->site_start_byte : 0, resolved ? resolved->site_end_byte : 0,
resolved ? resolved->kind : -1,
resolved && resolved->callee_qn ? resolved->callee_qn : "(null)",
resolved && resolved->strategy ? resolved->strategy : "(null)");
failed_cases++;
}
}
}
cbm_free_result(r);
ASSERT_EQ(failed_cases, 0);
PASS();
}
/* An exported arrow const is a module definition, not a lexical local. Bound
* into the module scope, it made the JSX resolver fail closed on `<Badge />`
* (the edge was lost: JSX carriers have no other resolver) and the exact
* same-module call path skip `formatLabel()`. Both callers must resolve both
* targets exactly, whether the caller is a function or an arrow const. */
static bool ts_resolved_with_strategy(const CBMFileResult *r, const char *caller,
const char *callee, const char *strategy) {
int idx = require_resolved(r, caller, callee);
const char *got = idx >= 0 ? r->resolved_calls.items[idx].strategy : NULL;
if (idx >= 0 && (!got || strcmp(got, strategy) != 0)) {
printf(" %s -> %s resolved by %s, expected %s\n", caller, callee, got ? got : "(null)",
strategy);
}
return got && strcmp(got, strategy) == 0;
}
TEST(tslsp_jsx_exported_arrow_component_resolves_in_same_module) {
CBMFileResult *r =
extract_tsx("export const Badge = () => <span/>;\n"
"export const formatLabel = (s: string) => s.trim();\n"
"export function FnUser() { formatLabel('a'); return <Badge/>; }\n"
"export const ArrowUser = () => { formatLabel('b'); return <Badge/>; };\n");
ASSERT_NOT_NULL(r);
bool fn_jsx = ts_resolved_with_strategy(r, "main.FnUser", "main.Badge", "lsp_ts_jsx");
bool arrow_jsx = ts_resolved_with_strategy(r, "main.ArrowUser", "main.Badge", "lsp_ts_jsx");
bool fn_call = ts_resolved_with_strategy(r, "main.FnUser", "main.formatLabel", "lsp_ts_local");
bool arrow_call =
ts_resolved_with_strategy(r, "main.ArrowUser", "main.formatLabel", "lsp_ts_local");
cbm_free_result(r);
ASSERT_TRUE(fn_jsx);
ASSERT_TRUE(arrow_jsx);
ASSERT_TRUE(fn_call);
ASSERT_TRUE(arrow_call);
PASS();
}
/* A callable value named like a module component owns the JSX tag. The LSP
* pass must not confidently redirect it to the module function, and the
* carrier must require semantic resolution so graph construction cannot do
@@ -4863,6 +4982,8 @@ SUITE(ts_lsp) {
RUN_TEST(tslsp_jsx_component_with_children);
RUN_TEST(tslsp_jsx_import_requires_registered_component);
RUN_TEST(tslsp_jsx_component_resolutions_join_exact_tag_sites);
RUN_TEST(tslsp_jsx_top_level_async_arrow_callers_keep_exact_identity);
RUN_TEST(tslsp_jsx_exported_arrow_component_resolves_in_same_module);
RUN_TEST(tslsp_jsx_local_tag_shadow_blocks_module_component);
RUN_TEST(tslsp_jsx_intrinsic_skipped);
RUN_TEST(tslsp_jsx_nested_component);
+2 -1
View File
@@ -39,7 +39,7 @@ TEST(userconfig_project_basic) {
char proj[512];
snprintf(proj, sizeof(proj), "%s/.codebase-memory.json", dir);
ASSERT_EQ(
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\"}}"),
write_json(proj, "{\"extra_extensions\":{\".blade.php\":\"php\",\".mjs\":\"javascript\",\".html\":\"jinja2\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(dir);
@@ -47,6 +47,7 @@ TEST(userconfig_project_basic) {
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".blade.php"), CBM_LANG_PHP);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".mjs"), CBM_LANG_JAVASCRIPT);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".html"), CBM_LANG_JINJA2);
ASSERT_EQ(cbm_userconfig_lookup(cfg, ".go"), CBM_LANG_COUNT); /* not in user config */
cbm_userconfig_free(cfg);
+2
View File
@@ -386,6 +386,7 @@ if [ -d "$ROOT/scripts" ] && [ -f "$ROOT/scripts/test.sh" ]; then
HELP_ENTRIES="
scripts/test.sh
scripts/build.sh
scripts/ci/codeql-retrace-vendored.sh
scripts/lint.sh
scripts/smoke-local.sh
scripts/soak-legs.sh
@@ -433,6 +434,7 @@ test-infrastructure/vm/win.sh
STRICT_ENTRIES="
scripts/test.sh
scripts/build.sh
scripts/ci/codeql-retrace-vendored.sh
scripts/lint.sh
scripts/smoke-local.sh
scripts/soak-legs.sh
+38 -4
View File
@@ -1103,8 +1103,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) {
watcher_windows_image_identity_t expected_git;
ASSERT_TRUE(watcher_windows_capture_image_identity(fake_git, &expected_git));
const char *old_path_raw = getenv("PATH");
const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV);
const char *old_path_raw = cbm_runtime_getenv("PATH");
const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV);
char *old_path = old_path_raw ? cbm_strdup(old_path_raw) : NULL;
char *old_marker = old_marker_raw ? cbm_strdup(old_marker_raw) : NULL;
size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0);
@@ -1238,8 +1238,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) {
ASSERT_TRUE(watcher_test_self_image(self));
ASSERT_EQ(symlink(self, fake_git), 0);
const char *old_path_raw = getenv("PATH");
const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV);
const char *old_path_raw = cbm_runtime_getenv("PATH");
const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV);
char *old_path = old_path_raw ? strdup(old_path_raw) : NULL;
char *old_marker = old_marker_raw ? strdup(old_marker_raw) : NULL;
size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0);
@@ -3995,7 +3995,41 @@ TEST(watcher_null_watch_count) {
* SUITE
* ══════════════════════════════════════════════════════════════════ */
TEST(watcher_unicode_root_is_not_pruned_as_missing) {
char *parent = th_mktempdir("cbm_watch_utf8");
ASSERT_NOT_NULL(parent);
char root[CBM_SZ_4K];
snprintf(root, sizeof(root), "%s/\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E", parent);
ASSERT_EQ(th_mkdir_p(root), 0);
const char *saved = cbm_runtime_getenv("CBM_WATCHER_PRUNE_GRACE_S");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1);
cbm_store_t *store = cbm_store_open_memory();
cbm_store_upsert_project(store, "unicode-root", root);
cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL);
cbm_watcher_watch(watcher, "unicode-root", root);
for (int i = 0; i < 8; i++) {
cbm_watcher_poll_once(watcher);
}
cbm_project_t info = {0};
bool kept = cbm_watcher_watch_count(watcher) == 1 &&
cbm_store_get_project(store, "unicode-root", &info) == CBM_STORE_OK;
cbm_project_free_fields(&info);
cbm_watcher_free(watcher);
cbm_store_close(store);
if (saved_copy) {
cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_copy, 1);
} else {
cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S");
}
free(saved_copy);
ASSERT_EQ(th_rmtree(parent), 0);
ASSERT_TRUE(kept);
PASS();
}
SUITE(watcher) {
RUN_TEST(watcher_unicode_root_is_not_pruned_as_missing);
/* Adaptive interval */
RUN_TEST(poll_interval_base);
RUN_TEST(poll_interval_scaling);
+95
View File
@@ -414,6 +414,100 @@ TEST(ws_manifest_approval_refuses_overbroad_requests) {
PASS();
}
/* #1718: the sensitive-root escape hatch broke on Windows separator spelling.
* The grant is recorded from cbm_canonical_path — backslashes — while the
* indexer hands the candidate already normalized to '/'. A byte-exact
* comparison made the grant never match the candidate, so an approved
* "Program Files" root stayed refused. '/' and '\' are equivalent everywhere
* else in this module (ws_is_sep); the boundary comparison must treat them
* equally. The classify_root home rule uses the same ws_paths_equal, so a
* mixed-separator home spelling is the string-level regression check; the
* FS-backed grant round-trip lives in the _WIN32 half, where the real
* canonicalization exercises it. */
TEST(ws_approve_sensitive_matches_across_separator_styles) {
/* canonical spelled with '\' vs home spelled with '/' — must classify as
* the same directory. Pre-fix: strncmp mismatch at the separator byte, so
* the home rule never matched and an ordinary path under "C:\Users" fell
* through to CBM_WS_ALLOW. */
ASSERT_EQ(cbm_workspace_classify_root("C:\\Users\\dev", "C:/Users/dev", CACHE),
CBM_WS_DENY_SENSITIVE);
ASSERT_EQ(cbm_workspace_classify_root("C:/Users/dev", "C:\\Users\\dev", CACHE),
CBM_WS_DENY_SENSITIVE);
/* A sibling that differs after the same separator boundary is still a
* different directory: "/a/bc" must never look like "/a/b". */
ASSERT_EQ(cbm_workspace_classify_root("C:\\Users\\devx", "C:/Users/dev", CACHE), CBM_WS_ALLOW);
#ifdef _WIN32
/* Full grant round-trip on real filesystem paths: the grant stored with
* '\' must lift the exact candidate the indexer passes with '/'. The
* sensitive class is exercised by a credential-named directory (".ssh"),
* which is DENY_SENSITIVE at any depth — equivalent to "Program Files"
* for the refusal path under test. */
char *cache = th_mktempdir("cbm_ws_sw");
ASSERT(cache != NULL);
char dir[1024];
snprintf(dir, sizeof(dir), "%s/.ssh/cfg", cache);
ASSERT_TRUE(cbm_mkdir_p(dir, 0700));
char path[1024];
snprintf(path, sizeof(path), "%s/allowed_roots", cache);
char grant[2048];
snprintf(grant, sizeof(grant), "!%s\\.ssh\\cfg\n", cache);
FILE *f = cbm_fopen(path, "wb");
ASSERT_NOT_NULL(f);
(void)fprintf(f, "%s", grant);
(void)fclose(f);
char candidate[2048];
snprintf(candidate, sizeof(candidate), "%s/.ssh/cfg", cache);
/* Convert to '/' spelling, exactly as cbm_normalize_path_sep does for the
* indexer's candidate. */
char *p;
for (p = candidate; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
char err[1024];
err[0] = '\0';
ASSERT_TRUE(cbm_workspace_root_allowed(candidate, NULL, cache, NULL, err, sizeof(err)));
/* An unapproved sibling under the same sensitive tree stays refused. It
* lies outside every granted root, so the refusal is the boundary wording
* (kept verbatim by contract), not the sensitive hint. */
char sibling[2048];
snprintf(sibling, sizeof(sibling), "%s/.ssh/keys", cache);
for (p = sibling; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
err[0] = '\0';
ASSERT_FALSE(cbm_workspace_root_allowed(sibling, NULL, cache, NULL, err, sizeof(err)));
ASSERT_TRUE(strstr(err, "outside the allowed root") != NULL);
/* A strict descendant INSIDE the approved sensitive grant is contained
* (boundary passes) but not the exact grant, so the sensitive refusal
* applies and the hint names the flag. */
char descendant[2048];
snprintf(descendant, sizeof(descendant), "%s/.ssh/cfg/sub", cache);
for (p = descendant; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
/* Containment canonicalizes the candidate, which on Windows opens it
* (CreateFileW, OPEN_EXISTING): a descendant that is not on disk is
* "outside the allowed root" instead of reaching the sensitive hint. */
ASSERT_TRUE(cbm_mkdir_p(descendant, 0700));
err[0] = '\0';
ASSERT_FALSE(cbm_workspace_root_allowed(descendant, NULL, cache, NULL, err, sizeof(err)));
ASSERT_TRUE(strstr(err, "--approve-sensitive") != NULL);
th_cleanup(cache);
#endif
PASS();
}
/* ── Environment helpers ────────────────────────────────────────────────── */
static char *ws_env_save(const char *name) {
@@ -835,6 +929,7 @@ SUITE(workspace) {
RUN_TEST(ws_posix_matching_is_case_sensitive);
RUN_TEST(ws_null_context_disables_dependent_checks);
RUN_TEST(ws_every_verdict_has_a_reason);
RUN_TEST(ws_approve_sensitive_matches_across_separator_styles);
RUN_TEST(ws_home_dir_is_resolved);
RUN_TEST(ws_linked_home_classified_as_home);
RUN_TEST(ws_manifest_entry_classified_in_resolved_form);
+120
View File
@@ -520,6 +520,125 @@ def section_churn_stability(binary, work):
kill_pid(daemon_pid)
def section_local_cleanup_waits_for_startup_gate(binary, work):
"""A live startup owner must not turn completed local work into failure."""
if os.name != "nt":
print("SKIP: local teardown reacquires the startup gate only on Windows")
return True
import msvcrt
cache = section_dirs(work, "cleanup-contention")
env = cli_env(cache)
env["CBM_LOG_LEVEL"] = "info"
env["CBM_LOG_FORMAT"] = "text"
warm = subprocess.run([binary, "cli", "list_projects"], capture_output=True,
timeout=90, env=env)
if warm.returncode != 0:
raise SetupFailure("could not initialize the private runtime:\n" + out_text(warm))
gates = []
for parent, _, files in os.walk(runtime_for(cache)):
if "cbm-startup-v2.lock" in files:
gates.append(os.path.join(parent, "cbm-startup-v2.lock"))
if len(gates) != 1:
raise SetupFailure("expected one private startup gate, got %r" % gates)
repo = os.path.join(work, "cleanup-repo")
os.makedirs(repo)
for index in range(200):
with open(os.path.join(repo, "unit_%d.py" % index), "w") as handle:
handle.write("".join("def function_%d_%d():\n return %d\n"
% (index, item, item) for item in range(100)))
err_path = os.path.join(work, "cleanup-contention.stderr")
process = None
locked = False
traces = {}
def read_trace():
# The daemon supervises indexing in a worker. Worker progress goes to
# its own durable log; the outer CLI only reports the final response.
paths = [err_path]
log_dir = os.path.join(cache, "logs")
if os.path.isdir(log_dir):
paths.extend(os.path.join(log_dir, name) for name in os.listdir(log_dir)
if name.startswith(".worker-log-"))
for path in paths:
try:
with open(path, "rb") as handle:
traces[path] = handle.read()
except FileNotFoundError:
pass # The supervisor removes a successful worker's log.
return b"\n".join(traces.values())
with open(gates[0], "r+b") as gate, open(err_path, "wb") as errors:
try:
process = subprocess.Popen(
[binary, "cli", "index_repository", "--progress"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=errors, env=env)
process.stdin.write(json.dumps({"repo_path": repo, "mode": "fast"}).encode())
process.stdin.close()
process.stdin = None
deadline = time.monotonic() + 120
# Extraction is after begin_work released startup-v2. Observe that
# event before acquiring the real one-byte native startup lock.
while time.monotonic() < deadline:
trace = read_trace()
if b"msg=parallel.extract" in trace or b"msg=pass.definitions" in trace:
gate.seek(0)
try:
msvcrt.locking(gate.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
time.sleep(0.01)
continue
locked = True
break
if process.poll() is not None:
raise SetupFailure("index exited before the gate collision:\n" +
excerpt(trace))
time.sleep(0.01)
if not locked:
raise SetupFailure("index never entered local work:\n" + excerpt(trace))
while time.monotonic() < deadline:
trace = read_trace()
if b"Completed index_repository (" in trace:
break
if process.poll() is not None:
print("RED: index exited while a live peer held startup-v2:\n" +
excerpt(trace))
return False
time.sleep(0.01)
else:
raise SetupFailure("CLI did not finish its work:\n" + excerpt(trace))
# The outer CLI flushes this completion message after receiving
# the worker result and closing its daemon session, immediately
# before local cleanup. pipeline.done is too early: worker result
# handling can outlast a short lock hold. Keep the gate for longer
# than the old 500 ms cleanup deadline after this real boundary.
time.sleep(1)
stayed_alive = process.poll() is None
gate.seek(0)
msvcrt.locking(gate.fileno(), msvcrt.LK_UNLCK, 1)
locked = False
stdout, _ = process.communicate(timeout=30)
trace = read_trace()
if not stayed_alive or process.returncode != 0 or b"cleanup_timeout" in trace:
print("RED: completed work must wait out startup contention and exit 0 "
"after release (rc=%s):\n%s" %
(process.returncode, excerpt(stdout + trace)))
return False
print("PASS: completed local work waited for the live startup owner and exited 0")
return True
finally:
if locked:
gate.seek(0)
msvcrt.locking(gate.fileno(), msvcrt.LK_UNLCK, 1)
if process is not None and process.poll() is None:
subprocess.run(["taskkill", "/F", "/T", "/PID", str(process.pid)],
capture_output=True, timeout=30)
process.wait(timeout=30)
status = run_cli(binary, cache, ["daemon", "status"], timeout=30)
kill_pid(pid_from(out_text(status)))
def section_cold_storm(binary, work):
cache = section_dirs(work, "storm")
results = _parallel_one_shots(binary, cache, 6)
@@ -558,6 +677,7 @@ def main():
section_stop_refuses_busy,
section_crash_recovery,
section_churn_stability,
section_local_cleanup_waits_for_startup_gate,
section_cold_storm,
]
failed = []