diff --git a/packages/skilllint/tests/test_frontmatter_module.py b/packages/skilllint/tests/test_frontmatter_module.py new file mode 100644 index 0000000..62a5b2a --- /dev/null +++ b/packages/skilllint/tests/test_frontmatter_module.py @@ -0,0 +1,224 @@ +"""Tests for skilllint.frontmatter module (mmap-based processor). + +Tests: +- loads_frontmatter: no-closing-delimiter path, YAML parse error path +- load_frontmatter / dump_frontmatter / dumps_frontmatter / update_field +- process_markdown_file: empty file, no-frontmatter, malformed, and with + lint_and_fix mocked to drive both the "no fix needed" and "fix needed" paths +- lint_and_fix raises NotImplementedError (scaffold) + +How: Uses tmp_path for real files; unittest.mock.patch to control lint_and_fix. +Why: frontmatter.py is separate from frontmatter_utils.py and had 0% coverage + for the mmap-based process_markdown_file and several loads_frontmatter + branches. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from skilllint.frontmatter import ( + Post, + dump_frontmatter, + dumps_frontmatter, + lint_and_fix, + load_frontmatter, + loads_frontmatter, + process_markdown_file, + update_field, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestLoadsFrontmatter: + """Tests for loads_frontmatter string parser.""" + + def test_no_opening_delimiter_returns_empty_metadata(self) -> None: + """Text not starting with '---' yields empty metadata.""" + text = "# Just a heading\n\nSome body text.\n" + post = loads_frontmatter(text) + assert post.metadata == {} + assert "Just a heading" in post.content + + def test_unclosed_frontmatter_returns_empty_metadata(self) -> None: + """Text starting with '---' but without a closing delimiter yields empty metadata.""" + text = "---\nname: test\ndescription: no closing\n" + post = loads_frontmatter(text) + assert post.metadata == {} + # The unclosed text is returned as content + assert post.content == text + + def test_valid_frontmatter_parsed(self) -> None: + """Standard frontmatter is parsed correctly.""" + text = "---\nname: my-skill\ndescription: A test skill\n---\n\nBody.\n" + post = loads_frontmatter(text) + assert post.metadata["name"] == "my-skill" + assert post.metadata["description"] == "A test skill" + assert post.content == "Body." + + def test_non_dict_yaml_returns_empty_metadata(self) -> None: + """YAML that parses as a non-dict (list) yields empty metadata.""" + # A YAML list is valid YAML but not a dict — metadata stays empty. + text = "---\n- item1\n- item2\n---\n\nBody.\n" + post = loads_frontmatter(text) + assert post.metadata == {} + + def test_empty_frontmatter_block_returns_empty_metadata(self) -> None: + """Frontmatter delimiters with no content yield empty metadata.""" + text = "---\n---\n\nBody text.\n" + post = loads_frontmatter(text) + assert post.metadata == {} + + def test_body_content_extracted(self) -> None: + """Body is everything after the closing '---' line.""" + text = "---\nname: test\n---\n\nFirst line.\nSecond line.\n" + post = loads_frontmatter(text) + assert "First line." in post.content + assert "Second line." in post.content + + +class TestLoadFrontmatter: + """Tests for load_frontmatter file-based API.""" + + def test_loads_from_file(self, tmp_path: Path) -> None: + """Reads and parses a real file.""" + f = tmp_path / "test.md" + f.write_text("---\nname: skill\n---\n\nContent.\n", encoding="utf-8") + post = load_frontmatter(f) + assert post.metadata["name"] == "skill" + + def test_string_path_accepted(self, tmp_path: Path) -> None: + """Accepts a string path as well as a Path object.""" + f = tmp_path / "test.md" + f.write_text("---\nname: skill\n---\n\nContent.\n", encoding="utf-8") + post = load_frontmatter(str(f)) + assert post.metadata["name"] == "skill" + + +class TestDumpFrontmatter: + """Tests for dump_frontmatter and dumps_frontmatter.""" + + def test_dump_produces_delimiters(self) -> None: + """Serialised output starts with '---' and contains another '---'.""" + post = Post(metadata={"name": "skill"}, content="Body.\n") + result = dump_frontmatter(post) + assert result.startswith("---\n") + assert "\n---\n" in result + + def test_dump_includes_body(self) -> None: + """Body content appears after the closing delimiter.""" + post = Post(metadata={"name": "skill"}, content="My body.") + result = dump_frontmatter(post) + assert "My body." in result + + def test_dumps_writes_file(self, tmp_path: Path) -> None: + """dumps_frontmatter writes a valid file that can be round-tripped.""" + post = Post(metadata={"name": "write-test"}, content="Body text.") + target = tmp_path / "out.md" + dumps_frontmatter(post, target) + reloaded = load_frontmatter(target) + assert reloaded.metadata["name"] == "write-test" + + +class TestUpdateField: + """Tests for update_field convenience function.""" + + def test_updates_existing_field(self, tmp_path: Path) -> None: + """An existing key is overwritten.""" + f = tmp_path / "skill.md" + f.write_text("---\nname: old\n---\n\nBody.\n", encoding="utf-8") + update_field(f, "name", "new") + post = load_frontmatter(f) + assert post.metadata["name"] == "new" + + def test_adds_new_field(self, tmp_path: Path) -> None: + """A new key is inserted.""" + f = tmp_path / "skill.md" + f.write_text("---\nname: skill\n---\n\nBody.\n", encoding="utf-8") + update_field(f, "model", "sonnet") + post = load_frontmatter(f) + assert post.metadata["model"] == "sonnet" + assert post.metadata["name"] == "skill" + + +class TestProcessMarkdownFile: + """Tests for process_markdown_file (mmap-based in-place processor).""" + + def test_empty_file_is_skipped_silently(self, tmp_path: Path) -> None: + """Empty file does not raise (mmap raises ValueError on zero-length).""" + f = tmp_path / "empty.md" + f.write_text("", encoding="utf-8") + # Should return without error + process_markdown_file(str(f)) + assert f.read_text(encoding="utf-8") == "" + + def test_file_without_frontmatter_is_untouched(self, tmp_path: Path) -> None: + """File with no opening '---' delimiter is not modified.""" + original = "# No frontmatter\n\nJust a body.\n" + f = tmp_path / "plain.md" + f.write_text(original, encoding="utf-8") + process_markdown_file(str(f)) + assert f.read_text(encoding="utf-8") == original + + def test_malformed_no_closing_delimiter_is_untouched(self, tmp_path: Path) -> None: + """File with opening '---' but no closing delimiter is not modified.""" + original = "---\nname: skill\ndescription: no closing\n" + f = tmp_path / "malformed.md" + f.write_text(original, encoding="utf-8") + process_markdown_file(str(f)) + assert f.read_text(encoding="utf-8") == original + + def test_lint_and_fix_not_needed_no_write(self, tmp_path: Path) -> None: + """When lint_and_fix returns (False, ...), the file is not rewritten.""" + original = "---\nname: skill\n---\n\nBody.\n" + f = tmp_path / "ok.md" + f.write_text(original, encoding="utf-8") + + with patch("skilllint.frontmatter.lint_and_fix", return_value=(False, b"name: skill\n")): + process_markdown_file(str(f)) + + # File must be unchanged + assert f.read_text(encoding="utf-8") == original + + def test_lint_and_fix_needed_rewrites_file(self, tmp_path: Path) -> None: + """When lint_and_fix returns (True, new_bytes), the file is rewritten.""" + original = "---\nname: skill\n---\n\nBody.\n" + f = tmp_path / "fixme.md" + f.write_text(original, encoding="utf-8") + + fixed_yaml = b"name: fixed-skill\n" + with patch("skilllint.frontmatter.lint_and_fix", return_value=(True, fixed_yaml)): + process_markdown_file(str(f)) + + result = f.read_text(encoding="utf-8") + assert "fixed-skill" in result + # Body should still be present + assert "Body." in result + + def test_lint_and_fix_adds_trailing_newline_if_missing(self, tmp_path: Path) -> None: + """lint_and_fix result without trailing newline gets one appended.""" + original = "---\nname: skill\n---\n\nBody.\n" + f = tmp_path / "nonl.md" + f.write_text(original, encoding="utf-8") + + # Bytes without trailing newline + fixed_yaml = b"name: newname" + with patch("skilllint.frontmatter.lint_and_fix", return_value=(True, fixed_yaml)): + process_markdown_file(str(f)) + + result = f.read_text(encoding="utf-8") + assert "newname" in result + + +class TestLintAndFix: + """Tests for the lint_and_fix scaffold.""" + + def test_raises_not_implemented(self) -> None: + """lint_and_fix raises NotImplementedError (scaffold, not yet integrated).""" + with pytest.raises(NotImplementedError, match="lint_and_fix is not yet implemented"): + lint_and_fix(b"name: skill\n") diff --git a/packages/skilllint/tests/test_limits.py b/packages/skilllint/tests/test_limits.py new file mode 100644 index 0000000..e358266 --- /dev/null +++ b/packages/skilllint/tests/test_limits.py @@ -0,0 +1,159 @@ +"""Tests for skilllint.limits module. + +Tests: +- Constant values match specification sources +- Provider and RuleLimit enums have expected members +- Legacy alias constants equal the canonical values +- Token thresholds are logically ordered + +How: Direct attribute and enum-member inspection. +Why: limits.py is the single source of truth for all validation thresholds; + regressions here silently break rule enforcement without these tests. +""" + +from __future__ import annotations + +from skilllint.limits import ( + BODY_TOKEN_ERROR, + BODY_TOKEN_WARNING, + COMPATIBILITY_MAX_LENGTH, + DESCRIPTION_MAX_LENGTH, + DESCRIPTION_MIN_LENGTH, + LICENSE_MAX_LENGTH, + MAX_SKILL_NAME_LENGTH, + METADATA_TOKEN_BUDGET, + NAME_MAX_LENGTH, + NAME_MIN_LENGTH, + NAME_PATTERN, + RECOMMENDED_DESCRIPTION_LENGTH, + TOKEN_ERROR_THRESHOLD, + TOKEN_WARNING_THRESHOLD, + Provider, + RuleLimit, +) + + +class TestProviderEnum: + """Tests for the Provider enumeration.""" + + def test_agentskills_io_member(self) -> None: + """Provider.AGENTSKILLS_IO has value 'agentskills.io'.""" + assert Provider.AGENTSKILLS_IO.value == "agentskills.io" + + def test_claude_code_member(self) -> None: + """Provider.CLAUDE_CODE has value 'claude-code'.""" + assert Provider.CLAUDE_CODE.value == "claude-code" + + def test_cursor_member(self) -> None: + """Provider.CURSOR has value 'cursor'.""" + assert Provider.CURSOR.value == "cursor" + + def test_codex_member(self) -> None: + """Provider.CODEX has value 'codex'.""" + assert Provider.CODEX.value == "codex" + + def test_skilllint_member(self) -> None: + """Provider.SKILL_LINT has value 'skilllint'.""" + assert Provider.SKILL_LINT.value == "skilllint" + + def test_all_providers_accounted_for(self) -> None: + """Provider enum contains exactly the expected set of members.""" + expected = {"AGENTSKILLS_IO", "CLAUDE_CODE", "CURSOR", "CODEX", "SKILL_LINT"} + assert {m.name for m in Provider} == expected + + +class TestRuleLimitEnum: + """Tests for the RuleLimit enumeration.""" + + def test_fm_name_max_length_member(self) -> None: + """RuleLimit.FM_NAME_MAX_LENGTH is accessible.""" + assert RuleLimit.FM_NAME_MAX_LENGTH.value == "fm_name_max_length" + + def test_fm_description_max_length_member(self) -> None: + """RuleLimit.FM_DESCRIPTION_MAX_LENGTH is accessible.""" + assert RuleLimit.FM_DESCRIPTION_MAX_LENGTH.value == "fm_desc_max_length" + + def test_body_warning_member(self) -> None: + """RuleLimit.BODY_WARNING is accessible.""" + assert RuleLimit.BODY_WARNING.value == "body_warning" + + def test_body_error_member(self) -> None: + """RuleLimit.BODY_ERROR is accessible.""" + assert RuleLimit.BODY_ERROR.value == "body_error" + + def test_metadata_budget_member(self) -> None: + """RuleLimit.METADATA_BUDGET is accessible.""" + assert RuleLimit.METADATA_BUDGET.value == "metadata_budget" + + def test_sk_description_min_length_member(self) -> None: + """RuleLimit.SK_DESCRIPTION_MIN_LENGTH is accessible.""" + assert RuleLimit.SK_DESCRIPTION_MIN_LENGTH.value == "sk_desc_min_length" + + +class TestFrontmatterFieldLimits: + """Tests for frontmatter field limit constants.""" + + def test_name_max_length(self) -> None: + """NAME_MAX_LENGTH is 64 per spec.""" + assert NAME_MAX_LENGTH == 64 + + def test_description_max_length(self) -> None: + """DESCRIPTION_MAX_LENGTH is 1024 per spec.""" + assert DESCRIPTION_MAX_LENGTH == 1024 + + def test_license_max_length(self) -> None: + """LICENSE_MAX_LENGTH is 500 per spec.""" + assert LICENSE_MAX_LENGTH == 500 + + def test_compatibility_max_length(self) -> None: + """COMPATIBILITY_MAX_LENGTH is 500 per spec.""" + assert COMPATIBILITY_MAX_LENGTH == 500 + + def test_name_min_length(self) -> None: + """NAME_MIN_LENGTH is 1 per spec.""" + assert NAME_MIN_LENGTH == 1 + + def test_name_pattern(self) -> None: + """NAME_PATTERN is the expected regex string.""" + assert NAME_PATTERN == r"^[a-z0-9]+(-[a-z0-9]+)*$" + + def test_description_min_length(self) -> None: + """DESCRIPTION_MIN_LENGTH is 20 (best practice).""" + assert DESCRIPTION_MIN_LENGTH == 20 + + +class TestTokenThresholds: + """Tests for token threshold constants.""" + + def test_body_token_warning(self) -> None: + """BODY_TOKEN_WARNING is 4400.""" + assert BODY_TOKEN_WARNING == 4400 + + def test_body_token_error(self) -> None: + """BODY_TOKEN_ERROR is 8800.""" + assert BODY_TOKEN_ERROR == 8800 + + def test_warning_below_error(self) -> None: + """Warning threshold is strictly less than error threshold.""" + assert BODY_TOKEN_WARNING < BODY_TOKEN_ERROR + + def test_metadata_token_budget(self) -> None: + """METADATA_TOKEN_BUDGET is 100.""" + assert METADATA_TOKEN_BUDGET == 100 + + def test_backward_compat_aliases(self) -> None: + """TOKEN_WARNING_THRESHOLD and TOKEN_ERROR_THRESHOLD match canonical values.""" + assert TOKEN_WARNING_THRESHOLD == BODY_TOKEN_WARNING + assert TOKEN_ERROR_THRESHOLD == BODY_TOKEN_ERROR + + +class TestLegacyAliases: + """Tests for deprecated / legacy alias constants.""" + + def test_max_skill_name_length_is_40(self) -> None: + """Legacy MAX_SKILL_NAME_LENGTH is 40 (differs from spec 64).""" + assert MAX_SKILL_NAME_LENGTH == 40 + + def test_recommended_description_length_matches_max(self) -> None: + """RECOMMENDED_DESCRIPTION_LENGTH equals DESCRIPTION_MAX_LENGTH.""" + assert RECOMMENDED_DESCRIPTION_LENGTH == DESCRIPTION_MAX_LENGTH diff --git a/packages/skilllint/tests/test_platform_adapter_protocol.py b/packages/skilllint/tests/test_platform_adapter_protocol.py new file mode 100644 index 0000000..8f6b23a --- /dev/null +++ b/packages/skilllint/tests/test_platform_adapter_protocol.py @@ -0,0 +1,160 @@ +"""Tests for skilllint.adapters.protocol module. + +Tests: +- PlatformAdapter is a @runtime_checkable Protocol +- Protocol method stubs return expected values when called directly +- Concrete adapter classes satisfy isinstance(obj, PlatformAdapter) +- Incomplete adapters do NOT satisfy isinstance(obj, PlatformAdapter) + +How: Calls protocol methods directly on Protocol class and on conforming + mock implementations. +Why: The protocol method stubs (``...`` bodies) were 0% covered because + they are only called when testing structural subtyping at runtime. +""" + +from __future__ import annotations + +import pathlib + +import pytest + +from skilllint.adapters.protocol import PlatformAdapter + + +class _FullAdapter: + """A complete mock adapter implementing all five protocol methods.""" + + def id(self) -> str: + return "mock" + + def path_patterns(self) -> list[str]: + return ["**/*.mock"] + + def applicable_rules(self) -> set[str]: + return {"AS", "CC"} + + def constraint_scopes(self) -> set[str]: + return {"shared"} + + def validate(self, path: pathlib.Path) -> list[dict]: + return [] + + +class _IncompleteAdapter: + """A mock adapter missing constraint_scopes and validate methods.""" + + def id(self) -> str: + return "incomplete" + + def path_patterns(self) -> list[str]: + return [] + + def applicable_rules(self) -> set[str]: + return set() + + +class TestPlatformAdapterProtocol: + """Tests for the PlatformAdapter Protocol class.""" + + def test_full_adapter_satisfies_protocol(self) -> None: + """A class implementing all five methods is a PlatformAdapter.""" + adapter = _FullAdapter() + assert isinstance(adapter, PlatformAdapter) + + def test_incomplete_adapter_does_not_satisfy_protocol(self) -> None: + """A class missing required methods is not a PlatformAdapter.""" + adapter = _IncompleteAdapter() + assert not isinstance(adapter, PlatformAdapter) + + def test_protocol_is_runtime_checkable(self) -> None: + """isinstance() against PlatformAdapter does not raise TypeError.""" + # Would raise TypeError if not @runtime_checkable + result = isinstance(_FullAdapter(), PlatformAdapter) + assert isinstance(result, bool) + + def test_protocol_method_id(self) -> None: + """Protocol.id() stub is callable and returns None (placeholder).""" + # Calling the stub directly exercises the '...' body for coverage. + result = PlatformAdapter.id(None) # type: ignore[arg-type] + assert result is None + + def test_protocol_method_path_patterns(self) -> None: + """Protocol.path_patterns() stub is callable.""" + result = PlatformAdapter.path_patterns(None) # type: ignore[arg-type] + assert result is None + + def test_protocol_method_applicable_rules(self) -> None: + """Protocol.applicable_rules() stub is callable.""" + result = PlatformAdapter.applicable_rules(None) # type: ignore[arg-type] + assert result is None + + def test_protocol_method_constraint_scopes(self) -> None: + """Protocol.constraint_scopes() stub is callable.""" + result = PlatformAdapter.constraint_scopes(None) # type: ignore[arg-type] + assert result is None + + def test_protocol_method_validate(self) -> None: + """Protocol.validate() stub is callable.""" + result = PlatformAdapter.validate(None, pathlib.Path()) # type: ignore[arg-type] + assert result is None + + def test_real_adapters_satisfy_protocol(self) -> None: + """Built-in adapters (ClaudeCode, Cursor, Codex) satisfy PlatformAdapter.""" + from skilllint.adapters.claude_code import ClaudeCodeAdapter + from skilllint.adapters.codex import CodexAdapter + from skilllint.adapters.cursor import CursorAdapter + + for adapter_cls in (ClaudeCodeAdapter, CursorAdapter, CodexAdapter): + adapter = adapter_cls() + assert isinstance(adapter, PlatformAdapter), ( + f"{adapter_cls.__name__} should satisfy PlatformAdapter protocol" + ) + + def test_non_adapter_object_not_protocol(self) -> None: + """A plain object without the required methods is not a PlatformAdapter.""" + assert not isinstance(object(), PlatformAdapter) + assert not isinstance("string", PlatformAdapter) + assert not isinstance(42, PlatformAdapter) + + +class TestProtocolMethodSignatures: + """Tests for the concrete method behaviour on real adapters.""" + + @pytest.fixture(params=["claude_code", "cursor", "codex"]) + def adapter(self, request: pytest.FixtureRequest) -> PlatformAdapter: + """Parametrised fixture providing each built-in adapter.""" + if request.param == "claude_code": + from skilllint.adapters.claude_code import ClaudeCodeAdapter + + return ClaudeCodeAdapter() + if request.param == "cursor": + from skilllint.adapters.cursor import CursorAdapter + + return CursorAdapter() + from skilllint.adapters.codex import CodexAdapter + + return CodexAdapter() + + def test_id_returns_non_empty_string(self, adapter: PlatformAdapter) -> None: + """id() returns a non-empty string.""" + result = adapter.id() + assert isinstance(result, str) + assert result + + def test_path_patterns_returns_list_of_strings(self, adapter: PlatformAdapter) -> None: + """path_patterns() returns a non-empty list of strings.""" + result = adapter.path_patterns() + assert isinstance(result, list) + assert len(result) > 0 + assert all(isinstance(p, str) for p in result) + + def test_applicable_rules_returns_set_of_strings(self, adapter: PlatformAdapter) -> None: + """applicable_rules() returns a set of strings.""" + result = adapter.applicable_rules() + assert isinstance(result, set) + assert all(isinstance(r, str) for r in result) + + def test_constraint_scopes_returns_set(self, adapter: PlatformAdapter) -> None: + """constraint_scopes() returns a set.""" + result = adapter.constraint_scopes() + assert isinstance(result, set) diff --git a/packages/skilllint/tests/test_spec_constants.py b/packages/skilllint/tests/test_spec_constants.py new file mode 100644 index 0000000..373d4b2 --- /dev/null +++ b/packages/skilllint/tests/test_spec_constants.py @@ -0,0 +1,50 @@ +"""Tests for skilllint._spec_constants module. + +Tests: +- Constant values are correct per the agentskills.io specification +- Constants are importable and accessible + +How: Direct attribute inspection. +Why: Constant values drive validation rules; regressions here cause silent + mis-validation without test coverage to catch them. +""" + +from __future__ import annotations + +from skilllint._spec_constants import ( + MAX_COMPATIBILITY_LENGTH, + MAX_DESCRIPTION_LENGTH, + MAX_NAME_LENGTH, + MIN_DESCRIPTION_LENGTH, +) + + +class TestSpecConstants: + """Tests for the auto-generated spec constants.""" + + def test_max_name_length(self) -> None: + """MAX_NAME_LENGTH matches agentskills.io spec value of 64.""" + assert MAX_NAME_LENGTH == 64 + + def test_max_description_length(self) -> None: + """MAX_DESCRIPTION_LENGTH matches agentskills.io spec value of 1024.""" + assert MAX_DESCRIPTION_LENGTH == 1024 + + def test_min_description_length(self) -> None: + """MIN_DESCRIPTION_LENGTH matches agentskills.io spec value of 1.""" + assert MIN_DESCRIPTION_LENGTH == 1 + + def test_max_compatibility_length(self) -> None: + """MAX_COMPATIBILITY_LENGTH matches agentskills.io spec value of 500.""" + assert MAX_COMPATIBILITY_LENGTH == 500 + + def test_constants_are_integers(self) -> None: + """All spec constants are integer values.""" + assert isinstance(MAX_NAME_LENGTH, int) + assert isinstance(MAX_DESCRIPTION_LENGTH, int) + assert isinstance(MIN_DESCRIPTION_LENGTH, int) + assert isinstance(MAX_COMPATIBILITY_LENGTH, int) + + def test_description_range_is_sane(self) -> None: + """MIN_DESCRIPTION_LENGTH < MAX_DESCRIPTION_LENGTH.""" + assert MIN_DESCRIPTION_LENGTH < MAX_DESCRIPTION_LENGTH diff --git a/packages/skilllint/tests/test_token_counter_module.py b/packages/skilllint/tests/test_token_counter_module.py new file mode 100644 index 0000000..60624da --- /dev/null +++ b/packages/skilllint/tests/test_token_counter_module.py @@ -0,0 +1,186 @@ +"""Tests for skilllint.token_counter module. + +Tests: +- _split_frontmatter_body edge cases (no frontmatter, unclosed, valid) +- count_file_tokens (normal, body_only, OSError, missing file) +- count_skill_tokens structured breakdown +- TokenCounts dataclass attributes + +How: Creates temp files and exercises the public API directly. +Why: token_counter is the single source of truth for counting; its + count_file_tokens and count_skill_tokens paths were previously + untested, making threshold regressions invisible. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from skilllint.token_counter import ( + TokenCounts, + _split_frontmatter_body, + count_file_tokens, + count_skill_tokens, + count_tokens, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSplitFrontmatterBody: + """Tests for the _split_frontmatter_body helper.""" + + def test_no_frontmatter_returns_empty_fm_and_full_body(self) -> None: + """Content without '---' yields empty frontmatter and full body.""" + content = "# Title\n\nSome body text.\n" + fm, body = _split_frontmatter_body(content) + assert fm == "" + assert body == content + + def test_valid_frontmatter_splits_correctly(self) -> None: + """Standard frontmatter block is split at the closing '---'.""" + content = "---\nname: test\n---\n\nBody here.\n" + fm, body = _split_frontmatter_body(content) + assert "name: test" in fm + assert "Body here." in body + + def test_unclosed_frontmatter_returns_whole_file_as_fm(self) -> None: + """When there is no closing '---', the whole file is frontmatter.""" + content = "---\nname: test\ndescription: missing closing delimiter\n" + fm, body = _split_frontmatter_body(content) + assert fm == content + assert body == "" + + def test_empty_string_returns_empty_fm_and_body(self) -> None: + """Empty string yields empty frontmatter and body.""" + fm, body = _split_frontmatter_body("") + assert fm == "" + assert body == "" + + def test_body_after_closing_delimiter_captured(self) -> None: + """Everything after the closing '---\\n' ends up in body.""" + content = "---\nkey: val\n---\nline1\nline2\n" + _, body = _split_frontmatter_body(content) + assert "line1" in body + assert "line2" in body + + +class TestCountFileTokens: + """Tests for count_file_tokens.""" + + def test_counts_tokens_in_file(self, tmp_path: Path) -> None: + """Returns a positive integer for a non-empty file.""" + f = tmp_path / "test.md" + f.write_text("Hello world, this is some content.\n", encoding="utf-8") + result = count_file_tokens(f) + assert isinstance(result, int) + assert result > 0 + + def test_body_only_excludes_frontmatter(self, tmp_path: Path) -> None: + """body_only=True counts fewer tokens than the full file.""" + content = "---\nname: skill\ndescription: A skill\n---\n\nBody content here.\n" + f = tmp_path / "skill.md" + f.write_text(content, encoding="utf-8") + + total = count_file_tokens(f) + body_only = count_file_tokens(f, body_only=True) + + assert isinstance(total, int) + assert isinstance(body_only, int) + assert total > body_only + + def test_returns_none_for_missing_file(self, tmp_path: Path) -> None: + """Returns None when the file does not exist.""" + result = count_file_tokens(tmp_path / "nonexistent.md") + assert result is None + + def test_empty_file_returns_zero(self, tmp_path: Path) -> None: + """Empty file yields 0 tokens.""" + f = tmp_path / "empty.md" + f.write_text("", encoding="utf-8") + result = count_file_tokens(f) + assert result == 0 + + def test_body_only_plain_markdown(self, tmp_path: Path) -> None: + """body_only on a file without frontmatter counts the full content.""" + content = "# Title\n\nSome body text.\n" + f = tmp_path / "plain.md" + f.write_text(content, encoding="utf-8") + + total = count_file_tokens(f) + body_only = count_file_tokens(f, body_only=True) + + # Without frontmatter, body == full content + assert total == body_only + + +class TestCountSkillTokens: + """Tests for count_skill_tokens.""" + + def test_returns_token_counts_dataclass(self) -> None: + """Returns a TokenCounts instance.""" + content = "---\nname: test\n---\n\nBody text.\n" + result = count_skill_tokens(content) + assert isinstance(result, TokenCounts) + + def test_total_equals_frontmatter_plus_body(self) -> None: + """total == frontmatter + body always holds.""" + content = "---\nname: test\ndescription: A skill\n---\n\nBody content here.\n" + result = count_skill_tokens(content) + assert result.total == result.frontmatter + result.body + + def test_plain_content_has_zero_frontmatter(self) -> None: + """Content without frontmatter has frontmatter == 0.""" + content = "# Title\n\nJust body text with no frontmatter.\n" + result = count_skill_tokens(content) + assert result.frontmatter == 0 + assert result.body == result.total + + def test_body_tokens_positive_for_non_empty_body(self) -> None: + """body > 0 when the body section has content.""" + content = "---\nname: skill\n---\n\nSome body words here.\n" + result = count_skill_tokens(content) + assert result.body > 0 + + def test_empty_content_returns_zeros(self) -> None: + """Empty string returns all-zero TokenCounts.""" + result = count_skill_tokens("") + assert result.total == 0 + assert result.frontmatter == 0 + assert result.body == 0 + + +class TestTokenCounts: + """Tests for the TokenCounts dataclass.""" + + def test_is_frozen(self) -> None: + """TokenCounts is immutable (frozen=True).""" + import dataclasses + + tc = TokenCounts(total=10, frontmatter=3, body=7) + with pytest.raises(dataclasses.FrozenInstanceError): + tc.total = 99 # type: ignore[misc] + + def test_fields_accessible(self) -> None: + """All three fields are readable.""" + tc = TokenCounts(total=100, frontmatter=20, body=80) + assert tc.total == 100 + assert tc.frontmatter == 20 + assert tc.body == 80 + + def test_equality(self) -> None: + """Two TokenCounts with identical values compare equal.""" + a = TokenCounts(total=5, frontmatter=2, body=3) + b = TokenCounts(total=5, frontmatter=2, body=3) + assert a == b + + def test_count_tokens_function(self) -> None: + """count_tokens returns a consistent, positive integer for a known string.""" + result = count_tokens("Hello world") + assert isinstance(result, int) + assert result > 0 + # Deterministic + assert count_tokens("Hello world") == result diff --git a/packages/skilllint/tests/test_token_utils.py b/packages/skilllint/tests/test_token_utils.py new file mode 100644 index 0000000..ec20b18 --- /dev/null +++ b/packages/skilllint/tests/test_token_utils.py @@ -0,0 +1,42 @@ +"""Tests for skilllint.token_utils backward-compatibility shim. + +Tests: +- Re-exported symbols are the same objects as in token_counter +- All three public names are importable from token_utils + +How: Direct import and identity checks. +Why: token_utils is a shim for existing callers; if re-exports break, + downstream code silently uses wrong thresholds or functions. +""" + +from __future__ import annotations + +from skilllint import token_counter, token_utils + + +class TestTokenUtilsReexports: + """Tests that token_utils correctly re-exports token_counter symbols.""" + + def test_count_tokens_is_same_object(self) -> None: + """token_utils.count_tokens is the same callable as token_counter.count_tokens.""" + assert token_utils.count_tokens is token_counter.count_tokens + + def test_token_warning_threshold_matches(self) -> None: + """TOKEN_WARNING_THRESHOLD has the same value as token_counter's.""" + assert token_utils.TOKEN_WARNING_THRESHOLD == token_counter.TOKEN_WARNING_THRESHOLD + + def test_token_error_threshold_matches(self) -> None: + """TOKEN_ERROR_THRESHOLD has the same value as token_counter's.""" + assert token_utils.TOKEN_ERROR_THRESHOLD == token_counter.TOKEN_ERROR_THRESHOLD + + def test_all_public_names_in_all(self) -> None: + """__all__ declares the three public re-exports.""" + assert "count_tokens" in token_utils.__all__ + assert "TOKEN_WARNING_THRESHOLD" in token_utils.__all__ + assert "TOKEN_ERROR_THRESHOLD" in token_utils.__all__ + + def test_count_tokens_callable(self) -> None: + """count_tokens imported via token_utils produces correct results.""" + result = token_utils.count_tokens("hello world") + assert isinstance(result, int) + assert result > 0