2114ccd278
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
245 lines
10 KiB
Python
245 lines
10 KiB
Python
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Unit tests for build_context node.
|
|
|
|
Uses skill spec layout: SKILL.md, references/, scripts/, assets/
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from skillspector.constants import MODEL_CONFIG
|
|
from skillspector.nodes.build_context import build_context
|
|
from skillspector.state import SkillspectorState
|
|
|
|
|
|
def _make_skill_spec_dir(root: Path, *, skill_md_name: str = "SKILL.md") -> None:
|
|
"""Populate root with skill spec: SKILL.md, references/, scripts/, assets/."""
|
|
if skill_md_name == "SKILL.md":
|
|
(root / "SKILL.md").write_text(
|
|
"---\nname: test-skill\ndescription: For tests\ntriggers: [a, b]\npermissions: [read]\n---\n\n# Skill\n",
|
|
encoding="utf-8",
|
|
)
|
|
(root / "references").mkdir(exist_ok=True)
|
|
(root / "references" / "guide.md").write_text("# Reference guide\n", encoding="utf-8")
|
|
(root / "scripts").mkdir(exist_ok=True)
|
|
(root / "scripts" / "run.py").write_text("print(1)\n", encoding="utf-8")
|
|
(root / "assets").mkdir(exist_ok=True)
|
|
(root / "assets" / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
if skill_md_name == "skill.md":
|
|
(root / "skill.md").write_text(
|
|
"---\nname: lower\ndescription: d\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_build_context_real_directory_with_skill_md(tmp_path: Path) -> None:
|
|
"""skill_path with skill spec (SKILL.md, references/, scripts/, assets/) yields components, file_cache, manifest."""
|
|
_make_skill_spec_dir(tmp_path)
|
|
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
|
|
assert "components" in result
|
|
components = result["components"]
|
|
assert isinstance(components, list)
|
|
assert "SKILL.md" in components
|
|
assert "references/guide.md" in components
|
|
assert "scripts/run.py" in components
|
|
assert "assets/icon.png" in components
|
|
assert result["file_cache"]
|
|
assert result["file_cache"].get("SKILL.md", "").startswith("---")
|
|
assert result["file_cache"].get("references/guide.md") == "# Reference guide\n"
|
|
assert result["file_cache"].get("scripts/run.py") == "print(1)\n"
|
|
assert result["manifest"] == {
|
|
"name": "test-skill",
|
|
"description": "For tests",
|
|
"triggers": ["a", "b"],
|
|
"permissions": ["read"],
|
|
"allowed-tools": [],
|
|
"parameters": [],
|
|
}
|
|
assert result["ast_cache"] == {}
|
|
assert result["previous_manifest"] is None
|
|
assert "component_metadata" in result
|
|
assert isinstance(result["component_metadata"], list)
|
|
assert len(result["component_metadata"]) == len(result["components"])
|
|
run_py_meta = next(
|
|
(m for m in result["component_metadata"] if m.get("path") == "scripts/run.py"), None
|
|
)
|
|
assert run_py_meta is not None
|
|
assert run_py_meta.get("type") == "python"
|
|
assert run_py_meta.get("executable") is True
|
|
assert run_py_meta.get("lines") == 1
|
|
assert "has_executable_scripts" in result
|
|
assert result["has_executable_scripts"] is True
|
|
|
|
|
|
def test_build_context_missing_skill_path() -> None:
|
|
"""Missing skill_path raises instead of producing a clean empty scan."""
|
|
state: SkillspectorState = {}
|
|
with pytest.raises(ValueError, match="skill_path is required"):
|
|
build_context(state)
|
|
|
|
|
|
def test_build_context_empty_skill_path() -> None:
|
|
"""Empty skill_path raises instead of producing a clean empty scan."""
|
|
state: SkillspectorState = {"skill_path": ""}
|
|
with pytest.raises(ValueError, match="skill_path is required"):
|
|
build_context(state)
|
|
|
|
|
|
def test_build_context_nonexistent_path() -> None:
|
|
"""Non-existent path raises instead of producing a clean empty scan."""
|
|
state: SkillspectorState = {"skill_path": "/nonexistent/path/xyz"}
|
|
with pytest.raises(ValueError, match="not an existing directory"):
|
|
build_context(state)
|
|
|
|
|
|
def test_build_context_path_is_file_not_dir(tmp_path: Path) -> None:
|
|
"""Path that is a file raises instead of producing a clean empty scan."""
|
|
f = tmp_path / "file.txt"
|
|
f.write_text("x", encoding="utf-8")
|
|
state: SkillspectorState = {"skill_path": str(f)}
|
|
with pytest.raises(ValueError, match="not an existing directory"):
|
|
build_context(state)
|
|
|
|
|
|
def test_build_context_empty_directory_is_valid_empty_scan(tmp_path: Path) -> None:
|
|
"""An existing empty directory is a valid scan target with no components."""
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["components"] == []
|
|
assert result["file_cache"] == {}
|
|
assert result["manifest"] == {}
|
|
assert result["model_config"] == MODEL_CONFIG
|
|
|
|
|
|
def test_build_context_skips_skip_dirs(tmp_path: Path) -> None:
|
|
"""Skip dirs like __pycache__ and node_modules are not included in components."""
|
|
_make_skill_spec_dir(tmp_path)
|
|
(tmp_path / "__pycache__" / "x.pyc").parent.mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "__pycache__" / "x.pyc").write_text("", encoding="utf-8")
|
|
(tmp_path / "node_modules" / "pkg" / "index.js").parent.mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "node_modules" / "pkg" / "index.js").write_text("", encoding="utf-8")
|
|
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
|
|
components = result["components"]
|
|
assert "SKILL.md" in components
|
|
assert "references/guide.md" in components
|
|
assert "scripts/run.py" in components
|
|
assert not any("__pycache__" in p for p in components)
|
|
assert not any("node_modules" in p for p in components)
|
|
|
|
|
|
def test_build_context_no_skill_md_returns_empty_manifest(tmp_path: Path) -> None:
|
|
"""Skill spec dir without SKILL.md or skill.md yields empty manifest."""
|
|
(tmp_path / "references").mkdir(exist_ok=True)
|
|
(tmp_path / "references" / "doc.md").write_text("x", encoding="utf-8")
|
|
(tmp_path / "scripts").mkdir(exist_ok=True)
|
|
(tmp_path / "assets").mkdir(exist_ok=True)
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["manifest"] == {}
|
|
assert "references/doc.md" in result["components"]
|
|
assert result["file_cache"].get("references/doc.md") == "x"
|
|
|
|
|
|
def test_build_context_no_executable_scripts_when_only_markdown(tmp_path: Path) -> None:
|
|
"""Directory with only .md files has has_executable_scripts False."""
|
|
(tmp_path / "SKILL.md").write_text("---\nname: docs-only\n---\n# Doc", encoding="utf-8")
|
|
(tmp_path / "readme.md").write_text("# Readme", encoding="utf-8")
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["has_executable_scripts"] is False
|
|
assert len(result["component_metadata"]) == 2
|
|
for meta in result["component_metadata"]:
|
|
assert meta.get("executable") is False
|
|
|
|
|
|
def test_build_context_skill_md_lowercase(tmp_path: Path) -> None:
|
|
"""skill.md (lowercase) is used when SKILL.md absent; skill spec layout."""
|
|
_make_skill_spec_dir(tmp_path, skill_md_name="skill.md")
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["manifest"]["name"] == "lower"
|
|
assert result["manifest"]["description"] == "d"
|
|
assert "skill.md" in result["components"]
|
|
assert "references/guide.md" in result["components"]
|
|
|
|
|
|
def test_build_context_parses_parameters_from_frontmatter(tmp_path: Path) -> None:
|
|
"""`parameters` frontmatter is preserved as dicts so MCP TP checks can reach it.
|
|
|
|
Regression guard: without this, the mcp_tool_poisoning parameter checks
|
|
(TP3 and parameter-scoped TP1/TP2) never fire on real scans because the
|
|
manifest carried no `parameters` key.
|
|
"""
|
|
(tmp_path / "SKILL.md").write_text(
|
|
"---\n"
|
|
"name: reader\n"
|
|
"description: reads data\n"
|
|
"parameters:\n"
|
|
" - name: path\n"
|
|
" description: file path to read\n"
|
|
" - not-a-dict\n" # non-dict entries are dropped
|
|
"---\n",
|
|
encoding="utf-8",
|
|
)
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["manifest"]["parameters"] == [
|
|
{"name": "path", "description": "file path to read"}
|
|
]
|
|
|
|
|
|
def test_build_context_parses_allowed_tools_list(tmp_path: Path) -> None:
|
|
"""`allowed-tools` list form is preserved so LP3 treats it as a declaration."""
|
|
(tmp_path / "SKILL.md").write_text(
|
|
"---\nname: deployer\ndescription: deploys services\nallowed-tools: [Bash, Read]\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["manifest"]["allowed-tools"] == ["Bash", "Read"]
|
|
|
|
|
|
def test_build_context_allowed_tools_malformed_value(tmp_path: Path) -> None:
|
|
"""A non-list, non-string `allowed-tools` value normalizes to an empty list."""
|
|
(tmp_path / "SKILL.md").write_text(
|
|
"---\nname: deployer\ndescription: deploys services\nallowed-tools: 42\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["manifest"]["allowed-tools"] == []
|
|
|
|
|
|
def test_build_context_parses_allowed_tools_comma_string(tmp_path: Path) -> None:
|
|
"""`allowed-tools` comma-separated string form is normalized to a list."""
|
|
(tmp_path / "SKILL.md").write_text(
|
|
"---\nname: deployer\ndescription: deploys services\nallowed-tools: Bash, Read\n---\n",
|
|
encoding="utf-8",
|
|
)
|
|
state: SkillspectorState = {"skill_path": str(tmp_path)}
|
|
result = build_context(state)
|
|
assert result["manifest"]["allowed-tools"] == ["Bash", "Read"]
|