test-impact
SkillDev tools`$codemap-py:test-impact <qname> [--no-mocks]`: affected tests; skip caller/dependency query/exec.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the test-impact skill
What this skill tells your AI
The instructions your AI receives, as published by borda/ai-rig in plugins/codemap-py/codex-skills/test-impact/SKILL.md and read by ahel’s review.
Find minimal tests affected by function/module change via Codemap static analysis; run no tests. Return ready pytest command for impacted tests only.
Two input modes:
- Function-level (
module::symbol) — reverse-call-graph BFS; all direct/transitive test callers + symbol mocks (mock_patches). - Module-level (bare
module) — reverse-import-graph BFS; all tests importing through any chain + any module-symbol mocks.
not_covered: dynamic dispatch, hook callbacks, string-dispatch callers; same blind spot as fn-blast. Surface caveat; log gap.
NOT for: finding all callers of a function (use /codemap-py:query-code fn-rdeps <module::symbol> --exclude-tests); querying module deps or blast radius (use /codemap-py:query-code); running/executing tests (identified here, not executed).
- $ARGUMENTS:
<qname> [--no-mocks]qname—module::symbol(function-level) or bare dotted module (module-level)--no-mocks— exclude mock-only test files (no call/import path)- Omitted → AskUserQuestion in Step 1
Step 0 — Ensure index
# timeout: 10000
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
_CM_PROJ=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || basename "$PWD")
_IDX="${CODEMAP_INDEX_DIR:-.cache/codemap}"
INDEX="${_IDX}/${_CM_PROJ}.json"
# dispatcher, not the scan-query/scan-index aliases — aliases lease in-engine too, but skip the dispatcher's interpreter probe (exit 127) and are deprecated shims, removed no earlier than 1.0.0
# PATH-literal invocation everywhere below — expansion-bearing form matches no bare-name allow prefix; the plugin's absolute bin/codemap-py stays the interactive fallback
command -v codemap-py >/dev/null 2>&1 || { echo "codemap-py not on PATH — install the codemap-py plugin, or invoke its bin/codemap-py launcher as one standalone command"; exit 1; }
[ ! -f "$INDEX" ] && echo "No index found — will build via codemap-py index"
SCAN_NO_AUTOBUILD=1 opts out: use index unchanged; no refresh/full build. Echo build wall-time when run, separating build/query cost.
If $INDEX missing:
- With
SCAN_NO_AUTOBUILD=1: print! codemap index missing and SCAN_NO_AUTOBUILD=1 — refusing to auto-build. Build it manually first: /codemap-py:scan-codebase; exit 1. - Otherwise run foreground
codemap-py index; wait, then continue. Never invokecodemap-py:scan-codebase:disable-model-invocation:true, user-slash-only. Build through gatedcodemap-py indexdispatcher.
If index already exists:
# timeout: 30000
_CM_PROJ=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || basename "$PWD")
_IDX="${CODEMAP_INDEX_DIR:-.cache/codemap}"
if [ "${SCAN_NO_AUTOBUILD:-0}" = "1" ]; then
echo "[codemap] SCAN_NO_AUTOBUILD=1 — using existing index as-is (no refresh)"
else
_CM_BUILD_T0=$(date +%s)
# export, not an inline env prefix — a prefix puts an expansion ahead of the binary, so the command no longer matches a bare-name allow prefix
export CODEMAP_INDEX_DIR="${_IDX}" # forward to the build; ensures it writes to same path as INDEX
codemap-py index --incremental \
&& echo "[codemap] index built in $(( $(date +%s) - _CM_BUILD_T0 ))s" \
|| printf "⚠ codemap-py index --incremental failed — index may be stale; continuing\n"
fi
After build/refresh, re-verify index:
# timeout: 5000
_CM_PROJ=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || basename "$PWD")
_IDX="${CODEMAP_INDEX_DIR:-.cache/codemap}"
INDEX="${_IDX}/${_CM_PROJ}.json"
[ -f "$INDEX" ] || { printf "! Index not found after refresh at %s — check CODEMAP_INDEX_DIR or re-run /codemap-py:scan-codebase\n" "$INDEX"; exit 1; }
Step 1 — Parse arguments
Extract QNAME + NO_MOCKS from $ARGUMENTS.
# timeout: 5000
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
_CM_PROJ=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || basename "$PWD")
ARGS="${ARGUMENTS:-}"
QNAME=$(echo "$ARGS" | awk '{print $1}')
MOCKS_FLAG=$(echo "$ARGS" | grep -q -- "--no-mocks" && echo "--no-mocks" || echo "")
echo "$QNAME" > "${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-qname-${CSID}"
echo "$MOCKS_FLAG" > "${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-mocks-${CSID}"
If $ARGUMENTS empty, use AskUserQuestion: "Which function or module changed?" Options: (a) Enter module::symbol for function-level; (b) Enter bare module for module-level; (c) Cancel, exit without analysis. After answer, set QNAME; write tmpfile before Step 2:
# timeout: 5000
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
_CM_PROJ=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || basename "$PWD")
QNAME="<answer from AskUserQuestion>"
echo "$QNAME" > "${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-qname-${CSID}"
Multi-symbol guard: $ARGUMENTS may contain multiple tokens (for example mypackage.auth::validate mypackage.auth::parse); awk '{print $1}' silently keeps first. If more than one after removing --no-mocks, print ⚠ test-impact accepts one symbol at a time — using first token only: $QNAME. Run separately for each remaining symbol.
Step 2 — Run test-impact query
# timeout: 10000
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
_CM_PROJ=$(git rev-parse --show-toplevel 2>/dev/null | xargs basename 2>/dev/null || basename "$PWD")
IFS= read -r QNAME < "${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-qname-${CSID}" 2>/dev/null || QNAME=""
IFS= read -r MOCKS_FLAG < "${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-mocks-${CSID}" 2>/dev/null || MOCKS_FLAG=""
_TI_ERR="${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-stderr-${CSID}"
# capture stderr to a file, never 2>/dev/null — a swallowed diagnostic is what rendered a broken index as "no affected tests"
RESULT=$(codemap-py query test-impact "$QNAME" $MOCKS_FLAG 2>"$_TI_ERR")
_TI_RC=$?
if [ "$_TI_RC" -ne 0 ]; then
printf "! test-impact query failed (exit %d) — this is NOT a 'no affected tests' result; do not report an empty test set\n" "$_TI_RC" >&2
[ -s "$_TI_ERR" ] && sed -n '1,5p' "$_TI_ERR" >&2
printf "Rebuild with /codemap-py:scan-codebase, then re-run.\n" >&2
exit 1
fi
# one parse, no per-field `|| echo` defaults — a default here forges total=0 out of an unparsable payload
printf '%s' "$RESULT" | python3 -c "
import json, sys
base, suf = sys.argv[1], sys.argv[2]
try:
payload = json.loads(sys.stdin.read())
except ValueError:
sys.stderr.write('! test-impact returned non-JSON output — NOT an empty result; rebuild via /codemap-py:scan-codebase and re-run\n')
raise SystemExit(1)
index = payload.get('index') or {}
fields = {
'not-covered': json.dumps(index.get('not_covered') or []),
'hint': index.get('hint') or '',
'total': str(len(payload.get('test_files') or [])),
'pytest-cmd': payload.get('pytest_cmd') or '',
}
for name, value in fields.items():
with open(base + name + suf, 'w') as handle:
handle.write(value + '\n')
" "${TMPDIR:-/tmp}/codemap-${_CM_PROJ}-ti-" "-${CSID}" || exit 1
Parse $RESULT JSON:
test_files— list of test file pathspytest_cmd— ready-to-run commandvia_call/via_mock— breakdown of how tests were foundindex.not_covered— surface as caveat if non-emptyindex.hint— include as suggestion
Loud-failure contract: query failure ≠ empty result. Shell enforces it: nonzero exits skill 1 before field reads; unparsable payload exits parser 1. Neither may become "no affected tests". False empty set from broken index is worst possible output.
haiku JSON guard: output may include log/warning prefix/suffix. Always pipe stdin into python3 -c "import json, sys; ..."; never assume raw valid JSON. Never add per-field || echo "0" / || echo "[]": failed parse must not manufacture benign defaults.
Step 3 — Output
When total == 0: reachable only after Step 2 exit 0 + parsed payload: genuine empty. Report "No tests found via static analysis. Try full suite or check with grep -rn <symbol_name> tests/."
When total > 0:
## Test impact: <qname>
**Affected tests** (<total> files, <via_call> via call/import graph, <via_mock> via mocks):
<test_files as bullet list>
**Run:**
```
<pytest_cmd>
```
<if not_covered non-empty>
**Caveat:** dynamic-dispatch / hook-callback callers are not in the static graph — <hint>.
</if>
Output routing: if total >= 5, derive free non-colliding path; write report there:
# timeout: 3000
BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-'); BRANCH="${BRANCH:-main}"
# never overwrite — a same-day re-run on another target would replace an unrelated report
TI_OUT=".temp/output-test-impact-${BRANCH}-$(date +%Y-%m-%d).md"; _n=1
while [ -e "$TI_OUT" ]; do _n=$((_n+1)); TI_OUT=".temp/output-test-impact-${BRANCH}-$(date +%Y-%m-%d)-${_n}.md"; done
printf '%s\n' "$TI_OUT"
Signals
- GitHub stars
- 27
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
test-impact- Source
- github.com/borda/ai-rig