201 lines
6.7 KiB
Python
201 lines
6.7 KiB
Python
"""Tests for skill-creator-linter (G1 + G2 gates)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
_LINTER_DIR = REPO / "src" / "opensquilla" / "skills" / "bundled" / "skill-creator-linter"
|
|
_BUNDLED_DIR = REPO / "src" / "opensquilla" / "skills" / "bundled"
|
|
_EXP_DIR = REPO / "src" / "opensquilla" / "skills" / "exp"
|
|
LINT = _LINTER_DIR / "scripts" / "lint.py"
|
|
|
|
|
|
def _run_lint(skill_md: str, gates: str = "G1,G2") -> dict:
|
|
proc = subprocess.run(
|
|
[sys.executable, str(LINT), "--gates", gates, "--skill-md-stdin"],
|
|
input=skill_md,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
|
)
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
VALID_P1 = """---
|
|
name: lint-test-p1
|
|
description: "Lint-test P1 sequential meta-skill: extract then summarize."
|
|
kind: meta
|
|
meta_priority: 50
|
|
triggers:
|
|
- "lint test trigger"
|
|
provenance:
|
|
origin: opensquilla-user
|
|
composition:
|
|
steps:
|
|
- id: extract
|
|
skill: pdf-toolkit
|
|
with:
|
|
task: "Extract: {{ inputs.user_message | xml_escape | truncate(512) }}"
|
|
- id: digest
|
|
skill: summarize
|
|
depends_on: [extract]
|
|
with:
|
|
text: "{{ outputs.extract | truncate(2000) }}"
|
|
---
|
|
# Lint test P1
|
|
"""
|
|
|
|
|
|
def test_g1_passes_on_valid_p1() -> None:
|
|
out = _run_lint(VALID_P1)
|
|
assert out["G1"]["passed"] is True
|
|
|
|
|
|
def test_g1_fails_on_missing_xml_escape() -> None:
|
|
bad = VALID_P1.replace("{{ inputs.user_message | xml_escape | truncate(512) }}",
|
|
"{{ inputs.user_message }}")
|
|
out = _run_lint(bad)
|
|
assert out["G1"]["passed"] is False
|
|
assert any("xml_escape" in d.lower() for d in out["G1"]["diagnostics"])
|
|
|
|
|
|
def test_g1_fails_on_unknown_skill_reference() -> None:
|
|
bad = VALID_P1.replace("skill: pdf-toolkit", "skill: this-skill-does-not-exist")
|
|
out = _run_lint(bad)
|
|
assert out["G1"]["passed"] is False
|
|
assert any("does-not-exist" in d for d in out["G1"]["diagnostics"])
|
|
|
|
|
|
def test_g2_passes_on_valid_p1() -> None:
|
|
out = _run_lint(VALID_P1)
|
|
assert out["G2"]["passed"] is True
|
|
|
|
|
|
EXISTING_META_BUNDLES = [
|
|
"meta-pdf-intelligence", "meta-travel-planner",
|
|
"meta-migration-assistant",
|
|
"meta-stack-trace-investigator", "meta-paper-write",
|
|
"meta-skill-creator",
|
|
]
|
|
|
|
|
|
def _existing_meta_skill_md(name: str) -> Path:
|
|
for root in (_BUNDLED_DIR, _EXP_DIR):
|
|
candidate = root / name / "SKILL.md"
|
|
if candidate.is_file():
|
|
return candidate
|
|
raise FileNotFoundError(name)
|
|
|
|
|
|
def test_g1_fails_on_non_xml_escape_first_filter() -> None:
|
|
"""G1.6 fires when inputs.user_message has a filter but xml_escape is not
|
|
the first one. Catches the bug class that motivated tightening the regex
|
|
in the first place (truncate-without-escape was a real injection risk in
|
|
3 bundles before this rule was enforced).
|
|
"""
|
|
bad = VALID_P1.replace(
|
|
"{{ inputs.user_message | xml_escape | truncate(512) }}",
|
|
"{{ inputs.user_message | truncate(512) }}",
|
|
)
|
|
out = _run_lint(bad)
|
|
assert out["G1"]["passed"] is False
|
|
assert any("xml_escape" in d.lower() for d in out["G1"]["diagnostics"])
|
|
|
|
|
|
def test_g1_passes_on_non_meta_skill_without_xml_escape() -> None:
|
|
"""G1.6 must be scoped to kind: meta. A non-meta skill using
|
|
{{ inputs.user_message | truncate }} is legitimate (the escape happens
|
|
at a different layer). Linter should not false-positive here."""
|
|
non_meta = """---
|
|
name: non-meta-test
|
|
description: "A regular non-meta skill using inputs.user_message without xml_escape."
|
|
kind: skill
|
|
provenance:
|
|
origin: opensquilla-user
|
|
---
|
|
# Non-meta skill body
|
|
"""
|
|
# Note: this won't have a meta plan, so G1.1 will fail with
|
|
# "parse_meta_plan returned None (kind != meta?)" — but the failure
|
|
# diagnostic must NOT mention G1.6. The G1.6 grep should silently skip.
|
|
out = _run_lint(non_meta)
|
|
# G1 fails overall (no meta plan), but the cause is NOT G1.6
|
|
assert not any("xml_escape" in d.lower() for d in out["G1"]["diagnostics"]), (
|
|
f"G1.6 should not fire on non-meta; diagnostics={out['G1']['diagnostics']}"
|
|
)
|
|
|
|
|
|
def test_g1_rejects_nested_meta_skill_reference() -> None:
|
|
"""N5 regression: G1.2 must reject steps that reference another kind=meta
|
|
bundle. The agent executor refuses nested meta-skills at runtime with
|
|
'cannot compose another meta-skill', but the old set-based catalog check
|
|
only verified existence — kind=meta bundles passed G1 and G2 silently,
|
|
producing misleading auto_enable_eligible=true proposals that crashed at
|
|
runtime."""
|
|
nested_meta = VALID_P1.replace(
|
|
"skill: pdf-toolkit", "skill: meta-paper-write"
|
|
)
|
|
out = _run_lint(nested_meta)
|
|
assert out["G1"]["passed"] is False
|
|
assert any(
|
|
"meta-paper-write" in d
|
|
and ("nested" in d.lower() or "kind: meta" in d)
|
|
for d in out["G1"]["diagnostics"]
|
|
), f"Expected nested meta-skill diagnostic; got: {out['G1']['diagnostics']}"
|
|
|
|
|
|
@pytest.mark.parametrize("bundle", EXISTING_META_BUNDLES)
|
|
def test_linter_passes_existing_meta_bundle(bundle: str) -> None:
|
|
"""Regression: linter must accept every existing kind=meta bundle.
|
|
Catches over-strict lint rules."""
|
|
skill_md = _existing_meta_skill_md(bundle).read_text(encoding="utf-8")
|
|
out = _run_lint(skill_md)
|
|
assert out["G1"]["passed"] is True, f"{bundle} G1 fail: {out['G1']['diagnostics']}"
|
|
assert out["G2"]["passed"] is True, f"{bundle} G2 fail: {out['G2']['diagnostics']}"
|
|
|
|
|
|
def test_g1_6_catches_quoted_kind_with_unsafe_template() -> None:
|
|
"""N13: G1.6 must not be bypassable by quoting the kind value.
|
|
Previously the gating used a regex on raw YAML text that only matched
|
|
`kind: meta` (unquoted). Quoting as `kind: \"meta\"` is semantically
|
|
identical in YAML but bypassed the regex. The fix uses the parsed
|
|
spec.kind from SkillLoader instead."""
|
|
bad = '''---
|
|
name: quoted-kind-bypass-test
|
|
description: "Demonstrates that quoting kind cannot bypass G1.6 xml_escape check."
|
|
kind: "meta"
|
|
meta_priority: 50
|
|
triggers:
|
|
- "quoted kind bypass test"
|
|
provenance:
|
|
origin: opensquilla-user
|
|
composition:
|
|
steps:
|
|
- id: a
|
|
skill: summarize
|
|
with:
|
|
task: "{{ inputs.user_message }}"
|
|
- id: b
|
|
skill: memory
|
|
depends_on: [a]
|
|
with:
|
|
text: "{{ outputs.a }}"
|
|
---
|
|
# bypass test
|
|
'''
|
|
out = _run_lint(bad)
|
|
assert out["G1"]["passed"] is False, (
|
|
"N13: quoted kind: \"meta\" must NOT bypass G1.6 xml_escape requirement"
|
|
)
|
|
assert any("xml_escape" in d.lower() for d in out["G1"]["diagnostics"]), (
|
|
f"N13: expected G1.6 xml_escape diagnostic; got: {out['G1']['diagnostics']}"
|
|
)
|