Skip to content

Commit d97b780

Browse files
krokokobgagentcursoragent
authored
feat(ci): no-magic-numbers (eslint) + PLR2004 (ruff) with allowlist (#258) (#310)
* feat(ci): no-magic-numbers (eslint) + PLR2004 (ruff) with allowlist (#258) Inline magic numbers are a classic AI-generated-code smell (AI007): locally plausible, globally inconsistent. Add lint gates that steer hard-coded values into named constants — or contracts/constants.json when they must agree across languages: - cli: @typescript-eslint/no-magic-numbers as blocking 'error'; baseline fixed (20 violations promoted to named constants). - cdk: same rule as advisory 'warn' (162 baseline hits); flips to 'error' in a follow-up once the baseline is clean. - agent: ruff PLR2004 blocking; baseline fixed (5 violations). - Shared allowlist: 0/1/-1/2, HTTP status codes, radix and unit-conversion factors. Tests are exempt in all three packages. - max_budget_usd bounds (0.01–100) promoted to contracts/constants.json — they were duplicated as literals in cdk validation.ts and cli submit.ts, exactly the drift this contract exists to prevent. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): promote post-merge magic number 72 to BANNER_WIDTH (#258) PR #241 merged into main with a literal banner width in the new `bgagent github` command, tripping the no-magic-numbers rule this branch enables. Same constant name as linear.ts. --------- Co-authored-by: bgagent <bgagent@noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 82270e0 commit d97b780

23 files changed

Lines changed: 189 additions & 33 deletions

agent/pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ select = [
8383
"SIM", # flake8-simplify
8484
"TCH", # flake8-type-checking
8585
"RUF", # ruff-specific rules
86+
# AI007 guard (#258): magic-value comparisons should use named constants.
87+
# Values shared across Python/TypeScript belong in contracts/constants.json
88+
# (see contracts/constants.md).
89+
"PLR2004", # pylint magic-value-comparison
8690
]
8791
ignore = [
8892
"S603", # subprocess call — allowed for agent CLI operations
@@ -93,7 +97,7 @@ ignore = [
9397
"src/entrypoint.py" = ["E402"] # re-export shim: importlib.reload() call must precede re-export from-imports
9498
"src/system_prompt.py" = ["E501"] # long prompt strings
9599
"src/prompts/*.py" = ["E501"] # long prompt strings
96-
"tests/**" = ["S101", "S106", "S108", "E402"] # assert; test tokens; /tmp paths; importorskip
100+
"tests/**" = ["S101", "S106", "S108", "E402", "PLR2004"] # assert; test tokens; /tmp paths; importorskip; literal assertions
97101

98102
[tool.pytest.ini_options]
99103
testpaths = ["tests"]

agent/src/hooks.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -972,6 +972,11 @@ async def post_tool_use_hook(
972972
# leak across tasks in the same runtime.
973973
_INJECTED_NUDGES: dict[str, set[str]] = {}
974974

975+
# Preview length for the first nudge message in the milestone details
976+
# string. Kept short so the whole details line stays under ~120 chars
977+
# (single terminal line in ``bgagent watch``).
978+
_NUDGE_PREVIEW_LEN = 60
979+
975980

976981
def _reset_injected_nudges_for_tests() -> None:
977982
"""Test-only helper to clear the in-process injected-nudge dedup set."""
@@ -1098,10 +1103,10 @@ def _nudge_between_turns_hook(ctx: dict) -> list[str]:
10981103
# Short details string for the stream — preview the first nudge, total
10991104
# count, and the nudge IDs for traceability. Kept under ~120 chars so
11001105
# it fits on a single terminal line.
1101-
first_msg = (pending[0].get("message") or "")[:60]
1106+
first_msg = (pending[0].get("message") or "")[:_NUDGE_PREVIEW_LEN]
11021107
ids = ",".join(str(n.get("nudge_id", ""))[-8:] for n in pending)
11031108
details = f"{count} nudge(s) acknowledged (ids=…{ids}): {first_msg}" + (
1104-
"…" if count > 1 or len(first_msg) == 60 else ""
1109+
"…" if count > 1 or len(first_msg) == _NUDGE_PREVIEW_LEN else ""
11051110
)
11061111
# AD-5: emit the ack BEFORE returning the injection list.
11071112
_emit_nudge_milestone(ctx, "nudge_acknowledged", details)

agent/src/linear_reactions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import os
2929
import threading
3030
import time
31+
from http import HTTPStatus
3132
from typing import Any
3233

3334
import requests
@@ -177,7 +178,7 @@ def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
177178
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear (auth)")
178179
return None
179180

180-
if resp.status_code != 200:
181+
if resp.status_code != HTTPStatus.OK:
181182
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear")
182183
return None
183184

agent/src/models.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ class MemoryContext(BaseModel):
4747
# Attachment types — mirrors AttachmentType in cdk/src/handlers/shared/types.ts.
4848
AttachmentType = Literal["image", "file", "url"]
4949

50+
# A SHA-256 digest rendered as lowercase hex is always 64 characters.
51+
SHA256_HEX_LEN = 64
52+
5053

5154
class AttachmentConfig(BaseModel):
5255
"""Attachment descriptor from the orchestrator — mirrors AgentAttachmentPayload in types.ts."""
@@ -71,7 +74,7 @@ def _validate_integrity_fields(self) -> Self:
7174
if not self.checksum_sha256:
7275
raise ValueError("checksum_sha256 is required for integrity verification")
7376
# checksum must be lowercase hex (SHA-256 = 64 hex chars)
74-
if len(self.checksum_sha256) != 64 or not all(
77+
if len(self.checksum_sha256) != SHA256_HEX_LEN or not all(
7578
c in "0123456789abcdef" for c in self.checksum_sha256
7679
):
7780
raise ValueError("checksum_sha256 must be a 64-character lowercase hex string")

agent/src/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@
4242
_debug_cw_failures_lock = threading.Lock()
4343
_DEBUG_CW_FAILURE_EMIT_EVERY = 5
4444

45+
# Only redact secrets at least this long — replacing very short strings
46+
# would mangle unrelated text that happens to contain them.
47+
_MIN_REDACTABLE_SECRET_LEN = 12
48+
4549

4650
def _redact_cached_credentials(text: str) -> str:
4751
"""Remove cached env secrets from debug text before stdout / CloudWatch."""
4852
out = text
4953
for env_key in ("GITHUB_TOKEN", "LINEAR_API_TOKEN"):
5054
secret = os.environ.get(env_key) or ""
51-
if len(secret) >= 12:
55+
if len(secret) >= _MIN_REDACTABLE_SECRET_LEN:
5256
out = out.replace(secret, f"<{env_key}_REDACTED>")
5357
return out
5458

agent/src/telemetry.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ def get_disk_usage(path: str = AGENT_WORKSPACE) -> float:
3030
return 0
3131

3232

33+
_BYTES_PER_UNIT = 1024
34+
35+
3336
def format_bytes(size: float) -> str:
3437
"""Human-readable byte size."""
3538
for unit in ("B", "KB", "MB", "GB"):
36-
if abs(size) < 1024:
39+
if abs(size) < _BYTES_PER_UNIT:
3740
return f"{size:.1f} {unit}"
38-
size /= 1024
41+
size /= _BYTES_PER_UNIT
3942
return f"{size:.1f} TB"
4043

4144

cdk/eslint.config.mjs

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

158158
// TypeScript rules
159+
// AI007 guard (#258): inline numeric literals should be named constants.
160+
// Values shared across Python/TypeScript belong in contracts/constants.json
161+
// (see contracts/constants.md). Advisory ('warn') until the existing
162+
// baseline is cleaned up, then this becomes 'error' like in cli/.
163+
'@typescript-eslint/no-magic-numbers': ['warn', {
164+
ignore: [
165+
// Identity / trivial arithmetic
166+
-1, 0, 1, 2,
167+
// Radix, percentages, and well-known unit conversions
168+
10, 24, 60, 100, 1000, 1024, 3600, 86400,
169+
// HTTP status codes (600 = exclusive upper bound for 5xx checks)
170+
200, 201, 202, 204, 301, 302, 304,
171+
400, 401, 403, 404, 409, 422, 429,
172+
500, 502, 503, 504, 600,
173+
],
174+
ignoreEnums: true,
175+
ignoreNumericLiteralTypes: true,
176+
ignoreReadonlyClassProperties: true,
177+
ignoreTypeIndexes: true,
178+
ignoreDefaultValues: true,
179+
ignoreClassFieldInitialValues: true,
180+
ignoreArrayIndexes: true,
181+
}],
159182
'@typescript-eslint/no-require-imports': 'error',
160183
'@typescript-eslint/no-shadow': 'error',
161184
'@typescript-eslint/no-floating-promises': ['error'],
@@ -219,4 +242,12 @@ export default [
219242
'jest/no-focused-tests': 'error',
220243
},
221244
},
245+
246+
// Override: tests legitimately use inline literals (fixtures, assertions)
247+
{
248+
files: ['test/**/*.ts'],
249+
rules: {
250+
'@typescript-eslint/no-magic-numbers': 'off',
251+
},
252+
},
222253
];

cdk/src/handlers/shared/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,3 +1078,12 @@ export const APPROVAL_TIMEOUT_S_DEFAULT = sharedConstants.approval_timeout_s.def
10781078
export const APPROVAL_GATE_CAP_MIN = sharedConstants.approval_gate_cap.min;
10791079
export const APPROVAL_GATE_CAP_MAX = sharedConstants.approval_gate_cap.max;
10801080
export const APPROVAL_GATE_CAP_DEFAULT = sharedConstants.approval_gate_cap.default;
1081+
1082+
/** Minimum allowed `max_budget_usd` (1 cent). The CLI pre-validates with the
1083+
* same bound (`bgagent submit --max-budget`), so it lives in
1084+
* ``contracts/constants.json`` rather than as a local literal (#258). */
1085+
export const MAX_BUDGET_USD_MIN = sharedConstants.max_budget_usd.min;
1086+
1087+
/** Maximum allowed `max_budget_usd` ($100).
1088+
* Sourced from ``contracts/constants.json`` (#258). */
1089+
export const MAX_BUDGET_USD_MAX = sharedConstants.max_budget_usd.max;

cdk/src/handlers/shared/validation.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
type InlineAttachment,
2525
type PresignedAttachment,
2626
type UrlAttachment,
27+
MAX_BUDGET_USD_MIN,
28+
MAX_BUDGET_USD_MAX,
2729
} from './types';
2830
import { type WorkflowRequiredInputs } from './workflows';
2931
import { TaskStatus } from '../../constructs/task-status';
@@ -36,10 +38,6 @@ export const MIN_MAX_TURNS = 1;
3638
export const MAX_MAX_TURNS = 500;
3739
/** Maximum allowed length for task_description. */
3840
export const MAX_TASK_DESCRIPTION_LENGTH = 10_000;
39-
/** Minimum allowed value for max_budget_usd (1 cent). */
40-
export const MIN_MAX_BUDGET_USD = 0.01;
41-
/** Maximum allowed value for max_budget_usd ($100). */
42-
export const MAX_MAX_BUDGET_USD = 100;
4341

4442
const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
4543
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/;
@@ -223,7 +221,7 @@ export function validateMaxTurns(value: unknown): number | null | undefined {
223221
export function validateMaxBudgetUsd(value: unknown): number | null | undefined {
224222
if (value === undefined || value === null) return undefined;
225223
if (typeof value !== 'number') return null;
226-
if (value < MIN_MAX_BUDGET_USD || value > MAX_MAX_BUDGET_USD) return null;
224+
if (value < MAX_BUDGET_USD_MIN || value > MAX_BUDGET_USD_MAX) return null;
227225
return value;
228226
}
229227

cli/eslint.config.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,28 @@ export default [
153153
}],
154154

155155
// TypeScript rules
156+
// AI007 guard (#258): inline numeric literals should be named constants.
157+
// Values shared across Python/TypeScript belong in contracts/constants.json
158+
// (see contracts/constants.md).
159+
'@typescript-eslint/no-magic-numbers': ['error', {
160+
ignore: [
161+
// Identity / trivial arithmetic
162+
-1, 0, 1, 2,
163+
// Radix, percentages, and well-known unit conversions
164+
10, 24, 60, 100, 1000, 1024, 3600, 86400,
165+
// HTTP status codes (600 = exclusive upper bound for 5xx checks)
166+
200, 201, 202, 204, 301, 302, 304,
167+
400, 401, 403, 404, 409, 422, 429,
168+
500, 502, 503, 504, 600,
169+
],
170+
ignoreEnums: true,
171+
ignoreNumericLiteralTypes: true,
172+
ignoreReadonlyClassProperties: true,
173+
ignoreTypeIndexes: true,
174+
ignoreDefaultValues: true,
175+
ignoreClassFieldInitialValues: true,
176+
ignoreArrayIndexes: true,
177+
}],
156178
'@typescript-eslint/no-require-imports': 'error',
157179
'@typescript-eslint/no-shadow': 'error',
158180
'@typescript-eslint/no-floating-promises': ['error'],
@@ -215,6 +237,14 @@ export default [
215237
},
216238
},
217239

240+
// Override: tests and build tooling legitimately use inline literals
241+
{
242+
files: ['test/**/*.ts', 'build-tools/**/*.ts'],
243+
rules: {
244+
'@typescript-eslint/no-magic-numbers': 'off',
245+
},
246+
},
247+
218248
// Override: shebang files can't have the license header at line 1
219249
{
220250
files: ['src/bin/**/*.ts'],

0 commit comments

Comments
 (0)