From 2f3ad0bddf16c2b26bee266961f23192a1ddde6e Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 11:39:29 -0700 Subject: [PATCH 01/10] fix: correct invalid GraphQL escape sequences in batch_create_issues Replace `\$` with `$` at two sites in _cmd_rpc_issues.py. Python silently drops the backslash at runtime (producing valid GraphQL accidentally), but the source emits SyntaxWarning on 3.13+ and will become SyntaxError in a future release. `$` needs no escaping in Python f-strings. Refs #4062 --- src/autoskillit/recipe/_cmd_rpc_issues.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/recipe/_cmd_rpc_issues.py b/src/autoskillit/recipe/_cmd_rpc_issues.py index 72f14ae89d..524e4cc3d1 100644 --- a/src/autoskillit/recipe/_cmd_rpc_issues.py +++ b/src/autoskillit/recipe/_cmd_rpc_issues.py @@ -290,7 +290,7 @@ def batch_create_issues( for idx, (title, body, _) in enumerate(chunk): alias = f"issue{idx}" mutation_parts.append( - f"{alias}: createIssue(input: \$i{idx}) {{ issue {{ number url }} }}" + f"{alias}: createIssue(input: $i{idx}) {{ issue {{ number url }} }}" ) variables[f"i{idx}"] = { "repositoryId": repo_id, @@ -300,7 +300,7 @@ def batch_create_issues( } mutation = ( "mutation(" - + ",".join(f"\$i{k}: CreateIssueInput!" for k in range(len(chunk))) + + ",".join(f"$i{k}: CreateIssueInput!" for k in range(len(chunk))) + ") {" + " ".join(mutation_parts) + "}" From 29ffc1156b236aa6b19415776a50dee3909d07a6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 11:42:05 -0700 Subject: [PATCH 02/10] feat: add lint and structural guards for GraphQL mutation construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three complementary defenses against GraphQL construction defects in batch_create_issues and refetch_issues: 1. Ruff W category (W605 invalid-escape-sequence) — source-text gate that fires in pre-commit and CI for any new invalid escape sequence. 2. Pytest filterwarnings: error::SyntaxWarning — compile-time gate that fails the test suite at import time if any module has invalid escapes, even those not covered by specific GraphQL tests. 3. _validate_mutation_variables runtime helper — asserts that every key in the variables dict is both declared and referenced in the mutation string. Guards against variable-name drift and the ruff W605 raw-string auto-fix trap (if ruff converts $ to rf"\$", the runtime string would contain literal backslash-dollar and this check would fail). Also adds source-text lint test and strengthens existing GraphQL test assertions with structural form checks (starts-with-mutation, variable declaration/reference assertions). Refs #4062 --- pyproject.toml | 6 ++- src/autoskillit/recipe/_cmd_rpc_issues.py | 18 ++++++++ tests/recipe/test_cmd_rpc.py | 56 ++++++++++++++++++++--- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae4af634ba..824e5b91cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,10 @@ markers = [ "large: full integration — everything allowed (default for unannotated tests)", "feature(name): marks tests requiring a specific feature gate to be enabled", ] +filterwarnings = [ + "error::SyntaxWarning", + "error::DeprecationWarning:autoskillit", +] [tool.coverage.run] parallel = true @@ -120,7 +124,7 @@ target-version = "py311" line-length = 99 [tool.ruff.lint] -select = ["E", "F", "I", "UP", "TID251"] +select = ["E", "F", "I", "UP", "TID251", "W"] [tool.ruff.lint.per-file-ignores] "tests/execution/test_session_classification_e2e.py" = ["E402"] diff --git a/src/autoskillit/recipe/_cmd_rpc_issues.py b/src/autoskillit/recipe/_cmd_rpc_issues.py index 524e4cc3d1..5f20a9c4d9 100644 --- a/src/autoskillit/recipe/_cmd_rpc_issues.py +++ b/src/autoskillit/recipe/_cmd_rpc_issues.py @@ -115,6 +115,23 @@ def export_local_bundle( # ─── batch_create_issues helpers ──────────────────────────────────────────── +def _validate_mutation_variables(mutation: str, variables: dict[str, object]) -> None: + r"""Assert that mutation variable declarations match the variables dict. + + Guards against: (1) variable-name drift between the mutation template and + the variables dict, and (2) the ruff W605 raw-string auto-fix trap — if + ruff converts f"$i{k}" to rf"\$i{k}", the runtime string contains literal + backslash-dollar and this check fails because "$i0:" is absent. + """ + for key in variables: + decl = f"${key}:" + ref = f"${key})" + if decl not in mutation: + raise ValueError(f"Variable ${key} in variables dict but not declared in mutation") + if ref not in mutation: + raise ValueError(f"Variable ${key} declared but not referenced in mutation body") + + def _extract_title(raw: str) -> str: """Return the text following '# ' from the first H1 line, or a fallback.""" m = re.search(r"^#\s+(.+)$", raw, re.MULTILINE) @@ -305,6 +322,7 @@ def batch_create_issues( + " ".join(mutation_parts) + "}" ) + _validate_mutation_variables(mutation, variables) payload = json.dumps({"query": mutation, "variables": variables}) result = run_gh(["api", "graphql", "--input", "-"], cwd=workspace, input_data=payload) if result.returncode != 0: diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index 446396ed64..69a2c39b64 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -442,6 +442,7 @@ def test_refetch_issues_builds_query(): call_args = mock_run_gh.call_args[0][0] assert "graphql" in call_args query_arg = next(a for a in call_args if a.startswith("query=")) + assert query_arg.startswith("query={") assert "org" in query_arg assert "repo" in query_arg assert "issue(number: 1)" in query_arg @@ -597,8 +598,12 @@ def test_batch_create_issues_constructs_graphql_mutation(tmp_path): mutation_call = json.loads(kwargs["input_data"]) query = mutation_call["query"] variables = mutation_call["variables"] + assert query.startswith("mutation(") or query.startswith("{") assert "issue0: createIssue" in query assert "issue1: createIssue" in query + for var_name in variables: + assert f"${var_name}:" in query, f"Variable ${var_name} not declared in mutation" + assert f"${var_name})" in query, f"Variable ${var_name} not referenced in mutation" assert variables["i0"]["repositoryId"] == "R_123" assert variables["i1"]["repositoryId"] == "R_123" assert variables["i0"]["labelIds"] == ["L_1", "L_2"] @@ -608,6 +613,19 @@ def test_batch_create_issues_constructs_graphql_mutation(tmp_path): assert found, "no createIssue mutation call found in mock_run_gh calls" +def test_validate_mutation_variables_catches_mismatch(): + from autoskillit.recipe._cmd_rpc_issues import _validate_mutation_variables + + with pytest.raises(ValueError, match="not declared"): + _validate_mutation_variables("mutation() { ... }", {"i0": {}}) + with pytest.raises(ValueError, match="not referenced"): + _validate_mutation_variables("mutation($i0: CreateIssueInput!) { }", {"i0": {}}) + _validate_mutation_variables( + "mutation($i0: CreateIssueInput!) { issue0: createIssue(input: $i0) { ... } }", + {"i0": {}}, + ) + + def test_batch_create_issues_chunks_large_batches(tmp_path): va_dir = tmp_path / ".autoskillit" / "temp" / "validate-audit" va_dir.mkdir(parents=True) @@ -693,12 +711,19 @@ def side_effect(_args, **_kwargs): mock_run_gh.side_effect = side_effect_factory() result = batch_create_issues(workspace=str(tmp_path), chunk_size="10") - mutation_calls = sum( - 1 - for call in mock_run_gh.call_args_list - if call[1].get("input_data") and "createIssue" in call[1]["input_data"] - ) - assert mutation_calls == 3 + mutation_calls = [] + for call in mock_run_gh.call_args_list: + kwargs = call[1] + if kwargs.get("input_data") and "createIssue" in kwargs["input_data"]: + mutation_call = json.loads(kwargs["input_data"]) + query = mutation_call["query"] + variables = mutation_call["variables"] + assert query.startswith("mutation(") or query.startswith("{") + for var_name in variables: + assert f"${var_name}:" in query, f"Variable ${var_name} not declared in mutation" + assert f"${var_name})" in query, f"Variable ${var_name} not referenced in mutation" + mutation_calls.append(mutation_call) + assert len(mutation_calls) == 3 assert result["issue_count"] == "25" @@ -1326,3 +1351,22 @@ def _small_per_file(args: list[str], **kwargs): ): result = _check_regression(str(tmp_path), files_small, "main", file_status_small) assert result is None, f"Expected None (aggregate > 10 but per-file all < 5) but got: {result}" + + +def test_cmd_rpc_issues_no_invalid_escape_sequences(): + """Source-level guard: invalid escapes must not appear in GraphQL builders.""" + result = subprocess.run( + [ + "ruff", + "check", + "--select", + "W605", + "--output-format", + "json", + "src/autoskillit/recipe/_cmd_rpc_issues.py", + ], + capture_output=True, + text=True, + ) + findings = json.loads(result.stdout) if result.stdout.strip() else [] + assert findings == [], f"Invalid escape sequences found: {findings}" From 70582b9ffdc436af30d345b5ae59bad28c53ceef Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 11:52:57 -0700 Subject: [PATCH 03/10] fix: prevent filterwarnings DeprecationWarning promotion from breaking headless guards The error::DeprecationWarning:autoskillit filter promotes the operational DeprecationWarning from session_type() into an exception when HEADLESS=1 is set without SESSION_TYPE. The guards only caught ValueError, causing headless_error responses to become unhandled tool_exception responses. - Add filterwarnings exclusion for the session_type() DeprecationWarning - Broaden guard except clauses to catch (ValueError, DeprecationWarning) as defense-in-depth for fail-closed behavior Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 1 + src/autoskillit/server/_guards.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 824e5b91cd..72a717a399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,7 @@ markers = [ filterwarnings = [ "error::SyntaxWarning", "error::DeprecationWarning:autoskillit", + "default:AUTOSKILLIT_HEADLESS=1 without:DeprecationWarning", ] [tool.coverage.run] diff --git a/src/autoskillit/server/_guards.py b/src/autoskillit/server/_guards.py index a2bdfa5086..97def2b479 100644 --- a/src/autoskillit/server/_guards.py +++ b/src/autoskillit/server/_guards.py @@ -48,7 +48,7 @@ def _require_orchestrator_or_higher(tool_name: str = "") -> str | None: try: st = session_type() - except ValueError as e: + except (ValueError, DeprecationWarning) as e: return headless_error_result(f"{tool_name}: {e}" if tool_name else str(e)) if st in (SessionType.ORCHESTRATOR, SessionType.FLEET): return None @@ -74,7 +74,7 @@ def _require_orchestrator_exact(tool_name: str = "") -> str | None: try: st = session_type() - except ValueError as e: + except (ValueError, DeprecationWarning) as e: return headless_error_result(f"{tool_name}: {e}" if tool_name else str(e)) if st is SessionType.ORCHESTRATOR: return None @@ -105,7 +105,7 @@ def _require_fleet(tool_name: str = "") -> str | None: """ try: st = session_type() - except ValueError as e: + except (ValueError, DeprecationWarning) as e: return headless_error_result(f"{tool_name}: {e}" if tool_name else str(e)) if st is SessionType.FLEET: return None From eaf52864af59f8ae79c6146853f73a6d630deeb0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 12:16:06 -0700 Subject: [PATCH 04/10] test: add structural assertion for repository(owner: in refetch_issues GraphQL query This adds a structural guard beyond the existing weak substring checks ('org' in query_arg). The new assertion validates that the GraphQL query produced by refetch_issues contains the 'repository(owner:' field syntax, providing coverage that would fail if the query structure broke while the weaker 'org' substring still passed. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/recipe/test_cmd_rpc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index 69a2c39b64..cfc8605865 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -443,6 +443,7 @@ def test_refetch_issues_builds_query(): assert "graphql" in call_args query_arg = next(a for a in call_args if a.startswith("query=")) assert query_arg.startswith("query={") + assert "repository(owner:" in query_arg assert "org" in query_arg assert "repo" in query_arg assert "issue(number: 1)" in query_arg From 17330b1c852bc34580faa5dd15e42f76b07b4c0c Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 12:50:34 -0700 Subject: [PATCH 05/10] fix(review): remove dead DeprecationWarning catch from session-type guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session_type() uses warnings.warn() — DeprecationWarning is never raised as an exception in production. The filterwarnings suppression in pyproject.toml (added in 70582b9f) handles the test-suite promotion path. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/autoskillit/server/_guards.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/server/_guards.py b/src/autoskillit/server/_guards.py index 97def2b479..a2bdfa5086 100644 --- a/src/autoskillit/server/_guards.py +++ b/src/autoskillit/server/_guards.py @@ -48,7 +48,7 @@ def _require_orchestrator_or_higher(tool_name: str = "") -> str | None: try: st = session_type() - except (ValueError, DeprecationWarning) as e: + except ValueError as e: return headless_error_result(f"{tool_name}: {e}" if tool_name else str(e)) if st in (SessionType.ORCHESTRATOR, SessionType.FLEET): return None @@ -74,7 +74,7 @@ def _require_orchestrator_exact(tool_name: str = "") -> str | None: try: st = session_type() - except (ValueError, DeprecationWarning) as e: + except ValueError as e: return headless_error_result(f"{tool_name}: {e}" if tool_name else str(e)) if st is SessionType.ORCHESTRATOR: return None @@ -105,7 +105,7 @@ def _require_fleet(tool_name: str = "") -> str | None: """ try: st = session_type() - except (ValueError, DeprecationWarning) as e: + except ValueError as e: return headless_error_result(f"{tool_name}: {e}" if tool_name else str(e)) if st is SessionType.FLEET: return None From 21c3d614284ebc87668d4b2efa41bc19b5039d04 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 12:50:56 -0700 Subject: [PATCH 06/10] fix(review): anchor ruff test path to __file__ and add returncode guard Use Path(__file__)-relative resolution instead of hardcoded relative path to prevent silent pass under xdist CWD variance. Assert ruff exits 0 or 1 to catch tool failures that would otherwise produce empty findings. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/recipe/test_cmd_rpc.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index cfc8605865..8b638722b3 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -1356,6 +1356,14 @@ def _small_per_file(args: list[str], **kwargs): def test_cmd_rpc_issues_no_invalid_escape_sequences(): """Source-level guard: invalid escapes must not appear in GraphQL builders.""" + target = ( + Path(__file__).resolve().parent.parent.parent + / "src" + / "autoskillit" + / "recipe" + / "_cmd_rpc_issues.py" + ) + assert target.is_file(), f"Target file not found: {target}" result = subprocess.run( [ "ruff", @@ -1364,10 +1372,11 @@ def test_cmd_rpc_issues_no_invalid_escape_sequences(): "W605", "--output-format", "json", - "src/autoskillit/recipe/_cmd_rpc_issues.py", + str(target), ], capture_output=True, text=True, ) + assert result.returncode in (0, 1), f"ruff failed (exit {result.returncode}): {result.stderr}" findings = json.loads(result.stdout) if result.stdout.strip() else [] assert findings == [], f"Invalid escape sequences found: {findings}" From ae174287f5272db24512c76118edc05b53f01d55 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 13:01:11 -0700 Subject: [PATCH 07/10] fix(review): tighten mutation assertion to match actual production output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the dead `or query.startswith("{")` branch from the assertion in test_batch_create_issues_constructs_graphql_mutation. The production code always emits `mutation(...)` form — the `{` branch was unreachable and weakened the structural guard. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/recipe/test_cmd_rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index 8b638722b3..d6876e4755 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -599,7 +599,7 @@ def test_batch_create_issues_constructs_graphql_mutation(tmp_path): mutation_call = json.loads(kwargs["input_data"]) query = mutation_call["query"] variables = mutation_call["variables"] - assert query.startswith("mutation(") or query.startswith("{") + assert query.startswith("mutation(") assert "issue0: createIssue" in query assert "issue1: createIssue" in query for var_name in variables: From c7c60d02927cf9b60f6df68fe3caaccb5bbe5bd0 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 13:01:32 -0700 Subject: [PATCH 08/10] fix(review): tighten chunked mutation assertion to match production output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the dead `or query.startswith("{")` branch from the assertion in test_batch_create_issues_chunks_large_batches. Same fix as the prior commit — production always emits `mutation(...)` form. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/recipe/test_cmd_rpc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index d6876e4755..2813f3aab1 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -719,7 +719,7 @@ def side_effect(_args, **_kwargs): mutation_call = json.loads(kwargs["input_data"]) query = mutation_call["query"] variables = mutation_call["variables"] - assert query.startswith("mutation(") or query.startswith("{") + assert query.startswith("mutation(") for var_name in variables: assert f"${var_name}:" in query, f"Variable ${var_name} not declared in mutation" assert f"${var_name})" in query, f"Variable ${var_name} not referenced in mutation" From 4935a6a6557ea8b895f7478da96610fba6686828 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 13:01:52 -0700 Subject: [PATCH 09/10] fix(review): add returncode==0 guard after empty findings check If ruff exits 1 (violations found) but stdout is empty, the prior logic set findings=[] and the test passed falsely. Add a post-check assertion that returncode must be 0 when no findings were parsed, catching the empty-stdout-on-exit-1 edge case. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/recipe/test_cmd_rpc.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index 2813f3aab1..2c214cc362 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -1380,3 +1380,6 @@ def test_cmd_rpc_issues_no_invalid_escape_sequences(): assert result.returncode in (0, 1), f"ruff failed (exit {result.returncode}): {result.stderr}" findings = json.loads(result.stdout) if result.stdout.strip() else [] assert findings == [], f"Invalid escape sequences found: {findings}" + assert result.returncode == 0, ( + f"ruff exited {result.returncode} but produced no JSON findings: {result.stderr}" + ) From 42a9e67386c0288c5c333f9b4a87de886d18e228 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 11 Jun 2026 13:18:36 -0700 Subject: [PATCH 10/10] fix(review): resolve ruff from venv bin dir in escape-sequence lint test The test called bare "ruff" which isn't on PATH in CI's subprocess environment. Resolve the ruff binary from sys.executable's parent directory (the venv bin/) and skip if unavailable. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/recipe/test_cmd_rpc.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/recipe/test_cmd_rpc.py b/tests/recipe/test_cmd_rpc.py index 2c214cc362..02f4c16209 100644 --- a/tests/recipe/test_cmd_rpc.py +++ b/tests/recipe/test_cmd_rpc.py @@ -5,6 +5,7 @@ import json import os import subprocess +import sys from pathlib import Path from unittest.mock import patch @@ -1356,6 +1357,9 @@ def _small_per_file(args: list[str], **kwargs): def test_cmd_rpc_issues_no_invalid_escape_sequences(): """Source-level guard: invalid escapes must not appear in GraphQL builders.""" + ruff = Path(sys.executable).resolve().parent / "ruff" + if not ruff.is_file(): + pytest.skip("ruff not found in venv bin directory") target = ( Path(__file__).resolve().parent.parent.parent / "src" @@ -1366,7 +1370,7 @@ def test_cmd_rpc_issues_no_invalid_escape_sequences(): assert target.is_file(), f"Target file not found: {target}" result = subprocess.run( [ - "ruff", + str(ruff), "check", "--select", "W605",