mirror of
https://github.com/systemd/systemd.git
synced 2026-11-06 05:17:24 +00:00
ci: give the Claude review agents semcode (#44651)
The Claude review agents navigate the systemd and kernel sources with grep and by reading whole files. semcode (https://github.com/masoncl/semcode-devel) indexes C code with tree-sitter and answers where a function is defined, who calls it and what a type looks like from the index, in one call, through an MCP server. This gives the agents semcode for the checkout and for the kernel tree, and makes them use it. - A new semcode workflow builds semcode at a pinned commit of semcode-devel and indexes mainline once a week, both into the actions cache. A cold build takes about twenty minutes and the kernel index is about a gigabyte, neither fits into a review. systemd uses no actions cache so far, so the whole quota is available. - The review job restores both, indexes the pull request (the head plus the range from the merge base, so lookups work before and after the change and per commit; about a minute on a runner) and gives every lens a systemd and a linux server. When there is a kernel index, the kernel is fetched at the indexed commit instead of the latest mainline. - Everything semcode runs in CI runs under bubblewrap: the indexers with only the index writable, the servers read-only and without network, and no server starts if bwrap doesn't work. semcode-mcp runs outside the Claude Code sandbox, so this gives it one. - A PreToolUse hook denies recursive grep over src/ and over the kernel tree and names the semcode tools instead. Single files, the rest of the checkout, Documentation/, the other trees and "define" lookups stay grep-able, since object-like macros aren't indexed. - Each lens reports how many semcode calls succeeded and how many grep commands it ran, so we can see whether the agents actually use it. Nothing changes for reviews until the semcode workflow has run on main once and filled the cache. Until then the review runs as before.
This commit is contained in:
2 files changed
+495
-17
No files matched your search
@@ -289,12 +289,61 @@ jobs:
|
||||
git fetch origin "$PREVIOUS_HEAD" || echo "Previous head ${PREVIOUS_HEAD} is not fetchable, reviewing the full diff."
|
||||
fi
|
||||
|
||||
# Claude Code sandboxes the agents with bubblewrap. So does the review for
|
||||
# everything semcode runs on the pull request, hence this comes first.
|
||||
- name: Install sandbox dependencies
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
sudo apt-get update && sudo apt-get install -y bubblewrap socat
|
||||
|
||||
# The semcode workflow builds semcode and indexes the kernel with it, both
|
||||
# into the actions cache, under a key that ends in the commit of each. The
|
||||
# newest entry for the prefix is restored. The path is part of the cache
|
||||
# version, so it is the one semcode.yml saves. A review without semcode is
|
||||
# still a review, so a miss only logs.
|
||||
- name: Restore semcode
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ runner.temp }}/semcode
|
||||
key: semcode-bin-${{ runner.os }}-${{ runner.arch }}-
|
||||
restore-keys: semcode-bin-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
- name: Check semcode
|
||||
id: semcode
|
||||
run: |
|
||||
semcode="$RUNNER_TEMP/semcode"
|
||||
ref="$(cat "$semcode/ref" 2>/dev/null || :)"
|
||||
if [[ ! -x "$semcode/bin/semcode-index" || ! -x "$semcode/bin/semcode-mcp" || ! "$ref" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "No usable semcode in the cache, reviewing without it."
|
||||
rm -rf "$semcode"
|
||||
exit 0
|
||||
fi
|
||||
echo "Using semcode ${ref}."
|
||||
echo "ref=${ref}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
# An index is only readable by the semcode that wrote it, so the kernel
|
||||
# index is looked up under the commit of the restored binaries.
|
||||
- name: Restore the kernel index
|
||||
if: steps.semcode.outputs.ref != ''
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ runner.temp }}/semcode-linux
|
||||
key: semcode-linux-${{ runner.os }}-${{ runner.arch }}-${{ steps.semcode.outputs.ref }}-
|
||||
restore-keys: semcode-linux-${{ runner.os }}-${{ runner.arch }}-${{ steps.semcode.outputs.ref }}-
|
||||
|
||||
# The model's knowledge of the kernel, glibc, musl, and UAPI specifications can
|
||||
# be wrong or out of date. The review agents therefore check details in the
|
||||
# cloned repositories. A review without a repository is still useful, so a
|
||||
# failed clone does not fail the job. git clones into a temporary directory
|
||||
# first. If the timeout kills git, the final directory does not exist, and
|
||||
# the agents are not told about it.
|
||||
#
|
||||
# The kernel index only answers for the commit it was built from, so with an
|
||||
# index in the cache the kernel is fetched at that commit, which mainline
|
||||
# keeps, instead of the latest. The fetch has depth two: at startup
|
||||
# semcode-mcp compares HEAD with its parent to see that the index covers
|
||||
# HEAD, and without the parent it checks every file instead. When the fetch
|
||||
# fails, the latest mainline is cloned and the index stays unused.
|
||||
- name: Clone the Linux kernel, glibc, musl, and UAPI specifications
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
@@ -306,8 +355,24 @@ jobs:
|
||||
mv "$dir.tmp" "$dir" &&
|
||||
git -C "$dir" log -1 --format="Cloned $name at %H (%s)"
|
||||
}
|
||||
clone_linux() {
|
||||
local url=https://github.com/torvalds/linux.git dir="$RUNNER_TEMP/claude-review/linux" commit
|
||||
commit="$(cat "$RUNNER_TEMP/semcode-linux/commit" 2>/dev/null || :)"
|
||||
if [[ "$commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
if git init --quiet "$dir.tmp" &&
|
||||
git -C "$dir.tmp" fetch --quiet --depth 2 "$url" "$commit" &&
|
||||
git -C "$dir.tmp" checkout --quiet --detach FETCH_HEAD; then
|
||||
mv "$dir.tmp" "$dir" &&
|
||||
git -C "$dir" log -1 --format="Cloned linux at %H (%s), the commit of the semcode index"
|
||||
return
|
||||
fi
|
||||
rm -rf "$dir.tmp"
|
||||
echo "Fetching the indexed kernel commit ${commit} failed, cloning the latest mainline instead."
|
||||
fi
|
||||
clone linux "$url" --depth 1 --single-branch
|
||||
}
|
||||
pids=()
|
||||
clone linux https://github.com/torvalds/linux.git --depth 1 --single-branch &
|
||||
clone_linux &
|
||||
pids+=($!)
|
||||
clone glibc https://sourceware.org/git/glibc.git --depth 1 --single-branch &
|
||||
pids+=($!)
|
||||
@@ -357,10 +422,39 @@ jobs:
|
||||
}' "$INPUT/pr-context.json" >"$INPUT/discussion.json"
|
||||
rm "$INPUT/pr-context.json"
|
||||
|
||||
- name: Install sandbox dependencies
|
||||
# The first run indexes the files of the head. The second adds the files
|
||||
# each commit of the pull request changed, at every commit in the range and
|
||||
# at the merge base, and the commits themselves. Lookups at the head, at
|
||||
# the merge base and at any commit in between then answer from the index.
|
||||
# semcode-index parses the pull request, so it runs in a sandbox like the
|
||||
# agents do: every file read-only but the index, nothing under /run, no
|
||||
# network, none of the job's environment. A read-only bind does not stop
|
||||
# a connect, and the docker socket lives in /run. The index moves into
|
||||
# place once both runs are done, so a run the timeout kills leaves nothing
|
||||
# behind to serve.
|
||||
- name: Index the pull request with semcode
|
||||
if: steps.semcode.outputs.ref != ''
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
MERGE_BASE: ${{ steps.inputs.outputs.merge_base }}
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
sudo apt-get update && sudo apt-get install -y bubblewrap socat
|
||||
index="$RUNNER_TEMP/claude-review/semcode-systemd"
|
||||
rm -rf "$index.tmp"
|
||||
mkdir -p "$index.tmp"
|
||||
semcode_index() {
|
||||
bwrap --ro-bind / / --tmpfs /run --dev /dev --proc /proc --tmpfs /tmp --bind "$index.tmp" "$index.tmp" \
|
||||
--clearenv --setenv PATH /usr/bin:/bin --setenv HOME "$HOME" \
|
||||
--unshare-all --die-with-parent --new-session -- \
|
||||
"$RUNNER_TEMP/semcode/bin/semcode-index" \
|
||||
--source "$GITHUB_WORKSPACE/source" --database "$index.tmp/.semcode.db" "$@"
|
||||
}
|
||||
if ! semcode_index || ! semcode_index --git "${MERGE_BASE}..${HEAD_SHA}"; then
|
||||
rm -rf "$index.tmp"
|
||||
exit 1
|
||||
fi
|
||||
mv "$index.tmp" "$index"
|
||||
du -sh "$index"
|
||||
|
||||
- name: Install Claude Code
|
||||
run: |
|
||||
@@ -402,12 +496,12 @@ jobs:
|
||||
cat > ~/.claude/settings.json << 'SETTINGS'
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash", "Read", "Grep", "Glob"]
|
||||
"allow": ["Bash", "Read", "Grep", "Glob", "mcp__systemd__*", "mcp__linux__*"]
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash|Read|Grep|Glob",
|
||||
"matcher": "Bash|Read|Grep|Glob|mcp__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
@@ -472,6 +566,7 @@ jobs:
|
||||
--max-turns 100 \
|
||||
--setting-sources user \
|
||||
--tools Bash,Read,Grep,Glob \
|
||||
"${mcp_args[@]}" \
|
||||
--output-format stream-json \
|
||||
--verbose \
|
||||
--json-schema "$schema" \
|
||||
@@ -483,21 +578,31 @@ jobs:
|
||||
# got at least one tool result other than its own structured output.
|
||||
# When the sandbox is broken, every Bash call fails while Read and Grep
|
||||
# still work. If the agent ran Bash, also require that one Bash call
|
||||
# succeeded.
|
||||
local worked bash_calls bash_worked
|
||||
read -r worked bash_calls bash_worked < <(jq -Rnr '
|
||||
# succeeded. The same goes for the semcode servers. A call a hook denied
|
||||
# or one that ran into the time limit is an error too, but says nothing
|
||||
# about either, so both are left out. The grep calls are counted to see
|
||||
# how much searching still bypasses semcode.
|
||||
local worked bash_calls bash_worked semcode_calls semcode_worked greps denied timeouts
|
||||
read -r worked bash_calls bash_worked semcode_calls semcode_worked greps denied timeouts < <(jq -Rnr '
|
||||
def text: (.content // "") | if type == "string" then . else map(.text? // "") | join("") end;
|
||||
[inputs | fromjson?] as $events
|
||||
| [$events[] | select(.type == "assistant") | .message.content[]?
|
||||
| select(.type == "tool_use")] as $calls
|
||||
| [$calls[] | select(.name == "StructuredOutput") | .id] as $ignore
|
||||
| [$calls[] | select(.name == "Bash") | .id] as $bash
|
||||
| [$events[] | select(.type == "user") | .message.content[]?
|
||||
| select(.type == "tool_result" and .is_error != true) | .tool_use_id] as $ok
|
||||
| "\($ok - $ignore | length) \($bash | length) \($ok - ($ok - $bash) | length)"' <"$OUTPUT/$name.log")
|
||||
if [[ -s "$OUTPUT/$name.json" ]] && (( worked > 0 )) && (( bash_calls == 0 || bash_worked > 0 )); then
|
||||
echo "${name}: done (status ${status})"
|
||||
| select(.type == "tool_result")] as $results
|
||||
| [$results[] | select(text | startswith("PreToolUse:")) | .tool_use_id] as $denied
|
||||
| [$results[] | select(text | test("^MCP server .* timed out after")) | .tool_use_id] as $timeouts
|
||||
| [$calls[] | select(.name == "StructuredOutput") | .id] as $ignore
|
||||
| ([$calls[] | select(.name == "Bash") | .id] - $denied) as $bash
|
||||
| ([$calls[] | select(.name | startswith("mcp__")) | .id] - $denied - $timeouts) as $semcode
|
||||
| [$calls[] | select(.name == "Grep" or (.name == "Bash" and (.input.command // ""
|
||||
| test("(^|[^[:alnum:]_./-])(grep|egrep|fgrep|rg|ag|ack|git\\s+(-C\\s+\\S+\\s+)?grep)(\\s|$)"))))] as $greps
|
||||
| [$results[] | select(.is_error != true) | .tool_use_id] as $ok
|
||||
| "\($ok - $ignore | length) \($bash | length) \($ok - ($ok - $bash) | length) \($semcode | length) \($ok - ($ok - $semcode) | length) \($greps | length) \($denied | length) \($timeouts | length)"' <"$OUTPUT/$name.log")
|
||||
if [[ -s "$OUTPUT/$name.json" ]] && (( worked > 0 )) && (( bash_calls == 0 || bash_worked > 0 )) && (( semcode_calls == 0 || semcode_worked > 0 )); then
|
||||
echo "${name}: done (status ${status}, ${semcode_worked} of ${semcode_calls} semcode call(s) succeeded, ${timeouts} timed out, ${greps} grep call(s), ${denied} call(s) denied by hooks)"
|
||||
else
|
||||
echo "${name}: no result (status ${status}, ${worked} tool call(s) succeeded, ${bash_worked} of ${bash_calls} Bash call(s) succeeded)"
|
||||
echo "${name}: no result (status ${status}, ${worked} tool call(s) succeeded, ${bash_worked} of ${bash_calls} Bash call(s) succeeded, ${semcode_worked} of ${semcode_calls} semcode call(s) succeeded)"
|
||||
rm -f "$OUTPUT/$name.json"
|
||||
fi
|
||||
}
|
||||
@@ -522,7 +627,7 @@ jobs:
|
||||
if [[ -d "$linux" ]]; then
|
||||
context+="
|
||||
|
||||
A shallow clone of the latest mainline Linux kernel is at ${linux}. It has no history. When a
|
||||
A shallow clone of a recent mainline Linux kernel is at ${linux}. It has no history. When a
|
||||
finding depends on kernel behavior, such as system call semantics, flags, error codes, or
|
||||
ioctl and netlink interfaces, check the kernel source there instead of relying on memory."
|
||||
fi
|
||||
@@ -557,6 +662,244 @@ jobs:
|
||||
the specification in ${uapi}/specs instead of relying on memory."
|
||||
fi
|
||||
|
||||
# semcode answers lookups by name and call graph from the indexes built
|
||||
# above, with one server per indexed tree. semcode-mcp runs outside the
|
||||
# Claude Code sandbox, so it gets one of its own, or does not run at all:
|
||||
# every file read-only, nothing under /run (a read-only bind does not
|
||||
# stop a connect, and the docker socket lives there), no network, none
|
||||
# of the job's environment, gone with the agent. Each index is complete
|
||||
# for the commit its tree is checked out at, so the indexing pass
|
||||
# semcode-mcp runs at startup has nothing to write. The server files
|
||||
# live in $RUNNER_TEMP, which the sandbox denies writes to, so a
|
||||
# sandboxed shell cannot replace them. A server that does not answer a
|
||||
# probe is not offered, so the agents are never pointed at tools that
|
||||
# do not exist.
|
||||
#
|
||||
# A call that runs longer than the timeout fails and the agent carries
|
||||
# on. Without one, Claude Code waits half an hour for a stdio server,
|
||||
# longer than the agent has. The server is not told, so it finishes the
|
||||
# call anyway; it answers requests concurrently, so the next call does
|
||||
# not queue behind it.
|
||||
mcp_args=()
|
||||
servers='{}'
|
||||
sandbox=(bwrap --ro-bind / / --tmpfs /run --dev /dev --proc /proc --tmpfs /tmp --clearenv --setenv PATH /usr/bin:/bin --setenv HOME "$HOME" --unshare-all --die-with-parent --new-session)
|
||||
if ! "${sandbox[@]}" -- /bin/true; then
|
||||
echo "bubblewrap does not work, reviewing without semcode."
|
||||
rm -rf "$RUNNER_TEMP/claude-review/semcode-systemd" "$RUNNER_TEMP/semcode-linux"
|
||||
fi
|
||||
add_server() {
|
||||
local name=$1 db=$2 repo=$3 wrapper="$RUNNER_TEMP/claude-review/semcode-mcp-$1"
|
||||
cat >"$wrapper" <<EOF
|
||||
#!/bin/bash
|
||||
exec ${sandbox[*]} -- \\
|
||||
"$RUNNER_TEMP/semcode/bin/semcode-mcp" --database "$db" --git-repo "$repo"
|
||||
EOF
|
||||
chmod +x "$wrapper"
|
||||
# The pipe stays open long enough for the answers. A server whose
|
||||
# index is unusable lists its tools all the same but answers a lookup
|
||||
# with an error text instead of a search.
|
||||
local answer
|
||||
answer="$({ printf '%s\n' \
|
||||
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
|
||||
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||||
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"grep_functions","arguments":{"pattern":"probe_no_such_symbol_","limit":1}}}'
|
||||
sleep 3; } | timeout 30 "$wrapper" 2>/dev/null)" || answer=""
|
||||
if ! grep -c '"find_function"' <<<"$answer" >/dev/null || ! grep -c 'Searching function bodies' <<<"$answer" >/dev/null; then
|
||||
echo "The semcode server for $name does not answer, reviewing without it."
|
||||
return 1
|
||||
fi
|
||||
servers="$(jq -c --arg name "$name" --arg command "$wrapper" '. + {($name): {command: $command, timeout: 120000}}' <<<"$servers")"
|
||||
}
|
||||
|
||||
systemd_index="$RUNNER_TEMP/claude-review/semcode-systemd"
|
||||
systemd_indexed=""
|
||||
if [[ -d "$systemd_index/.semcode.db" ]] && add_server systemd "$systemd_index/.semcode.db" "$SOURCE"; then
|
||||
systemd_indexed="$SOURCE"
|
||||
context+="
|
||||
|
||||
The checkout is indexed with semcode; its tools are the mcp__systemd__* tools. Use
|
||||
find_function, find_type, find_callers, find_calls, find_callchain and grep_functions to read
|
||||
and search the C code of the checkout instead of grep: one call answers what grep and reading
|
||||
files answer in many. Lookups default to the head; pass git_sha=${MERGE_BASE} for the code
|
||||
before the pull request, and find_commit with git_range=${MERGE_BASE}..${HEAD_SHA} for the
|
||||
commits under review. Object-like macros, global variables and files that are not C are not
|
||||
indexed; read those files directly."
|
||||
fi
|
||||
|
||||
linux_index="$RUNNER_TEMP/semcode-linux"
|
||||
linux_indexed=""
|
||||
if [[ -d "$linux_index/.semcode.db" && -d "$linux" ]] &&
|
||||
[[ "$(cat "$linux_index/commit" 2>/dev/null)" =~ ^[0-9a-f]{40}$ ]] &&
|
||||
[[ "$(git -C "$linux" rev-parse HEAD)" == "${BASH_REMATCH[0]}" ]] &&
|
||||
add_server linux "$linux_index/.semcode.db" "$linux"; then
|
||||
linux_indexed="$linux"
|
||||
context+="
|
||||
|
||||
The kernel tree is indexed with semcode; its tools are the mcp__linux__* tools. Use
|
||||
find_function, find_type, find_callers, find_calls, find_callchain and grep_functions to read
|
||||
and search kernel code instead of grep."
|
||||
fi
|
||||
|
||||
if [[ "$servers" != "{}" ]]; then
|
||||
jq -n --argjson servers "$servers" '{mcpServers: $servers}' >"$RUNNER_TEMP/claude-review/mcp.json"
|
||||
mcp_args=(--mcp-config "$RUNNER_TEMP/claude-review/mcp.json" --strict-mcp-config)
|
||||
|
||||
# A recursive search of indexed C code is what the semcode tools are
|
||||
# for, so the hook below denies it and names them: the checkout and
|
||||
# its src/, and the kernel tree outside Documentation/, when their
|
||||
# index is served. Single files, the rest of the checkout, searches
|
||||
# restricted to other file types and object-like macros, which are
|
||||
# not indexed, stay searchable.
|
||||
guard="$RUNNER_TEMP/claude-review/semcode-guard"
|
||||
cat >"$guard" <<'GUARD'
|
||||
#!/bin/bash
|
||||
# PreToolUse hook: the tool call arrives as JSON on stdin, exit status 2
|
||||
# denies it and shows stderr to the agent. $1 is the checkout and $2 the
|
||||
# kernel tree, each empty when its index is not served.
|
||||
set -uf
|
||||
source=$1 linux=$2
|
||||
input="$(cat)"
|
||||
tool="$(jq -r '.tool_name' <<<"$input")"
|
||||
|
||||
# A directory of indexed C code: the checkout as a whole or below src/,
|
||||
# the kernel tree outside Documentation/.
|
||||
indexed() {
|
||||
local path
|
||||
[[ -d "$1" ]] || return 1
|
||||
path="$(realpath -m -- "$1")"
|
||||
[[ -n "$source" && ("$path" == "$source" || "$path" == "$source/src" || "$path" == "$source/src/"*) ]] && return 0
|
||||
[[ -n "$linux" && ("$path" == "$linux" || "$path" == "$linux/"*) &&
|
||||
"$path" != "$linux/Documentation" && "$path" != "$linux/Documentation/"* ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# A file pattern that selects no indexed C: a suffix other than .c, .h,
|
||||
# .rs and .zig, or a build file.
|
||||
non_c() {
|
||||
local name=${1##*/}
|
||||
[[ "$name" == *\{* ]] && return 1
|
||||
[[ "$name" =~ \.(c|h|rs|zig)$ ]] && return 1
|
||||
[[ "$name" =~ \.[[:alnum:]_]+$ || "$name" =~ ^(Makefile|Kconfig|Kbuild)[*]?$ ]]
|
||||
}
|
||||
|
||||
deny() {
|
||||
echo "Recursive searches of indexed C code are denied: the semcode tools answer them in one call. The checkout and its src/ are served by the mcp__systemd__* tools, the kernel tree outside Documentation/ by the mcp__linux__* tools: find_function and find_type for a definition, find_callers, find_calls and find_callchain for the call graph, grep_functions with path_pattern for a regex over function bodies. grep stays available for single files, for the parts of the checkout outside src/, for searches restricted to other file types with --include, --glob or the glob and type parameters, and for patterns containing define, as object-like macros are not indexed." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
case "$tool" in
|
||||
Grep)
|
||||
path="$(jq -r '.tool_input.path // ""' <<<"$input")"
|
||||
glob="$(jq -r '.tool_input.glob // ""' <<<"$input")"
|
||||
type="$(jq -r '.tool_input.type // ""' <<<"$input")"
|
||||
[[ -n "$glob" ]] && non_c "$glob" && exit 0
|
||||
[[ -n "$type" && ! "$type" =~ ^(c|cpp|rust|zig)$ ]] && exit 0
|
||||
[[ -n "$path" ]] && indexed "$path" && deny
|
||||
;;
|
||||
Bash)
|
||||
command="$(jq -r '.tool_input.command // ""' <<<"$input")"
|
||||
[[ "${command,,}" == *define* ]] && exit 0
|
||||
grep_re='(^|[^[:alnum:]_./-])(grep|egrep|fgrep|rg|ag|ack)([[:space:]]|$)'
|
||||
gitgrep_re='(^|[^[:alnum:]_./-])git[[:space:]]+(-C[[:space:]]+([^[:space:]]+)[[:space:]]+)?grep([[:space:]]|$)'
|
||||
# One segment per simple command, after joining continued lines and
|
||||
# blanking the separators inside quotes. A segment after a pipe reads
|
||||
# it instead of files. Only the operands of a segment that searches
|
||||
# count, resolved against the directory of a cd before it or of git -C.
|
||||
masked="" rest=${command//\\$'\n'/ }
|
||||
while [[ "$rest" =~ ^([^\'\"]*)(\'[^\']*\'|\"[^\"]*\")(.*)$ ]]; do
|
||||
quoted=${BASH_REMATCH[2]}
|
||||
masked+="${BASH_REMATCH[1]}${quoted//[|&;$'\n']/ }"
|
||||
rest=${BASH_REMATCH[3]}
|
||||
done
|
||||
masked+=$rest
|
||||
segments=${masked//$'\n'/;}
|
||||
segments=${segments//'||'/;}
|
||||
segments=${segments//'&&'/;}
|
||||
segments=${segments//'|'/'; __pipe__ '}
|
||||
segments=${segments//'&'/;}
|
||||
cwd=""
|
||||
IFS=';' read -ra parts <<<"$segments"
|
||||
for segment in "${parts[@]}"; do
|
||||
# shellcheck disable=SC2206
|
||||
words=($segment)
|
||||
piped=0
|
||||
if [[ "${words[0]-}" == __pipe__ ]]; then
|
||||
piped=1
|
||||
words=("${words[@]:1}")
|
||||
fi
|
||||
(( ${#words[@]} )) || continue
|
||||
if [[ "${words[0]}" == cd ]]; then
|
||||
target=${words[1]-}
|
||||
target=${target//[\"\']/}
|
||||
[[ "$target" == /* || -z "$cwd" ]] || target="$cwd/$target"
|
||||
cwd=$target
|
||||
continue
|
||||
fi
|
||||
base=$cwd
|
||||
rest=("${words[@]:1}")
|
||||
recursive=1
|
||||
if [[ "$segment" =~ $gitgrep_re ]]; then
|
||||
[[ -n "${BASH_REMATCH[3]}" ]] && base=${BASH_REMATCH[3]//[\"\']/}
|
||||
[[ "$base" == /* || -z "$cwd" || -z "$base" ]] || base="$cwd/$base"
|
||||
while (( ${#rest[@]} )) && [[ "${rest[0]}" != grep ]]; do rest=("${rest[@]:1}"); done
|
||||
rest=("${rest[@]:1}")
|
||||
elif [[ "$segment" =~ $grep_re ]]; then
|
||||
# grep descends into directories only when told to, the others
|
||||
# always do.
|
||||
[[ "${BASH_REMATCH[2]}" == *grep ]] && recursive=0
|
||||
else
|
||||
continue
|
||||
fi
|
||||
# The first word that is not an option is the pattern unless -e or -f
|
||||
# gave it, the rest are the files. A search restricted to other file
|
||||
# types is not one semcode answers.
|
||||
operands=() expect="" pattern=0 restricted=0 selects_c=0
|
||||
for word in "${rest[@]}"; do
|
||||
word=${word//[\"\']/}
|
||||
case "$expect:$word" in
|
||||
filepattern:*) restricted=1; non_c "$word" || selects_c=1; expect="" ;;
|
||||
filetype:*) restricted=1; [[ "$word" =~ ^(c|cpp|rust|zig)$ ]] && selects_c=1; expect="" ;;
|
||||
regexp:*|patternfile:*) pattern=1; expect="" ;;
|
||||
:--include=*|:--glob=*) restricted=1; non_c "${word#*=}" || selects_c=1 ;;
|
||||
:-g?*) restricted=1; non_c "${word#-g}" || selects_c=1 ;;
|
||||
:--type=*) restricted=1; [[ "${word#*=}" =~ ^(c|cpp|rust|zig)$ ]] && selects_c=1 ;;
|
||||
:-t?*) restricted=1; [[ "${word#-t}" =~ ^(c|cpp|rust|zig)$ ]] && selects_c=1 ;;
|
||||
:--include|:--glob|:-g) expect=filepattern ;;
|
||||
:-t|:--type) expect=filetype ;;
|
||||
:-e|:--regexp) expect=regexp ;;
|
||||
:-f|:--file) expect=patternfile ;;
|
||||
:--regexp=*|:--file=*|:-e?*|:-f?*) pattern=1 ;;
|
||||
:--recursive|:--dereference-recursive) recursive=1 ;;
|
||||
:--*) ;;
|
||||
:-*) [[ "$word" == *[rR]* ]] && recursive=1 ;;
|
||||
*) if (( pattern )); then operands+=("$word"); else pattern=1; fi ;;
|
||||
esac
|
||||
done
|
||||
(( restricted && ! selects_c )) && continue
|
||||
if (( ! ${#operands[@]} )); then
|
||||
# Without files a recursive search covers its directory, any other
|
||||
# reads its input.
|
||||
(( recursive && ! piped )) || continue
|
||||
operands=("${base:-.}")
|
||||
fi
|
||||
for word in "${operands[@]}"; do
|
||||
word=${word%%\**}
|
||||
[[ "$word" == /* || -z "$base" ]] || word="$base/$word"
|
||||
indexed "$word" && deny
|
||||
done
|
||||
done
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
GUARD
|
||||
chmod +x "$guard"
|
||||
jq --arg command "$guard \"$systemd_indexed\" \"$linux_indexed\"" \
|
||||
'.hooks.PreToolUse += [{matcher: "Bash|Grep", hooks: [{type: "command", command: $command}]}]' \
|
||||
~/.claude/settings.json >~/.claude/settings.json.new
|
||||
mv ~/.claude/settings.json.new ~/.claude/settings.json
|
||||
fi
|
||||
|
||||
previous=""
|
||||
if [[ -n "$PREVIOUS_HEAD" ]] && git -C "$SOURCE" cat-file -e "$PREVIOUS_HEAD" 2>/dev/null; then
|
||||
git -C "$SOURCE" diff "$PREVIOUS_HEAD" "$HEAD_SHA" >"$INPUT/incremental.patch"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
# Builds semcode for the Claude review and indexes the Linux kernel with it.
|
||||
#
|
||||
# The review agents look up functions, types and call graphs with semcode
|
||||
# instead of grepping through the trees. A cold build of semcode takes about
|
||||
# twenty minutes and a kernel index longer than that, far more than a review
|
||||
# may spend on them. Both go into the actions cache, from which the review job
|
||||
# restores them:
|
||||
#
|
||||
# semcode-bin-<os>-<arch>-<semcode commit> the binaries
|
||||
# semcode-linux-<os>-<arch>-<semcode commit>-<kernel commit> the kernel index
|
||||
#
|
||||
# The review restores the newest entry for the prefix it knows. The kernel
|
||||
# index only answers for the commit it was built from, which the review
|
||||
# therefore clones instead of the latest mainline. An index is tied to the
|
||||
# semcode that wrote it, so a new SEMCODE_REF rebuilds both. A cache nobody
|
||||
# restored for a week is evicted, and the kernel moves on, so this also runs
|
||||
# weekly.
|
||||
|
||||
name: semcode
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [.github/workflows/semcode.yml]
|
||||
schedule:
|
||||
# Mondays 03:00 UTC, before the European morning's reviews.
|
||||
- cron: '0 3 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: semcode
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# masoncl/semcode-devel main plus its pull request 10, which lets semcode-mcp
|
||||
# answer requests concurrently, from the fork the pull request comes from.
|
||||
SEMCODE_REPOSITORY: https://github.com/brauner/semcode-devel.git
|
||||
SEMCODE_REF: 08a0feefd4f97fce906420ee25961c44727efac8
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# The review only runs in these two, so nobody else needs the caches.
|
||||
if: contains(fromJSON('["systemd/systemd","daandemeyer/systemd"]'), github.repository)
|
||||
|
||||
outputs:
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
|
||||
steps:
|
||||
- name: Compute the cache key
|
||||
id: key
|
||||
run: echo "key=semcode-bin-${RUNNER_OS}-${RUNNER_ARCH}-${SEMCODE_REF}" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore semcode
|
||||
id: cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ runner.temp }}/semcode
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
|
||||
# protoc and the .proto files it reads: lance imports one of protobuf's
|
||||
# well-known types, which ships in libprotobuf-dev and not with the
|
||||
# compiler. gix reaches the network through curl built against openssl.
|
||||
- name: Install build dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
protobuf-compiler libprotobuf-dev pkg-config libssl-dev libcurl4-openssl-dev
|
||||
rustup update --no-self-update stable
|
||||
|
||||
- name: Build semcode
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
src="$RUNNER_TEMP/semcode-src"
|
||||
git init --quiet "$src"
|
||||
git -C "$src" fetch --quiet --depth 1 "$SEMCODE_REPOSITORY" "$SEMCODE_REF"
|
||||
git -C "$src" checkout --quiet --detach FETCH_HEAD
|
||||
cargo build --manifest-path "$src/Cargo.toml" --release --locked --bin semcode-index --bin semcode-mcp
|
||||
install -D -s -t "$RUNNER_TEMP/semcode/bin" "$src/target/release/semcode-index" "$src/target/release/semcode-mcp"
|
||||
echo "$SEMCODE_REF" >"$RUNNER_TEMP/semcode/ref"
|
||||
ls -l "$RUNNER_TEMP/semcode/bin"
|
||||
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Restore semcode
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ runner.temp }}/semcode
|
||||
key: ${{ needs.build.outputs.key }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
# The review runs semcode in a bubblewrap sandbox and so does this, for
|
||||
# one less difference between the two. Ubuntu restricts the unprivileged
|
||||
# user namespaces it needs.
|
||||
- name: Install the sandbox
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
sudo apt-get update && sudo apt-get install -y bubblewrap
|
||||
|
||||
- name: Clone the Linux kernel
|
||||
id: linux
|
||||
run: |
|
||||
git clone --quiet --depth 1 --single-branch https://github.com/torvalds/linux.git "$RUNNER_TEMP/linux"
|
||||
git -C "$RUNNER_TEMP/linux" log -1 --format="Cloned linux at %H (%s)"
|
||||
echo "commit=$(git -C "$RUNNER_TEMP/linux" rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
|
||||
# Two analysis threads keep the indexer below nine gigabytes, which a
|
||||
# runner has; with four it peaks above eleven.
|
||||
- name: Index the Linux kernel
|
||||
env:
|
||||
LINUX_COMMIT: ${{ steps.linux.outputs.commit }}
|
||||
run: |
|
||||
index="$RUNNER_TEMP/semcode-linux"
|
||||
mkdir -p "$index"
|
||||
bwrap --ro-bind / / --tmpfs /run --dev /dev --proc /proc --tmpfs /tmp --bind "$index" "$index" \
|
||||
--clearenv --setenv PATH /usr/bin:/bin --setenv HOME "$HOME" \
|
||||
--unshare-all --die-with-parent --new-session -- \
|
||||
"$RUNNER_TEMP/semcode/bin/semcode-index" --jobs 2 --source "$RUNNER_TEMP/linux" --database "$index/.semcode.db"
|
||||
echo "$LINUX_COMMIT" >"$index/commit"
|
||||
du -sh "$index"
|
||||
|
||||
- name: Save the kernel index
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: ${{ runner.temp }}/semcode-linux
|
||||
key: semcode-linux-${{ runner.os }}-${{ runner.arch }}-${{ env.SEMCODE_REF }}-${{ steps.linux.outputs.commit }}
|
||||
Reference in new issue
Block a user