mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
fix(lint): report malformed settings shapes without crashing (#146)
Valid JSON such as [] or {"permissions": []} caused lint_skills.py to raise AttributeError because it assumed both values were objects.
Validate the top-level settings value and permissions object before reading nested keys. Malformed settings now produce clear lint errors and exit 1 without a traceback.
Add subprocess regression tests covering invalid JSON, malformed root values, invalid permissions values, and non-list permissions.allow values.
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LINTER_SCRIPT = REPO_ROOT / "tools" / "lint_skills.py"
|
||||||
|
|
||||||
|
|
||||||
|
def run_linter(root: Path) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(root / "tools" / "lint_skills.py")],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LinterRepoFixture(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.root = Path(tempfile.mkdtemp())
|
||||||
|
self.addCleanup(shutil.rmtree, self.root, ignore_errors=True)
|
||||||
|
|
||||||
|
tools = self.root / "tools"
|
||||||
|
tools.mkdir()
|
||||||
|
shutil.copy(LINTER_SCRIPT, tools / "lint_skills.py")
|
||||||
|
# The Python-test CI job does not install PyYAML; the separate lint job
|
||||||
|
# does. These settings-focused tests only need a valid frontmatter map.
|
||||||
|
(tools / "yaml.py").write_text(
|
||||||
|
"class YAMLError(Exception):\n"
|
||||||
|
" pass\n\n"
|
||||||
|
"def safe_load(_text):\n"
|
||||||
|
" return {'name': 'example', 'description': 'Example skill'}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
command = self.root / ".claude" / "commands" / "setup.md"
|
||||||
|
command.parent.mkdir(parents=True)
|
||||||
|
command.write_text("# /setup - Test setup command\n", encoding="utf-8")
|
||||||
|
|
||||||
|
skill = self.root / ".claude" / "skills" / "example" / "SKILL.md"
|
||||||
|
skill.parent.mkdir(parents=True)
|
||||||
|
skill.write_text(
|
||||||
|
"---\nname: example\ndescription: Example skill\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.settings = self.root / ".claude" / "settings.json"
|
||||||
|
self.write_settings({"permissions": {"allow": []}})
|
||||||
|
|
||||||
|
def write_settings(self, data):
|
||||||
|
self.settings.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsShapeTests(LinterRepoFixture):
|
||||||
|
def test_valid_settings_pass(self):
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||||
|
self.assertIn("lint_skills: OK", result.stdout)
|
||||||
|
|
||||||
|
def test_invalid_json_fails_cleanly(self):
|
||||||
|
self.settings.write_text("{not json", encoding="utf-8")
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn(".claude/settings.json", result.stdout)
|
||||||
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
|
||||||
|
def test_non_object_root_fails_cleanly(self):
|
||||||
|
for data in ([], "settings", 1, None):
|
||||||
|
with self.subTest(data=data):
|
||||||
|
self.write_settings(data)
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("top-level JSON value to be an object", result.stdout)
|
||||||
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
|
||||||
|
def test_non_object_permissions_fails_cleanly(self):
|
||||||
|
for permissions in ([], "permissions", 1, None):
|
||||||
|
with self.subTest(permissions=permissions):
|
||||||
|
self.write_settings({"permissions": permissions})
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("expected permissions to be an object", result.stdout)
|
||||||
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
|
||||||
|
def test_non_list_allow_fails_cleanly(self):
|
||||||
|
for allow in ({}, "Bash(bun run:*)", 1, None):
|
||||||
|
with self.subTest(allow=allow):
|
||||||
|
self.write_settings({"permissions": {"allow": allow}})
|
||||||
|
|
||||||
|
result = run_linter(self.root)
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 1)
|
||||||
|
self.assertIn("expected permissions.allow to be a list", result.stdout)
|
||||||
|
self.assertNotIn("Traceback", result.stderr)
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -84,7 +84,14 @@ def check_settings() -> None:
|
|||||||
except (OSError, json.JSONDecodeError) as exc:
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
errors.append(f".claude/settings.json: {exc}")
|
errors.append(f".claude/settings.json: {exc}")
|
||||||
return
|
return
|
||||||
if not isinstance(data.get("permissions", {}).get("allow"), list):
|
if not isinstance(data, dict):
|
||||||
|
errors.append(".claude/settings.json: expected top-level JSON value to be an object")
|
||||||
|
return
|
||||||
|
permissions = data.get("permissions", {})
|
||||||
|
if not isinstance(permissions, dict):
|
||||||
|
errors.append(".claude/settings.json: expected permissions to be an object")
|
||||||
|
return
|
||||||
|
if not isinstance(permissions.get("allow"), list):
|
||||||
errors.append(".claude/settings.json: expected permissions.allow to be a list")
|
errors.append(".claude/settings.json: expected permissions.allow to be a list")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user