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:
Ayobami Adegoke
2026-07-13 20:45:22 +02:00
committed by GitHub
parent 160b479868
commit a03529f894
2 changed files with 115 additions and 1 deletions
+107
View File
@@ -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()
+8 -1
View File
@@ -84,7 +84,14 @@ def check_settings() -> None:
except (OSError, json.JSONDecodeError) as exc:
errors.append(f".claude/settings.json: {exc}")
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")