Skip to content

Commit 75a9fce

Browse files
Trecekclaude
andauthored
[FEATURE] GraphQL Escape Sequence Lint Gap and Untested Mutation Syntax (#4073)
## Summary Two invalid Python escape sequences (`\$`) in `_cmd_rpc_issues.py` emit `SyntaxWarning` on Python 3.13 and will become `SyntaxError` in a future Python release. The fix corrects the escape sequences, adds ruff `W605` (invalid-escape-sequence) and pytest `filterwarnings` gates, introduces runtime structural validation for GraphQL mutation variable declarations, and strengthens existing test assertions with forward-looking structural checks. This is a four-step immunity approach: (1) fix the `\$` escapes at lines 293 and 303, (2) add lint and pytest warning gates to pyproject.toml and guard infrastructure, (3) add runtime structural validation for mutation variable declarations, and (4) strengthen existing test assertions to validate variable declaration syntax and absence of backslash artifacts. ## Requirements `autoskillit order` sessions print compile-time warnings to the user's terminal on first import: ``` .../autoskillit/recipe/_cmd_rpc_issues.py:293: SyntaxWarning: invalid escape sequence '\$' f"{alias}: createIssue(input: \$i{idx}) {{ issue {{ number url }} }}" .../autoskillit/recipe/_cmd_rpc_issues.py:303: SyntaxWarning: invalid escape sequence '\$' + ",".join(f"\$i{k}: CreateIssueInput!" for k in range(len(chunk))) ``` `\$` is not a valid Python escape sequence. The backslash is silently dropped on current Python so the GraphQL is accidentally valid at runtime, but the code is formally incorrect and on a deprecation path to hard failure. Scope of fix: 1. Replace `\$` with `$` at both sites (lines 293 and 303). 2. Add `W605` to ruff `select` in pyproject.toml to catch invalid escape sequences. 3. Add `filterwarnings = ["error"]` for headless test gates. 4. Add runtime structural validation for mutation variable declarations. 5. Strengthen `test_batch_create_issues_constructs_graphql_mutation` with structural assertions for variable declaration/reference syntax. ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/remediation-20260611-105557-432256/.autoskillit/temp/rectify/rectify_graphql_escape_lint_gap_2026-06-11_110500.md` Closes #4062 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 829904e commit 75a9fce

3 files changed

Lines changed: 93 additions & 9 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ markers = [
110110
"large: full integration — everything allowed (default for unannotated tests)",
111111
"feature(name): marks tests requiring a specific feature gate to be enabled",
112112
]
113+
filterwarnings = [
114+
"error::SyntaxWarning",
115+
"error::DeprecationWarning:autoskillit",
116+
"default:AUTOSKILLIT_HEADLESS=1 without:DeprecationWarning",
117+
]
113118

114119
[tool.coverage.run]
115120
parallel = true
@@ -120,7 +125,7 @@ target-version = "py311"
120125
line-length = 99
121126

122127
[tool.ruff.lint]
123-
select = ["E", "F", "I", "UP", "TID251"]
128+
select = ["E", "F", "I", "UP", "TID251", "W"]
124129

125130
[tool.ruff.lint.per-file-ignores]
126131
"tests/execution/test_session_classification_e2e.py" = ["E402"]

src/autoskillit/recipe/_cmd_rpc_issues.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,23 @@ def export_local_bundle(
115115
# ─── batch_create_issues helpers ────────────────────────────────────────────
116116

117117

118+
def _validate_mutation_variables(mutation: str, variables: dict[str, object]) -> None:
119+
r"""Assert that mutation variable declarations match the variables dict.
120+
121+
Guards against: (1) variable-name drift between the mutation template and
122+
the variables dict, and (2) the ruff W605 raw-string auto-fix trap — if
123+
ruff converts f"$i{k}" to rf"\$i{k}", the runtime string contains literal
124+
backslash-dollar and this check fails because "$i0:" is absent.
125+
"""
126+
for key in variables:
127+
decl = f"${key}:"
128+
ref = f"${key})"
129+
if decl not in mutation:
130+
raise ValueError(f"Variable ${key} in variables dict but not declared in mutation")
131+
if ref not in mutation:
132+
raise ValueError(f"Variable ${key} declared but not referenced in mutation body")
133+
134+
118135
def _extract_title(raw: str) -> str:
119136
"""Return the text following '# ' from the first H1 line, or a fallback."""
120137
m = re.search(r"^#\s+(.+)$", raw, re.MULTILINE)
@@ -290,7 +307,7 @@ def batch_create_issues(
290307
for idx, (title, body, _) in enumerate(chunk):
291308
alias = f"issue{idx}"
292309
mutation_parts.append(
293-
f"{alias}: createIssue(input: \$i{idx}) {{ issue {{ number url }} }}"
310+
f"{alias}: createIssue(input: $i{idx}) {{ issue {{ number url }} }}"
294311
)
295312
variables[f"i{idx}"] = {
296313
"repositoryId": repo_id,
@@ -300,11 +317,12 @@ def batch_create_issues(
300317
}
301318
mutation = (
302319
"mutation("
303-
+ ",".join(f"\$i{k}: CreateIssueInput!" for k in range(len(chunk)))
320+
+ ",".join(f"$i{k}: CreateIssueInput!" for k in range(len(chunk)))
304321
+ ") {"
305322
+ " ".join(mutation_parts)
306323
+ "}"
307324
)
325+
_validate_mutation_variables(mutation, variables)
308326
payload = json.dumps({"query": mutation, "variables": variables})
309327
result = run_gh(["api", "graphql", "--input", "-"], cwd=workspace, input_data=payload)
310328
if result.returncode != 0:

tests/recipe/test_cmd_rpc.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import subprocess
8+
import sys
89
from pathlib import Path
910
from unittest.mock import patch
1011

@@ -442,6 +443,8 @@ def test_refetch_issues_builds_query():
442443
call_args = mock_run_gh.call_args[0][0]
443444
assert "graphql" in call_args
444445
query_arg = next(a for a in call_args if a.startswith("query="))
446+
assert query_arg.startswith("query={")
447+
assert "repository(owner:" in query_arg
445448
assert "org" in query_arg
446449
assert "repo" in query_arg
447450
assert "issue(number: 1)" in query_arg
@@ -597,8 +600,12 @@ def test_batch_create_issues_constructs_graphql_mutation(tmp_path):
597600
mutation_call = json.loads(kwargs["input_data"])
598601
query = mutation_call["query"]
599602
variables = mutation_call["variables"]
603+
assert query.startswith("mutation(")
600604
assert "issue0: createIssue" in query
601605
assert "issue1: createIssue" in query
606+
for var_name in variables:
607+
assert f"${var_name}:" in query, f"Variable ${var_name} not declared in mutation"
608+
assert f"${var_name})" in query, f"Variable ${var_name} not referenced in mutation"
602609
assert variables["i0"]["repositoryId"] == "R_123"
603610
assert variables["i1"]["repositoryId"] == "R_123"
604611
assert variables["i0"]["labelIds"] == ["L_1", "L_2"]
@@ -608,6 +615,19 @@ def test_batch_create_issues_constructs_graphql_mutation(tmp_path):
608615
assert found, "no createIssue mutation call found in mock_run_gh calls"
609616

610617

618+
def test_validate_mutation_variables_catches_mismatch():
619+
from autoskillit.recipe._cmd_rpc_issues import _validate_mutation_variables
620+
621+
with pytest.raises(ValueError, match="not declared"):
622+
_validate_mutation_variables("mutation() { ... }", {"i0": {}})
623+
with pytest.raises(ValueError, match="not referenced"):
624+
_validate_mutation_variables("mutation($i0: CreateIssueInput!) { }", {"i0": {}})
625+
_validate_mutation_variables(
626+
"mutation($i0: CreateIssueInput!) { issue0: createIssue(input: $i0) { ... } }",
627+
{"i0": {}},
628+
)
629+
630+
611631
def test_batch_create_issues_chunks_large_batches(tmp_path):
612632
va_dir = tmp_path / ".autoskillit" / "temp" / "validate-audit"
613633
va_dir.mkdir(parents=True)
@@ -693,12 +713,19 @@ def side_effect(_args, **_kwargs):
693713

694714
mock_run_gh.side_effect = side_effect_factory()
695715
result = batch_create_issues(workspace=str(tmp_path), chunk_size="10")
696-
mutation_calls = sum(
697-
1
698-
for call in mock_run_gh.call_args_list
699-
if call[1].get("input_data") and "createIssue" in call[1]["input_data"]
700-
)
701-
assert mutation_calls == 3
716+
mutation_calls = []
717+
for call in mock_run_gh.call_args_list:
718+
kwargs = call[1]
719+
if kwargs.get("input_data") and "createIssue" in kwargs["input_data"]:
720+
mutation_call = json.loads(kwargs["input_data"])
721+
query = mutation_call["query"]
722+
variables = mutation_call["variables"]
723+
assert query.startswith("mutation(")
724+
for var_name in variables:
725+
assert f"${var_name}:" in query, f"Variable ${var_name} not declared in mutation"
726+
assert f"${var_name})" in query, f"Variable ${var_name} not referenced in mutation"
727+
mutation_calls.append(mutation_call)
728+
assert len(mutation_calls) == 3
702729
assert result["issue_count"] == "25"
703730

704731

@@ -1326,3 +1353,37 @@ def _small_per_file(args: list[str], **kwargs):
13261353
):
13271354
result = _check_regression(str(tmp_path), files_small, "main", file_status_small)
13281355
assert result is None, f"Expected None (aggregate > 10 but per-file all < 5) but got: {result}"
1356+
1357+
1358+
def test_cmd_rpc_issues_no_invalid_escape_sequences():
1359+
"""Source-level guard: invalid escapes must not appear in GraphQL builders."""
1360+
ruff = Path(sys.executable).resolve().parent / "ruff"
1361+
if not ruff.is_file():
1362+
pytest.skip("ruff not found in venv bin directory")
1363+
target = (
1364+
Path(__file__).resolve().parent.parent.parent
1365+
/ "src"
1366+
/ "autoskillit"
1367+
/ "recipe"
1368+
/ "_cmd_rpc_issues.py"
1369+
)
1370+
assert target.is_file(), f"Target file not found: {target}"
1371+
result = subprocess.run(
1372+
[
1373+
str(ruff),
1374+
"check",
1375+
"--select",
1376+
"W605",
1377+
"--output-format",
1378+
"json",
1379+
str(target),
1380+
],
1381+
capture_output=True,
1382+
text=True,
1383+
)
1384+
assert result.returncode in (0, 1), f"ruff failed (exit {result.returncode}): {result.stderr}"
1385+
findings = json.loads(result.stdout) if result.stdout.strip() else []
1386+
assert findings == [], f"Invalid escape sequences found: {findings}"
1387+
assert result.returncode == 0, (
1388+
f"ruff exited {result.returncode} but produced no JSON findings: {result.stderr}"
1389+
)

0 commit comments

Comments
 (0)