Skip to content

Commit c4b2b7e

Browse files
ClintEastman02bgagentkrokoko
authored
feat(ci): dead-code detection gate — knip + no-unused-vars + vulture (#282) (#520)
* feat(ci): dead-code detection gate — knip + no-unused-vars + vulture (#282) Closes the unused-symbol detection asymmetry (cairn MVG gate #6): Python already fails on unused imports (ruff F401) but unused TS symbols passed silently in cdk/. Adds three detection layers plus advisory CI wiring. - TS lint-level: @typescript-eslint/no-unused-vars (error) in cdk+cli flat configs with ^_ ignore patterns; cdk tsconfig flips noUnusedLocals/noUnusedParameters to true (parity with cli). - TS project-graph: knip v6 with per-workspace knip.json (cdk/cli/docs) + a count-only ratchet (scripts/check-deadcode-ratchet.mjs, knip-baseline.json) that fails only when the count rises — the cairn "dead-code count should not increase" metric. - Python module-level: vulture 2.16 (config in pyproject [tool.vulture], min-confidence 80) with a minimal reviewed .vulture_allowlist.py for the SDK-mandated hook_context params. - CI: advisory dead-code job in security-pr.yml (continue-on-error, emits ::notice::/::warning:: annotations; not blocking yet). Resolves the three known live instances: drops the dead DEFAULT_MAX_TURNS import, removes the orphaned cdk/src/bootstrap/index.ts barrel (consumers import the submodules directly), and removes the unused LINEAR_SECRET_PREFIX export. Also clears the unused symbols the new rule surfaces across cdk/cli. The eslint half is blocking via the existing build check; knip+vulture stay advisory until the knip baseline is driven to zero (deferred AC). * fix(agent): exclude vulture allowlist from ty typecheck (#282) The ty typecheck step scanned .vulture_allowlist.py and failed with unresolved-reference on the bare hook_context names (the allowlist is bare-name expressions by design, not type-checkable Python). Same conflict already handled for ruff via extend-exclude; add the matching [tool.ty.src] exclude so `mise run build` passes. * chore(ci): ratchet dead-code baseline 79 → 78 after main merge (#282) Merging main (#505 CLI dedupe) removed a dead symbol, dropping knip's count to 78. Lock in the gain so the ratchet holds at the lower number. * fix(cdk): restore exhaustiveness guard + correct knip ratchet keys (#282) Addresses PR review (ayushtr-aws): 1. validation.ts: the `void (true as _AssertAttachmentExhaustive)` form silently defeated the AttachmentType exhaustiveness guard — a type assertion never errors, whereas the original ASSIGNMENT to a `never` type is the actual compile-time check. Restored the assignment and consume the binding with `void` so it survives noUnusedLocals and no-unused-vars without weakening the guard. Verified: tsc errors when a member is missing from ATTACHMENT_TYPE_LIST, compiles clean when exhaustive. 2. check-deadcode-ratchet.mjs: COUNTED_KEYS used non-existent knip 6.x keys (classMembers, nsExports, nsTypes) and counted unused files from a non-existent top-level report.files. Switched to the real keys (namespaceMembers, catalog) and count files from issues[].files[] so unused files actually move the ratchet. Count unchanged at 78. * chore(ci): harden dead-code ratchet + comment accuracy (#282 review nits) Non-blocking follow-ups from krokoko's review: - Pin knip exactly (6.20.0, drop caret) and make the ratchet fail loud instead of open: if a future knip reshapes its JSON so `issues` is not an array, countIssues now exits 2 rather than silently counting 0 and reporting a passing build (which would disable the gate). Needed before the gate flips to blocking. - COUNTED_KEYS comment: note the list is complete for the pinned knip and that nsExports/nsTypes/classMembers are not part of its schema. - validation.ts: attribute the `void` to tsconfig noUnusedLocals specifically (no-unused-vars already exempts the ^_ binding). * refactor(ci): move dead-code job to its own workflow + use uv run for vulture Address PR #520 review feedback: - Extract dead-code detection into dead-code-pr.yml (separate concern from security gate) - Use `uv run vulture` instead of `uvx vulture` for consistency with project conventions --------- Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Alain Krok <alkrok@amazon.com>
1 parent 37a5c88 commit c4b2b7e

25 files changed

Lines changed: 718 additions & 55 deletions

.github/workflows/dead-code-pr.yml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: dead-code-pr
2+
# Advisory dead-code detection gate (issue #282, cairn MVG gate #6).
3+
# Steps use continue-on-error so findings surface as annotations without
4+
# blocking the merge queue. Flips to required/blocking once the knip
5+
# baseline (knip-baseline.json) is driven to zero. The eslint no-unused-vars
6+
# half of the gate is already blocking via the `build` check.
7+
on:
8+
pull_request: {}
9+
merge_group: {}
10+
workflow_dispatch: {}
11+
12+
permissions:
13+
contents: read
14+
15+
concurrency:
16+
group: dead-code-pr-${{ github.event.pull_request.number || github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
dead-code:
21+
name: Dead-code detection (advisory)
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 15
24+
permissions:
25+
contents: read
26+
env:
27+
CI: "true"
28+
MISE_EXPERIMENTAL: "1"
29+
steps:
30+
- name: Checkout
31+
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
32+
with:
33+
persist-credentials: false
34+
35+
- name: Install mise
36+
uses: jdx/mise-action@dba19683ed58901619b14f395a24841710cb4925 # v4.1.0
37+
with:
38+
cache: true
39+
40+
- name: Setup Node.js
41+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
42+
with:
43+
node-version: 22.x
44+
45+
- name: Install dependencies
46+
run: mise run install
47+
48+
- name: TS dead-code ratchet (knip, advisory)
49+
continue-on-error: true
50+
run: |
51+
if mise run check:deadcode-ratchet; then
52+
echo "::notice title=knip ratchet::Dead-code count is at or below baseline."
53+
else
54+
echo "::warning title=knip ratchet::Dead-code count increased above the baseline (knip-baseline.json). Run 'yarn knip' locally and remove the new dead code, or suppress a false positive in knip.json."
55+
fi
56+
57+
- name: Python dead-code scan (vulture, advisory)
58+
continue-on-error: true
59+
run: |
60+
if mise //agent:lint:deadcode; then
61+
echo "::notice title=vulture::No Python dead code above the confidence threshold."
62+
else
63+
echo "::warning title=vulture::vulture found unused Python code. Remove it, or add an intentional keep to agent/.vulture_allowlist.py."
64+
fi

agent/.vulture_allowlist.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Vulture allowlist (issue #282, cairn MVG gate #6).
2+
#
3+
# Names here are reported by vulture as unused but are intentionally kept.
4+
# vulture "uses" every attribute access in this file, so listing a name
5+
# suppresses its finding. Only entries that fire at `--min-confidence 80`
6+
# (the CI threshold) belong here — lower-confidence noise (Pydantic
7+
# `model_config`, FastAPI route handlers, dynamically-dispatched methods)
8+
# is below the threshold and must NOT be blanket-allowlisted, so that real
9+
# dead code in those forms still surfaces.
10+
#
11+
# Regenerate/extend (then hand-review the diff):
12+
# cd agent && uvx vulture src --make-whitelist
13+
#
14+
# Keep this list MINIMAL. Prefer deleting dead code over allowlisting it.
15+
16+
# claude-agent-sdk invokes hooks positionally as
17+
# (hook_input, tool_use_id, hook_context). The dispatcher in hooks.py passes
18+
# its own `ctx`, so the `hook_context` positional is part of the required
19+
# signature but unread in these three hook bodies. Cannot be removed without
20+
# breaking the SDK calling convention.
21+
hook_context # unused variable (src/hooks.py:132)
22+
hook_context # unused variable (src/hooks.py:918)
23+
hook_context # unused variable (src/hooks.py:1258)

agent/mise.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ run = "uvx ruff format --check"
4444
description = "Type check with ty"
4545
run = "uv run ty check"
4646

47+
[tasks."lint:deadcode"]
48+
description = "Dead-code detection with vulture (#282, cairn MVG gate #6): unused functions/classes/methods ruff F cannot see. Config + allowlist in pyproject.toml / .vulture_allowlist.py."
49+
run = "uv run vulture"
50+
4751
[tasks.test]
4852
description = "Run tests with pytest (coverage floor enforced; use `uv run pytest --no-cov` for focused runs)"
4953
run = "uv run pytest --cov=src --cov-branch --cov-fail-under=72 --cov-report=term-missing:skip-covered --cov-report=lcov:coverage/lcov.info --cov-report=json:coverage/coverage.json"

agent/pyproject.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ constraint-dependencies = [
5555
"pydantic-settings>=2.14.2", # GHSA-4xgf-cpjx-pc3j — transitive via mcp; remove when mcp bumps floor
5656
]
5757

58+
# Dead-code detection (#282, cairn MVG gate #6). ruff "F" catches unused
59+
# imports/locals; vulture catches unused module-level functions/classes/methods
60+
# that ruff cannot see. min_confidence 80 keeps the signal high; intentional
61+
# keeps (e.g. SDK-mandated hook params) live in .vulture_allowlist.py.
62+
[tool.vulture]
63+
paths = ["src", ".vulture_allowlist.py"]
64+
min_confidence = 80
65+
5866
[tool.bandit]
5967
exclude_dirs = ["tests", ".venv"]
6068
skips = [
@@ -73,11 +81,16 @@ dev = [
7381
"pytest",
7482
"pygments==2.20.0",
7583
"pytest-cov==7.1.0",
84+
"vulture==2.16", # dead-code detection (#282): unused functions/classes ruff F can't see
7685
]
7786

7887
[tool.ruff]
7988
target-version = "py313"
8089
line-length = 100
90+
# The vulture allowlist is bare-name expressions by design (that is how vulture
91+
# "uses" a name to suppress it). ruff would flag every line F821/B018, so it is
92+
# not Python source ruff should lint. See .vulture_allowlist.py (#282).
93+
extend-exclude = [".vulture_allowlist.py"]
8194

8295
[tool.ruff.lint]
8396
select = [
@@ -124,3 +137,9 @@ skip_covered = true
124137
[tool.ty.environment]
125138
python-version = "3.13"
126139
extra-paths = ["src"]
140+
141+
[tool.ty.src]
142+
# The vulture allowlist is bare-name expressions by design, not type-checkable
143+
# Python — ty flags each line unresolved-reference. Excluded here for the same
144+
# reason ruff excludes it via extend-exclude (#282).
145+
exclude = [".vulture_allowlist.py"]

agent/uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cdk/eslint.config.mjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ export default [
156156
}],
157157

158158
// TypeScript rules
159+
// Dead-code parity with Python ruff F401 (#282): unused imports/locals/params
160+
// are errors. `^_` opts out an intentionally-unused binding. Base rule must be
161+
// off so the typescript-eslint variant (type-aware) is the only one active.
162+
'no-unused-vars': 'off',
163+
'@typescript-eslint/no-unused-vars': ['error', {
164+
args: 'all',
165+
argsIgnorePattern: '^_',
166+
varsIgnorePattern: '^_',
167+
caughtErrors: 'all',
168+
caughtErrorsIgnorePattern: '^_',
169+
ignoreRestSiblings: true,
170+
destructuredArrayIgnorePattern: '^_',
171+
}],
159172
// AI007 guard (#258): inline numeric literals should be named constants.
160173
// Values shared across Python/TypeScript belong in contracts/constants.json
161174
// (see contracts/constants.md). Baseline cleaned in #312; blocking like cli/.

cdk/src/bootstrap/index.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

cdk/src/handlers/confirm-uploads.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,9 @@ export async function handler(event: APIGatewayProxyEvent, context: Context): Pr
294294

295295
async function screenSingleAttachment(
296296
att: AttachmentRecord,
297-
task: TaskRecord,
297+
_task: TaskRecord,
298298
screeningConfig: ScreeningConfig,
299-
taskId: string,
299+
_taskId: string,
300300
meta: S3ObjectMeta,
301301
): Promise<AttachmentRecord> {
302302
const { s3Key, versionId, sizeBytes } = meta;
@@ -530,7 +530,7 @@ async function transitionToSubmitted(
530530
// ---------------------------------------------------------------------------
531531

532532
async function failTaskOnScreening(
533-
task: TaskRecord,
533+
_task: TaskRecord,
534534
taskId: string,
535535
filename: string,
536536
reason: string,

cdk/src/handlers/shared/create-task-core.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
type TaskRecord,
5454
toTaskDetail,
5555
} from './types';
56-
import { computeTtlEpoch, DEFAULT_MAX_TURNS, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, MAX_ATTACHMENT_SIZE_BYTES, MAX_TASK_DESCRIPTION_LENGTH, validateAttachments, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber } from './validation';
56+
import { computeTtlEpoch, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, MAX_ATTACHMENT_SIZE_BYTES, MAX_TASK_DESCRIPTION_LENGTH, validateAttachments, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber } from './validation';
5757
import { disallowedWorkflowModel, getWorkflowDescriptor, isValidWorkflowRef, resolveWorkflowRef, resolveWorkflowRefError } from './workflows';
5858
import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../../constructs/attachments-bucket';
5959
import { TaskStatus } from '../../constructs/task-status';
@@ -522,7 +522,6 @@ export async function createTaskCore(
522522
if (att.delivery !== 'presigned') continue;
523523
const presignedAtt = att as PresignedAttachment;
524524
const attachmentId = ulid();
525-
const s3Key = `${ATTACHMENT_OBJECT_KEY_PREFIX}${context.userId}/${taskId}/${attachmentId}/${presignedAtt.filename}`;
526525

527526
attachmentRecords.push(createAttachmentRecord({
528527
attachment_id: attachmentId,

cdk/src/handlers/shared/linear-verify.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ import { logger } from './logger';
2828
const sm = new SecretsManagerClient({});
2929
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
3030

31-
/** Prefix for Linear-related secrets in Secrets Manager. */
32-
export const LINEAR_SECRET_PREFIX = 'bgagent/linear/';
33-
3431
// In-memory secret cache with 5-minute TTL (same pattern as slack-verify.ts).
3532
const secretCache = new Map<string, { secret: string; expiresAt: number }>();
3633
const CACHE_TTL_MINUTES = 5;

0 commit comments

Comments
 (0)