Audit
Run a diff-scoped code audit. Execute checks and report results by severity.
Reviewer class: class-2 — independent observation: every check confirms an observable fact, so no cross-model reviewer applies. Judging whether the architecture is sound is not audit's job — that lives in the Architecture Review Gate (ARCHITECTURE.md) and quality-review.
Invocation log
This skill is required before marking a feature ticket done. The line below appends a current-run entry to skill-invocations.log under the project namespace root (.project/, or legacy .safeword-project/ where that exists) so the done-gate hook can verify /audit was actually invoked. Claude Code expands the ! line automatically and passes ${CLAUDE_SESSION_ID} when available. The helper also resolves Claude remote-container ids from the runtime environment, and on Cursor and Codex the pre-shell hook (beforeShellExecution / PreToolUse) bridges the session id to the helper — so on all three runtimes the fallback runs without hand-picking an id. Hand-writing audit results cannot produce this feature-gate proof.
!bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/record-skill-invocation.ts" "$CLAUDE_PROJECT_DIR" audit "${CLAUDE_SESSION_ID:-}" || echo "[skill-invocation-log] FAILED - no current-run proof logged"
If no [skill-invocation-log] audit ✓ line appears above, run this fallback before continuing:
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/record-skill-invocation.ts" "$PROJECT_DIR" audit "${CLAUDE_SESSION_ID:-}"
If the automatic line or fallback prints [skill-invocation-log] FAILED, prints no run identity, or still does not print audit ✓: a feature ticket can't be marked done without this proof — don't hand-write audit results as a substitute. Report the failure to the user (most likely cause: inline shell execution was denied, the runtime did not expose a usable run identity, or Bun could not run the installed helper) and ask them to resolve it before re-invoking /audit.
For task, patch, or no-ticket work, this proof isn't required — note it's missing and continue.
Scope
Default to the current working tree's change from origin/main, falling back to
local main. The shared scope helper prints its exact merge-base SHA and changed
files. Treat that printed list as the audit boundary for every review below.
- Review changed source, tests, agent configuration, documentation, and learning files; follow direct references from them when a missing reference could make the change invalid.
- Deleted and type-changed paths are evidence for broken-reference review,
never analyzer inputs. The helper prints them under
Reference review scope. - Whole-workspace Knip, repository clone totals, and dependency-freshness discovery are intentionally skipped in this mode because their pre-existing findings are noise for a feature diff.
Run a repository audit only when the user explicitly asks for a full,
repository-wide, or baseline audit, or when neither origin/main nor main
exists. In that mode retain the prior whole-project checks and report the mode
prominently. Do not silently widen a Git-aware diff audit.
For an explicit repository audit, set AUDIT_SCOPE_REQUEST=repository in the
environment of every executable audit block below. That is what widens code,
agent configuration, learnings, tests, documentation, and domain docs together.
Leave the variable unset for the default diff audit; do not edit the blocks'
commands.
For a stacked branch, set SAFEWORD_AUDIT_BASE_REF=<ref> in the environment of
every executable audit block below. The audit then covers only changes above
that ref. An invalid ref stops the audit instead of silently widening its scope.
Instructions
1. Code Quality Checks
Run the block below verbatim, as ONE bash invocation. Do not extract or paraphrase individual commands — the manifest gates, package-manager routing, and tool-absence messages are load-bearing, and a hand-rolled subset silently skips whole check families.
# Ensure we're in the project root regardless of prior CWD state, then load the
# same scope contract every executable audit block uses.
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
cd "$PROJECT_DIR" || exit 1
source "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/lib/audit-scope.sh"
audit_scope_initialize "$PROJECT_DIR" || exit $?
audit_scope_print
AUDIT_HAS_JS_CHANGE=false
AUDIT_HAS_PYTHON_CHANGE=false
AUDIT_HAS_GO_CHANGE=false
AUDIT_HAS_RUST_CHANGE=false
if [ "$AUDIT_SCOPE_MODE" = "diff" ]; then
audit_scope_has_path_matching '(^|/)(package\.json|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|knip(\.config)?\.(json|jsonc|js|ts)|\.knip\.jsonc?|\.dependency-cruiser\.(cjs|js|mjs|json))$|\.(cjs|cts|js|jsx|mjs|mts|ts|tsx|vue|svelte)$' && AUDIT_HAS_JS_CHANGE=true
audit_scope_has_path_matching '(^|/)(pyproject\.toml|requirements\.txt|setup\.py|setup\.cfg|Pipfile|uv\.lock|poetry\.lock)$|\.py$' && AUDIT_HAS_PYTHON_CHANGE=true
audit_scope_has_path_matching '(^|/)(go\.mod|go\.sum)$|\.go$' && AUDIT_HAS_GO_CHANGE=true
audit_scope_has_path_matching '(^|/)(Cargo\.toml|Cargo\.lock)$|\.rs$' && AUDIT_HAS_RUST_CHANGE=true
fi
AUDIT_HAS_CODE_OR_MANIFEST_CHANGE=false
if [ "$AUDIT_SCOPE_MODE" = "repository" ] || [ "$AUDIT_HAS_JS_CHANGE" = true ] || [ "$AUDIT_HAS_PYTHON_CHANGE" = true ] || [ "$AUDIT_HAS_GO_CHANGE" = true ] || [ "$AUDIT_HAS_RUST_CHANGE" = true ]; then
AUDIT_HAS_CODE_OR_MANIFEST_CHANGE=true
fi
# Stack-specific checks are gated by project manifests. A package.json may be a
# safeword lane host in Python, Rust, or Go installs, so JavaScript checks run
# from package.json evidence while native stack checks run independently.
# JavaScript-specific checks still run only when package.json exists; skip
# JavaScript checks for projects without package.json evidence.
# Detect package manager from lockfiles/packageManager for JavaScript package commands.
detect_package_manager() {
if [ -f bun.lock ] || [ -f bun.lockb ]; then
echo bun
return
fi
if [ -f pnpm-lock.yaml ]; then
echo pnpm
return
fi
if [ -f yarn.lock ]; then
echo yarn
return
fi
if [ -f package-lock.json ]; then
echo npm
return
fi
node -e 'try { const pm = JSON.parse(require("fs").readFileSync("package.json", "utf8")).packageManager || ""; console.log(pm.split("@")[0] || "npm"); } catch { console.log("npm"); }'
}
# Native manifests and Knip configs may belong to leaf applications rather than
# the repository root. Exclude dependency and virtual-environment trees: their
# files are not applications the audit owns and would make results noisy and slow.
find_audit_files() {
find . \
-type d \( -name .git -o -name node_modules -o -name .venv -o -name venv -o -name vendor -o -name target \) -prune -o \
"$@"
}
find_manifest_dirs() {
find_audit_files \
-type f \( "$@" \) -print 2> /dev/null \
| while IFS= read -r manifest; do dirname "$manifest"; done \
| LC_ALL=C sort -u
}
PYTHON_PROJECT_DIRS="$(find_manifest_dirs -name pyproject.toml -o -name requirements.txt -o -name setup.py -o -name setup.cfg -o -name Pipfile)"
GO_MODULE_DIRS="$(find_manifest_dirs -name go.mod)"
RUST_CRATE_DIRS="$(find_manifest_dirs -name Cargo.toml)"
# Keep native tools inside the changed application(s). A language change at the
# repository root correctly selects the root project; a docs-only diff selects
# none. Repository-mode audits retain the complete discovered set.
if [ "$AUDIT_SCOPE_MODE" = "diff" ]; then
if [ "$AUDIT_HAS_PYTHON_CHANGE" = true ]; then
PYTHON_PROJECT_DIRS="$(printf '%s\n' "$PYTHON_PROJECT_DIRS" | filter_audit_dirs_to_diff)"
else
PYTHON_PROJECT_DIRS=""
fi
if [ "$AUDIT_HAS_GO_CHANGE" = true ]; then
GO_MODULE_DIRS="$(printf '%s\n' "$GO_MODULE_DIRS" | filter_audit_dirs_to_diff)"
else
GO_MODULE_DIRS=""
fi
if [ "$AUDIT_HAS_RUST_CHANGE" = true ]; then
RUST_CRATE_DIRS="$(printf '%s\n' "$RUST_CRATE_DIRS" | filter_audit_dirs_to_diff)"
else
RUST_CRATE_DIRS=""
fi
fi
# Run Python dead-code checks once per application, not once per toolkit/package.
# Conventional apps/<app>/... manifests collapse to apps/<app>; jobs are one lane.
python_audit_roots() {
while IFS= read -r project_dir; do
[ -n "$project_dir" ] || continue
case "$project_dir" in
./apps/*/*) printf '%s\n' "$(printf '%s' "$project_dir" | cut -d/ -f1-3)" ;;
./jobs/*) printf '%s\n' './jobs' ;;
*) printf '%s\n' "$project_dir" ;;
esac
done | LC_ALL=C sort -u
}
PYTHON_AUDIT_DIRS="$(printf '%s\n' "$PYTHON_PROJECT_DIRS" | python_audit_roots)"
# Knip resolves a config relative to its current directory. In a monorepo a
# root invocation therefore does not automatically apply apps/*/knip.config.*.
# A root config owns the whole repository; without one, run each leaf config
# from its own directory so its entry/project patterns have their intended scope.
find_knip_configs() {
find_audit_files \
-type f \( -name knip.json -o -name knip.jsonc -o -name .knip.json -o -name .knip.jsonc -o -name knip.ts -o -name knip.js -o -name knip.config.ts -o -name knip.config.js \) -print 2> /dev/null \
| LC_ALL=C sort
}
root_knip_config() {
for config in knip.json knip.jsonc .knip.json .knip.jsonc knip.ts knip.js knip.config.ts knip.config.js; do
[ -f "$config" ] && {
printf '%s\n' "$config"
return
}
done
}
KNIP_CONFIG_FILES="$(find_knip_configs)"
run_knip_check() {
root_config="$(root_knip_config)"
if [ -n "$root_config" ]; then
echo "Knip — repository root ($root_config)"
bunx knip --config "$root_config" 2>&1 || true
elif [ -n "$KNIP_CONFIG_FILES" ]; then
while IFS= read -r config_path; do
[ -n "$config_path" ] || continue
config_dir="$(dirname "$config_path")"
config_name="$(basename "$config_path")"
echo "Knip — $config_dir ($config_name)"
(cd "$config_dir" && bunx knip --config "$config_name" 2>&1 || true)
done << EOF
$KNIP_CONFIG_FILES
EOF
else
echo "Knip — repository root (no workspace config found)"
bunx knip 2>&1 || true
fi
}
run_yarn_outdated_check() {
YARN_VERSION="$(yarn --version 2> /dev/null || true)"
case "$YARN_VERSION" in
0.* | 1.*)
echo "Yarn Classic outdated check: running yarn outdated"
yarn outdated 2>&1 || true
;;
"")
echo "Manual evidence required: yarn.lock found but yarn is unavailable; cannot check outdated JavaScript dependencies."
;;
*)
echo "Yarn modern detected. Manual evidence required: modern Yarn does not provide the Yarn Classic noninteractive 'yarn outdated' command; review dependency freshness with 'yarn upgrade-interactive' or project CI evidence."
;;
esac
}
run_python_outdated_check() {
project_dir="$1"
(
cd "$project_dir" || exit 0
if [ -f uv.lock ]; then
uv pip list --outdated 2>&1 || true
elif [ -f poetry.lock ] || grep -q '^\[tool\.poetry\]' pyproject.toml 2> /dev/null; then
poetry show --outdated 2>&1 || true
elif [ -f Pipfile ]; then
pipenv update --outdated 2>&1 || true
else
python -m pip list --outdated 2>&1 || pip list --outdated 2>&1 || true
fi
)
}
if [ "$AUDIT_HAS_CODE_OR_MANIFEST_CHANGE" != true ]; then
echo "Code quality scope: no changed source or manifest files"
else
[ -n "$PYTHON_PROJECT_DIRS" ] || echo "No Python projects found — Python architecture, dead-code, and outdated checks not applicable"
[ -n "$GO_MODULE_DIRS" ] || echo "No Go modules found — Go architecture, dead-code, and outdated checks not applicable"
[ -n "$RUST_CRATE_DIRS" ] || echo "No Rust crates found — Rust architecture, dead-code, and outdated checks not applicable"
# =========================================================================
# DETECT CONFIG DRIFT (read-only — no writes)
# =========================================================================
# 0. Compare generated vs on-disk depcruise config. Non-zero exit = drift.
# /audit must never mutate the working tree; surface stale config as W007.
# Resolve the locally installed safeword CLI first so the check reflects the
# repo's pinned version, not whatever the npm registry currently calls @latest.
if [ "$AUDIT_SCOPE_MODE" = "repository" ] || [ "$AUDIT_HAS_JS_CHANGE" = true ]; then
if [ -x node_modules/.bin/safeword ]; then
SW="node_modules/.bin/safeword"
elif [ -f packages/cli/src/cli.ts ]; then
SW="bun packages/cli/src/cli.ts"
else SW="bunx safeword"; fi
$SW project sync-config --check 2>&1 || echo "[W007] Stale .safeword/depcruise-config.cjs — run \`safeword project sync-config\` to refresh and commit"
# Config-drift coverage is JS/TS-only (W005 knip hints, W007 depcruise config).
# Native stacks have no comparable drift check yet — say so instead of letting
# silence read as "no drift" (#831).
([ -n "$PYTHON_PROJECT_DIRS" ] || [ -n "$GO_MODULE_DIRS" ] || [ -n "$RUST_CRATE_DIRS" ]) && echo "Coverage limitation: config-drift checks (W005/W007) cover JS/TS tooling only — native lint configs (ruff.toml, .golangci.yml, Cargo [lints]) are not drift-checked; review them manually."
fi
# =========================================================================
# ARCHITECTURE CHECKS (circular deps, layer violations)
# =========================================================================
# 1a. Architecture - TypeScript/JS (depcruise)
DEPCRUISE_CONFIG=""
[ -f .dependency-cruiser.cjs ] && DEPCRUISE_CONFIG=".dependency-cruiser.cjs"
[ -f .dependency-cruiser.js ] && DEPCRUISE_CONFIG=".dependency-cruiser.js"
if [ -n "$DEPCRUISE_CONFIG" ] && { [ "$AUDIT_SCOPE_MODE" = "repository" ] || [ "$AUDIT_HAS_JS_CHANGE" = true ]; }; then
if [ "$AUDIT_SCOPE_MODE" = "diff" ]; then
bunx depcruise --output-type err --config "$DEPCRUISE_CONFIG" --affected "$AUDIT_BASE_SHA" . 2>&1 || true
else
bunx depcruise --output-type err --config "$DEPCRUISE_CONFIG" . 2>&1 || true
fi
fi
# 1b. Architecture - Python (import-linter). Python does NOT reliably catch cycles
# at runtime — an ImportError fires only when the import order happens to touch a
# not-yet-defined name, so a passing test run is NOT proof of an acyclic import
# graph. import-linter is the static gate, but it is config-driven (it enforces only
# declared contracts, nothing by default), so gate on its config and never force it.
if [ -n "$PYTHON_PROJECT_DIRS" ]; then
while IFS= read -r project_dir; do
[ -n "$project_dir" ] || continue
(
cd "$project_dir" || exit 0
if [ -f .importlinter ] || grep -q '^\[importlinter\]' setup.cfg 2> /dev/null || grep -q '^\[tool\.importlinter\]' pyproject.toml 2> /dev/null; then
if command -v lint-imports > /dev/null 2>&1; then
lint-imports 2>&1 || true
else
echo "Manual evidence required: import-linter contracts found in $project_dir but 'lint-imports' not installed — Python architecture check skipped"
fi
else
echo "Manual evidence required: no import-linter contracts for $project_dir (.importlinter / [tool.importlinter] / setup.cfg [importlinter]) — Python import cycles are NOT statically checked (runtime does not reliably catch them). Add import-linter, or run 'pylint --disable=all --enable=cyclic-import <pkg>' for a config-free heuristic."
fi
)
done << EOF
$PYTHON_PROJECT_DIRS
EOF
fi
# 1c. Architecture - Go. The compiler REJECTS import cycles at build, so a green
# `go build ./...` / `go test ./...` already guarantees an acyclic package graph —
# no separate cycle check exists or is needed. Layer/boundary rules are enforced by
# depguard, which runs INSIDE the golangci-lint pass below when `.golangci.yml`
# configures it — do NOT force-enable it (an unconfigured depguard flags every
# non-stdlib import as a false positive).
if [ -n "$GO_MODULE_DIRS" ]; then
while IFS= read -r module_dir; do
[ -n "$module_dir" ] && echo "Go architecture — $module_dir: import cycles are compiler-guaranteed absent (a passing build proves it); boundary contracts run via depguard in the golangci-lint pass when .golangci.yml configures them."
done << EOF
$GO_MODULE_DIRS
EOF
fi
# 1d. Architecture - Rust. Cargo rejects circular crate deps and rustc forbids
# mutually-recursive modules, so a compiling project cannot contain cycles — no
# check needed. No mature standard tool enforces directional layer boundaries in
# Rust (cargo-modules only visualizes); teams enforce boundaries structurally via
# separate crates + visibility. (cargo-deny covers dependency supply-chain —
# advisories/licenses/bans — a different axis, not architecture.)
if [ -n "$RUST_CRATE_DIRS" ]; then
while IFS= read -r crate_dir; do
[ -n "$crate_dir" ] && echo "Rust architecture — $crate_dir: crate/module cycles are compiler-guaranteed absent (a passing build proves it); no standard layer-boundary tool exists — enforce structurally via crates."
done << EOF
$RUST_CRATE_DIRS
EOF
fi
# =========================================================================
# DEAD CODE DETECTION
# =========================================================================
# 2a. Dead code - TypeScript/JS (knip — read-only, reports unused exports/deps/config hints)
# Leaf Knip configs are executed from their workspace so monorepo audits do not
# silently ignore their entry/project rules. Knip's CLI scopes by workspace, not
# by changed source path, so a default diff audit must not emit its unrelated
# whole-workspace baseline. Run the repository audit when that discovery is wanted.
if [ "$AUDIT_SCOPE_MODE" = "repository" ]; then
([ -f package.json ] || [ -n "$KNIP_CONFIG_FILES" ]) && run_knip_check
elif [ "$AUDIT_HAS_JS_CHANGE" = true ]; then
echo "Knip: skipped in diff scope — run a repository audit for whole-workspace unused-code discovery"
fi
# 2b. Dead code - Python (deadcode)
# A missing tool must be loud — `|| true` alone would make "not installed"
# read as "no findings".
if [ -n "$PYTHON_AUDIT_DIRS" ]; then
if command -v deadcode > /dev/null 2>&1; then
while IFS= read -r project_dir; do
[ -n "$project_dir" ] || continue
echo "Python dead-code — $project_dir"
(cd "$project_dir" && deadcode . --exclude .git '*/.git' node_modules '*/node_modules' .venv '*/.venv' venv '*/venv' vendor '*/vendor' target '*/target' 2>&1 || true)
done << EOF
$PYTHON_AUDIT_DIRS
EOF
else
echo "Manual evidence required: deadcode not installed — Python dead-code checks skipped for discovered Python projects"
fi
fi
# 2c. Dead code - Go (golangci-lint unused)
if [ -n "$GO_MODULE_DIRS" ]; then
if command -v golangci-lint > /dev/null 2>&1; then
while IFS= read -r module_dir; do
[ -n "$module_dir" ] || continue
echo "Go dead-code — $module_dir"
(cd "$module_dir" && golangci-lint run --enable unused 2>&1 || true)
done << EOF
$GO_MODULE_DIRS
EOF
else
echo "Manual evidence required: golangci-lint not installed — Go dead-code checks skipped for discovered Go modules"
fi
fi
# 2d. Rust-specific checks (Clippy catches unused code and quality issues)
# Gate on `cargo-clippy` (the binary `cargo clippy` runs), not `cargo`: clippy
# is a rustup component that can be absent while cargo is on PATH, and `|| true`
# would otherwise swallow its failure into a false "clean" result.
if [ -n "$RUST_CRATE_DIRS" ]; then
if command -v cargo-clippy > /dev/null 2>&1; then
while IFS= read -r crate_dir; do
[ -n "$crate_dir" ] || continue
echo "Rust clippy — $crate_dir"
(cd "$crate_dir" && cargo clippy --all-targets --all-features -- -D warnings 2>&1 || true)
done << EOF
$RUST_CRATE_DIRS
EOF
else
echo "Manual evidence required: cargo clippy not available — Rust clippy checks skipped for discovered Rust crates"
fi
fi
# =========================================================================
# CODE DUPLICATION
# =========================================================================
# 3. Copy/paste detection (all languages). Generated/vendored trees are
# guaranteed clones, so exclude them — findings should be hand-written dupes.
# The ignore list IS the recorded scope (issue #825): `.safeword/**` is a
# parity-enforced byte-mirror of templates (clones by design) and the
# namespace root (`.project/**` / legacy `.safeword-project/**`) is the ticket
# archive — both drown real findings. Keep this list stable so clone counts
# stay comparable across audits. A changed-only input would miss a new clone
# against an unchanged file, so keep this as explicit repository discovery.
if [ "$AUDIT_SCOPE_MODE" = "repository" ]; then
bunx jscpd . --min-lines 10 --reporters console --ignore "**/node_modules/**,**/dist/**,**/build/**,**/coverage/**,**/.safeword/**,**/.project/**,**/.safeword-project/**" 2>&1 || true
elif [ "$AUDIT_HAS_CODE_OR_MANIFEST_CHANGE" = true ]; then
echo "Duplication: skipped in diff scope — run a repository audit for cross-file clone discovery"
fi
# =========================================================================
# OUTDATED DEPENDENCIES
# =========================================================================
# Dependency freshness is inherently repository-wide: every installed version
# can be outdated regardless of this diff. Keep it in repository mode instead
# of flooding a feature audit with unrelated upgrade work.
if [ "$AUDIT_SCOPE_MODE" = "repository" ]; then
# 4a. Outdated - TypeScript/JS
[ -f package.json ] && {
case "$(detect_package_manager)" in
bun) bun outdated 2>&1 || true ;;
npm) npm outdated 2>&1 || true ;;
pnpm) pnpm outdated 2>&1 || true ;;
yarn) run_yarn_outdated_check ;;
*) echo "Skipping outdated JavaScript dependencies: unsupported package manager" ;;
esac
} || echo "Skipping outdated JavaScript dependencies: no package.json; skip JavaScript package checks"
# 4b. Outdated - Python-specific checks (uv > poetry > pipenv > pip)
if [ -n "$PYTHON_AUDIT_DIRS" ]; then
while IFS= read -r project_dir; do
[ -n "$project_dir" ] || continue
echo "Python outdated dependencies — $project_dir"
run_python_outdated_check "$project_dir"
done << EOF
$PYTHON_AUDIT_DIRS
EOF
fi
# 4c. Outdated - Go-specific checks
if [ -n "$GO_MODULE_DIRS" ]; then
while IFS= read -r module_dir; do
[ -n "$module_dir" ] || continue
echo "Go outdated dependencies — $module_dir"
(cd "$module_dir" && (go list -m -u all 2>&1 | grep '\[' || echo "All Go modules up to date"))
done << EOF
$GO_MODULE_DIRS
EOF
fi
# 4d. Outdated - Rust-specific checks
if [ -n "$RUST_CRATE_DIRS" ]; then
while IFS= read -r crate_dir; do
[ -n "$crate_dir" ] || continue
echo "Rust outdated dependencies — $crate_dir"
(cd "$crate_dir" && cargo update --dry-run 2>&1 || true)
done << EOF
$RUST_CRATE_DIRS
EOF
fi
elif [ "$AUDIT_HAS_CODE_OR_MANIFEST_CHANGE" = true ]; then
echo "Dependency freshness: skipped in diff scope — run a repository audit for upgrade discovery"
fi
fi
Outdated Package Triage (repository audits only)
After running the outdated checks above, classify each outdated package using this matrix:
| Dep Type | Bump | Risk | Action |
|---|---|---|---|
| dev | patch | Low | Safe to update now |
| dev | minor | Low | Safe to update now |
| prod | patch | Low | Safe to update — run tests after |
| prod | minor | Medium | Review changelog, then update |
| dev | major | Medium | Research breaking changes, may need config updates |
| prod | major | High | Defer to dedicated task — investigate migration path |
| any | 0.x minor | Medium | Treat as major (semver allows breaking changes in 0.x) |
Present results as a structured table:
| Package | Current | Latest | Type | Bump | Risk |
|---------|---------|--------|------|------|------|
| knip | 5.86.0 | 5.88.1 | dev | patch | Low |
| eslint | 9.39.4 | 10.0.3 | dev | major | High |
Then give a verdict per risk tier:
- Low risk: "Safe to update now — dev-only tools, patch/minor bumps"
- Medium risk: "Review changelogs before updating" (list specific packages)
- High risk: "Defer to dedicated task — major version bumps need migration research" (list specific packages)
If all packages are up to date, report: ✅ All packages up to date
Knip Configuration Hints (W005, repository audits only)
Check the knip output above for "Configuration hints" lines. If knip reports configuration hints (unused entries in ignoreDependencies, ignoreBinaries, ignoreUnresolved, or ignoreWorkspaces), flag each as:
- [W005] Stale config: `knip.json` — `{entry}` can be removed from {list}
These mean the ignore override no longer matches anything knip would flag — the suppression is dead config. Cleaning them up reduces noise for future readers.
If no configuration hints are found, skip this section.
Findings triage — baselines, not re-litigation
- Knip:
knip.json's ignore lists ARE the accepted-false-positive baseline — persist confirmed FPs there instead of re-triaging them every run (W005 flags any entry that goes stale, so the baseline self-cleans). Report only findings not already covered by the ignore lists. - jscpd: record the clone count in the audit summary with its scope named next to the count — e.g.
Clones: 416 (8.9%) [repo minus .safeword,.project]— and compare against the previous audit's recorded count at the SAME scope (last verify.md/audit record, if any). A count whose scope differs from the prior record is a new baseline, not a delta (issue #825: unscoped counts spanning 84→594 proved incomparable). Deltas are the findings; a flat count is the baseline, not a finding. Never report a raw total as if it were new.
2. Agent Config Checks
In a diff audit, find and check changed agent configuration files (excluding
.safeword/) and direct references from them. In a repository audit, check
every matching configuration file.
Files to check:
CLAUDE.md,AGENTS.md(root and subdirectories).claude/CLAUDE.md(root and subdirectories).cursor/rules/*.mdcor.cursor/rules/*/(root and subdirectories).cursorrules(legacy)
For each changed config file, check:
| Check | Criteria | Severity |
|---|---|---|
| Size limit | CLAUDE.md/AGENTS.md: ~150-200 instructions; Cursor rules: 500 lines | warn |
| Structure | Has WHAT/WHY/HOW sections | warn |
| Dead refs | All referenced files/paths exist (skip URLs starting with http) | error |
| Staleness | Do not report date-only staleness in a diff audit; use a repository audit | n/a |
3. Learning Files Check
Changed project learnings in the resolved namespace root's learnings/*.md must have a Covers: line on line 3 — the auto-generated INDEX.md is built from these lines, and files without them don't appear in the index. In a repository audit, check every learning as before.
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
source "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/lib/audit-scope.sh"
audit_scope_initialize "$PROJECT_DIR" || exit $?
NS_ROOT="$(bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/resolve-namespace-root.ts" "$PROJECT_DIR")"
learning_is_in_audit_scope() {
[ "$AUDIT_SCOPE_MODE" = "repository" ] && return 0
learning_path="${1#"$PROJECT_DIR"/}"
audit_scope_path_changed "$learning_path"
}
if [ -d "$NS_ROOT/learnings" ]; then
for f in "$NS_ROOT"/learnings/*.md; do
[ -e "$f" ] || continue
[ "$(basename "$f")" = "INDEX.md" ] && continue
learning_is_in_audit_scope "$f" || continue
line3=$(sed -n '3p' "$f")
case "$line3" in
Covers:*) ;;
*) echo "[W006] Missing Covers: line on line 3 — $f" ;;
esac
done
fi
Flag each non-conforming file as:
- [W006] Learning file missing Covers: — `{path}` (absent from INDEX.md)
If all files conform, skip this section.
4. Test Quality Review
Review changed test files for quality issues, plus a changed source file's
co-located test when present. Check them against the iron laws and anti-patterns
in the $safeword:testing skill. A repository audit may use the former
project-wide sample.
Find test files:
# Start with test files named in the printed audit scope. For a changed
# `src/foo.ts`, also inspect `src/foo.test.ts` / `src/foo.spec.ts` when present.
# Repository audit fallback (common patterns):
find . -name "*.test.*" -o -name "*.spec.*" -o -name "*_test.*" | grep -v node_modules | grep -v dist | head -20
For each sampled test file, check:
The criteria are language-neutral; the parenthetical idioms are examples — map them to the project's test framework (Jest/Vitest, pytest, Go testing, Rust #[test], …).
| Check | Criteria | Severity |
|---|---|---|
| Meaningful assertions | Every test asserts specific values/behavior — not bare existence/truthiness/no-error checks (toBeTruthy, bare assert result, only err == nil, assert!(x.is_ok())) |
error |
| Behavior over implementation | Tests assert observable outcomes, not internal state or mock call args | error |
| Independence | No test depends on another test's side effects; fresh state per test | error |
| No arbitrary timeouts | No sleeps or hardcoded delays (sleep, waitForTimeout, time.Sleep, thread::sleep) |
error |
| Edge case coverage | Tests include error paths and boundary cases, not just happy path | error |
| No duplicate tests | Similar tests use parameterized/table-driven patterns (it.each, pytest.mark.parametrize, Go table-driven subtests, rstest) |
error |
| Test naming | Names describe behavior, not implementation ("returns 401 when..." not "works correctly") | error |
Report format:
Test Quality:
- Files reviewed: N
- Issues found: N (E errors)
- [E] file.test.ts:42 — Weak assertion: `expect(result).toBeTruthy()` → assert specific value
- [E] file.test.ts:15 — Shared mutable state: `user` modified across tests
- [E] file.test.ts — Happy-path only: no error case tests for `processOrder()`
5. Project Documentation Checks
Docs source inventory:
- Read
.safeword/config.jsonfirst. If top-leveldocs.sourcesexists, treat it as the authoritative documentation inventory:{ "type": "local", "path": "..." }— inspect that file or directory. Relative paths resolve from the project root.{ "type": "url", "url": "..." }— fetch the page/site when browsing or network access is available. If unavailable, report it under coverage limitations.{ "type": "git", "repo": "...", "path": "..." }— inspect the repo/path when it is already available or can be fetched without credentials. If unavailable, report it under coverage limitations.
- If
docs.sourcesis absent, prompt the user: "Where should audit look for project documentation? I can add local paths, URLs, git repos, or setdocs.sources: []to keep fallback discovery and stop asking." Wait for the answer before continuing unless the run is explicitly autonomous; in autonomous runs, use fallback discovery and report that no decision was recorded. - If the user chooses not to configure documentation sources, write
docs.sources: []in.safeword/config.json. Treat that explicit empty list as a durable no-prompt decision in future audits. - If
docs.sources: []is configured, do not prompt. Fall back to local discovery:README.md,docs/,documentation/, package docs folders, and known docs-site configs. - Always report docs coverage: configured vs fallback, sources checked, and sources skipped. In a diff audit, inspect sources directly affected by changed code or changed docs. In a repository audit, inspect the entire configured or fallback source inventory; date-only staleness and a last-20-commits sweep belong there too.
ARCHITECTURE.md (the architecture narrative):
- Resolve the narrative location first: the
paths.architecturetarget in.safeword/config.jsonwhen set — a file is the narrative itself; a directory holds decision records, read them all — else the rootARCHITECTURE.md. A configured location wins outright: do not fall back to a root file the host deliberately moved away from. Every check below applies to the resolved narrative. - If missing → create the configured architecture document from the required sections below (at the configured location when
paths.architectureis set, else rootARCHITECTURE.md) - If exists → check for drift and gaps along TWO axes — dependency drift (what tech) and structural drift (what modules/layers):
- Dependency drift:
- Drift (error): Documented tech contradicts the code's actual dependencies (e.g., doc says "Redux" but
package.jsonhas "zustand"; doc says "Flask" butpyproject.tomlhas "fastapi") - Gap (error): Major dependencies not documented
- Drift (error): Documented tech contradicts the code's actual dependencies (e.g., doc says "Redux" but
- Structural drift — reconcile ARCHITECTURE.md's STRUCTURAL claims against
architecture.generated.md, the deterministic, always-fresh module/package map (kept current by the architecture hooks). Read the generated doc as ground truth — NOTpackage.json:- Read the namespace-root
architecture.generated.md(resolve the namespace root the same way as other audit checks; default.project/). Its### <name>headings under## Modules(single-repo) or## Packages(monorepo) ARE the project's real top-level units. This machine list is the source of structural truth, so the verdict is deterministic-by-reading, not guessed. - Orphaned (error): ARCHITECTURE.md documents a module/layer — including a layer→directory mapping in its "Layers & Boundaries" table — that no longer appears in the generated map (renamed or removed).
- Drifted layer→dir (error): A "Layers & Boundaries"
directoryentry that matches no module path in the generated map. - Inventory omissions are not findings: The generated document owns the structural inventory. Never require the human narrative or decision records to mention every generated module/package; those documents own the architectural "why," not a duplicate package-by-package list.
- Report only — never auto-overwrite prose. Cite the generated-doc evidence and propose narrative edits for the user to review; the human "why" is human-owned, and only a person can judge whether a paragraph is still true. The deterministic structural facts come from reading the generated doc; the narrative judgment stays with the human/agent.
- Read the namespace-root
- A monorepo
## Coverage gapsadvisory in the generated doc (a present-but-unparseable workspace manager, #558) is itself a coverage limitation — note it so the structural reconciliation isn't mistaken for complete.
- Dependency drift:
README.md:
- Check changed claims and impacted references. Check date-only staleness only in a repository audit.
Docs site (if changed or directly impacted):
- Detect
docs/,documentation/with Starlight/Docusaurus/etc config - Check staleness of docs content
Documentation impact check:
Review the changed area from the printed scope. For each significantly changed area, check if related docs, readmes, or guides need updating. Flag stale, missing, or contradictory impacted documentation as errors. Documentation drift is never a warning; date-only staleness with no changed-code contradiction is repository-audit context, not a diff finding.
6. Principle Trace Integrity
For the active ticket, when impl-plan.md declares project-principle alignment,
resolve the source using paths.principles (default
<namespace-root>/principles.md) and check the principle trace as observable
facts only:
- The named principle exists in the configured source.
- The trace contains a non-empty concrete consequence and proof.
- The proof reference resolves to recorded test, verification, or manual evidence; an intentional conflict is named in Known deviations.
Report a missing source entry, incomplete mapping, dead evidence reference, or
unrecorded conflict as [E010] Broken principle trace. Do not judge whether a
principle was applicable, whether the consequence was a wise interpretation,
or whether an experience was genuinely delightful—those are adversarial
quality-review judgments. A plan with no declared applicable principle is not
an audit finding.
Run the factual checker below verbatim. Its sentinel keeps the executable audit contract testable without turning semantic review into shell heuristics.
# principle-trace-check — E010 objective trace integrity only.
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
TICKET_PATH="$(bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/resolve-verify-ticket.ts" "$PROJECT_DIR")"
ticket_status=$?
if [ "$ticket_status" -ne 0 ]; then
exit "$ticket_status"
fi
if [ -n "$TICKET_PATH" ]; then
PLAN_PATH="$(dirname "$TICKET_PATH")/impl-plan.md"
[ ! -f "$PLAN_PATH" ] || bun "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/audit-principle-trace.ts" "$PROJECT_DIR" "$PLAN_PATH"
fi
7. Namespace Domain Docs
When a changed feature/spec or changed domain doc references them, reconcile the
three namespace domain docs — personas.md, surfaces.md, glossary.md —
against those changed references and report empty scaffolds. A repository audit
reconciles the whole corpus. This check is read-only and class-2 (observable
facts only): it reports and offers, it never rewrites a doc. Run the block
below verbatim, as ONE bash invocation.
# domain-docs-check — read-only reconciliation of the namespace domain docs.
# Class-2: observable facts only. Emits W008 (empty). Never writes the tree.
cd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}" || exit 1
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2> /dev/null || pwd)}"
source "${CLAUDE_PLUGIN_ROOT}/runtime/hooks/lib/audit-scope.sh"
audit_scope_initialize "$PR
…(truncated)