diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b00c97d..2192d85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: - name: Validate workflow, prompt, and QC contracts run: | + python -m aigc_director_kit validate-local-skill-adapter examples/local_skill_adapter_case.json --json python -m aigc_director_kit validate-workflow examples/skill_workflow_case.json --library examples/action_library.json --json python -m aigc_director_kit validate-prompt-pack examples/prompt_pack_case.json --plan examples/one_take_previs_case.json --json python -m aigc_director_kit validate-qc-report examples/qc_report_unverified_case.json --json diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e853f..9900d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CineThread Changelog +## [0.2.3] - 2026-08-12 + +- Added `aigc-director-local-skill-adapter`, a strict metadata-only contract for describing the interface between local Skills and CineThread. +- Added `validate-local-skill-adapter`, a sanitized example, schema, documentation, CI coverage, and one-command public verification coverage. +- Kept local Skill source, prompts, paths, assets, credentials, runtime execution, and media QC outside the public adapter boundary. + ## [0.2.2] - 2026-08-12 - Added a package version field and safe JSON report export to `verify-examples`, plus `aigc-director-kit --version`. diff --git a/README.md b/README.md index a90fbf8..0c84ec8 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ Windows users who do not know which Python command to use can instead double-cli ## Skill workflow handoff -Local AIGC Skills can hand a sanitized workflow packet to this repository: +Local AIGC Skills can publish a metadata-only adapter, then hand a sanitized +workflow packet to this repository: ```text -Skill output -> skill-workflow JSON -> validate-workflow -> optional runtime adapter +local Skills -> metadata-only adapter -> skill-workflow JSON -> validate-workflow -> optional runtime adapter ``` The public contract is documented in [`docs/skill-workflow-integration.md`](docs/skill-workflow-integration.md), @@ -48,6 +49,18 @@ This is a deterministic handoff and dry-run boundary. It does not bundle private Skills, Blender, FFmpeg, model accounts, paid generation, or final video QC. +The optional adapter manifest makes the boundary explicit: it maps only Skill +labels, input/output contract names, and evidence semantics. It cannot carry +Skill source, prompts, paths, assets, or credentials. See +[`docs/local-skill-adapter.md`](docs/local-skill-adapter.md) and validate the +included example with: + +```powershell +python -m aigc_director_kit validate-local-skill-adapter ` + examples/local_skill_adapter_case.json ` + --json +``` + To create one packet for an optional runtime adapter: ```powershell diff --git a/docs/local-skill-adapter.md b/docs/local-skill-adapter.md new file mode 100644 index 0000000..40add47 --- /dev/null +++ b/docs/local-skill-adapter.md @@ -0,0 +1,72 @@ +# Metadata-only local Skill adapter + +`aigc-director-local-skill-adapter` is a public manifest for describing how a +private local Skill stack connects to CineThread. It records only stage labels, +input and output contract names, and the meaning of each evidence state. + +It is deliberately not a Skill export, a prompt pack, or a project handoff. +The manifest must stay safe to publish. + +## What this solves + +Local production Skills can be much more capable than a public repository: +they can reason about references, direct performance, route image or video +work, and inspect real artifacts. Publishing that implementation wholesale +would expose private methods and make the public core harder to reproduce. + +The adapter manifest gives each side a small, stable boundary: + +```text +private local Skills + -> metadata-only adapter manifest + -> sanitized skill-workflow packet + -> CineThread deterministic validation and dry-run handoff + -> optional runtime / real artifact + -> local evidence-based QC +``` + +The manifest is reusable across projects. A `aigc-director-skill-workflow` +packet is project-specific and may carry a sanitized shot plan, action +requests, prompt pack, and QC report. + +## Validate the public interface + +```powershell +python -m aigc_director_kit validate-local-skill-adapter ` + examples/local_skill_adapter_case.json ` + --json +``` + +The example maps local stage labels to existing CineThread contracts without +shipping any Skill instructions. It is also included in `verify-examples`. + +## Required public fields + +| Field | Meaning | Safe to publish | +| --- | --- | --- | +| `skill_label` | Stable label, not an installation path or source archive | Yes | +| `role` | Short description of the stage responsibility | Yes | +| `input_contracts` | Names of incoming interface contracts | Yes | +| `output_contract` | Name of the outgoing CineThread or workflow contract | Yes | +| `evidence` | `designed`, `inferred`, or `observed` evidence semantics | Yes | + +The manifest fixes `visibility` to `public-metadata-only` and +`source_access` to `local-only`. Unknown fields are rejected, and obvious +credentials or local paths in string values are rejected as a guard against +accidental publication. + +That guard is structural, not a guarantee that prose is harmless. Review every +manifest before publishing. + +## Keep these out + +- Skill source, installation paths, archives, or tool configuration; +- prompt bodies, client briefs, private notes, or reference descriptions; +- character and scene assets, source media, rendered output, or project IDs; +- credentials, cookies, API keys, account identifiers, and machine paths; +- claims that a render or QC pass happened when no artifact was inspected. + +Use [`skill-workflow-integration.md`](skill-workflow-integration.md) for the +next step: emitting a sanitized, per-project workflow packet. The adapter +manifest itself never executes a Skill, launches a runtime, renders media, or +proves output quality. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9f30505..936febf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -6,6 +6,8 @@ standard-library-only validators. - [x] A runtime adapter dry-run handoff can combine the validated plan and compiled action requests without launching Blender. +- [x] A metadata-only local Skill adapter can document public integration + contracts without publishing local implementation details. - [x] The Windows example launcher accepts `AIGC_DIRECTOR_PYTHON` and checks common Python install locations. - [ ] Add an optional Blender adapter that consumes the handoff and records diff --git a/docs/skill-workflow-integration.md b/docs/skill-workflow-integration.md index dfe6ca0..bf8f261 100644 --- a/docs/skill-workflow-integration.md +++ b/docs/skill-workflow-integration.md @@ -38,6 +38,24 @@ The example uses `aigc-manga-video-director`, `audio-to-prompt`, and `aigc-project-prompt-loop` as illustrative stage labels. They are not bundled with this Python package and can be replaced by another workflow producer. +## Metadata-only local Skill adapter + +Before a project-specific workflow packet exists, a local workflow may publish +an `aigc-director-local-skill-adapter` manifest. It maps only a stable Skill +label, its input and output contract labels, and the evidence semantics of the +stage. It is intentionally reusable and contains no project content. + +```powershell +python -m aigc_director_kit validate-local-skill-adapter ` + examples/local_skill_adapter_case.json ` + --json +``` + +The adapter is not a Skill export and does not execute a Skill. Its strict +metadata-only field set rejects prompt bodies, paths, assets, and credentials. +The separate [`local-skill-adapter.md`](local-skill-adapter.md) guide explains +what may safely appear in a public manifest. + ## Run the public handoff Without an action catalog, the workflow is checked structurally and action diff --git a/examples/local_skill_adapter_case.json b/examples/local_skill_adapter_case.json new file mode 100644 index 0000000..6c04028 --- /dev/null +++ b/examples/local_skill_adapter_case.json @@ -0,0 +1,50 @@ +{ + "contract": "aigc-director-local-skill-adapter", + "version": 1, + "adapter_id": "local-aigc-skill-bridge", + "visibility": "public-metadata-only", + "source_access": "local-only", + "workflow_contract": "aigc-director-skill-workflow", + "stages": [ + { + "id": "direction", + "skill_label": "aigc-manga-video-director", + "role": "Turns direction decisions into a bounded shot-plan handoff.", + "input_contracts": ["project-brief", "reference-inventory"], + "output_contract": "aigc-director-shot-plan", + "evidence": "designed" + }, + { + "id": "action-bridge", + "skill_label": "audio-to-prompt", + "role": "Turns timing and action intent into bounded action requests.", + "input_contracts": ["timing-and-action-intent", "shot-plan"], + "output_contract": "aigc-director-action-request", + "evidence": "inferred" + }, + { + "id": "prompt-contract", + "skill_label": "prompt-optimizer", + "role": "Keeps reusable prompt rules separate from shot-level deltas.", + "input_contracts": ["shot-plan", "prompt-rules"], + "output_contract": "aigc-director-prompt-pack", + "evidence": "designed" + }, + { + "id": "quality-evidence", + "skill_label": "aigc-output-qc", + "role": "Records evidence-bound assessment for an available artifact.", + "input_contracts": ["rendered-artifact", "qc-checklist"], + "output_contract": "aigc-director-qc-report", + "evidence": "observed" + }, + { + "id": "coordination", + "skill_label": "aigc-project-prompt-loop", + "role": "Packages sanitized stage outputs into a workflow handoff.", + "input_contracts": ["shot-plan", "action-request", "prompt-pack", "qc-report"], + "output_contract": "aigc-director-skill-workflow", + "evidence": "designed" + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 4766c9e..651dbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "aigc-director-kit" -version = "0.2.2" +version = "0.2.3" description = "CineThread: deterministic creative handoffs for AI animation, storyboards, and previs." readme = "README.md" requires-python = ">=3.10" diff --git a/schemas/local-skill-adapter.v1.json b/schemas/local-skill-adapter.v1.json new file mode 100644 index 0000000..468d547 --- /dev/null +++ b/schemas/local-skill-adapter.v1.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:aigc-director-kit:local-skill-adapter:v1", + "title": "AIGC Director Local Skill Adapter v1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "version", + "adapter_id", + "visibility", + "source_access", + "workflow_contract", + "stages" + ], + "properties": { + "contract": {"const": "aigc-director-local-skill-adapter"}, + "version": {"const": 1}, + "adapter_id": {"$ref": "#/$defs/label"}, + "visibility": {"const": "public-metadata-only"}, + "source_access": {"const": "local-only"}, + "workflow_contract": {"const": "aigc-director-skill-workflow"}, + "stages": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": {"$ref": "#/$defs/stage"} + } + }, + "$defs": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "pattern": "^[a-z][a-z0-9-]{0,79}$" + }, + "stage": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "skill_label", + "role", + "input_contracts", + "output_contract", + "evidence" + ], + "properties": { + "id": {"$ref": "#/$defs/label"}, + "skill_label": {"$ref": "#/$defs/label"}, + "role": {"type": "string", "minLength": 1, "maxLength": 240}, + "input_contracts": { + "type": "array", + "minItems": 1, + "maxItems": 12, + "uniqueItems": true, + "items": {"$ref": "#/$defs/label"} + }, + "output_contract": {"$ref": "#/$defs/label"}, + "evidence": {"enum": ["observed", "inferred", "designed"]} + } + } + } +} diff --git a/src/aigc_director_kit/__init__.py b/src/aigc_director_kit/__init__.py index 08356fe..c29f316 100644 --- a/src/aigc_director_kit/__init__.py +++ b/src/aigc_director_kit/__init__.py @@ -1,6 +1,7 @@ """CineThread public Python API.""" from ._version import __version__ +from .adapter import validate_local_skill_adapter, validate_local_skill_adapter_file from .actions import compile_action_request, list_actions, load_action_library from .contract import ValidationResult, load_plan, validate_plan, validate_plan_file from .prompt import validate_prompt_pack, validate_prompt_pack_file @@ -18,6 +19,8 @@ "load_action_library", "load_plan", "run_public_example_verification", + "validate_local_skill_adapter", + "validate_local_skill_adapter_file", "validate_plan", "validate_plan_file", "validate_prompt_pack", diff --git a/src/aigc_director_kit/_version.py b/src/aigc_director_kit/_version.py index 8da975f..d56736a 100644 --- a/src/aigc_director_kit/_version.py +++ b/src/aigc_director_kit/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the public package version.""" -__version__ = "0.2.2" +__version__ = "0.2.3" diff --git a/src/aigc_director_kit/adapter.py b/src/aigc_director_kit/adapter.py new file mode 100644 index 0000000..9bd4eb5 --- /dev/null +++ b/src/aigc_director_kit/adapter.py @@ -0,0 +1,191 @@ +"""Validate public metadata-only manifests for local Skill integrations. + +An adapter manifest describes only the interface between a private/local Skill +stack and CineThread. It intentionally cannot carry prompts, source paths, +assets, credentials, generated media, or implementation code. A separate +skill-workflow packet carries sanitized, per-project handoff data. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .contract import SUPPORTED_EVIDENCE, ValidationResult, load_json +from .workflow import WORKFLOW_CONTRACT + + +ADAPTER_CONTRACT = "aigc-director-local-skill-adapter" +ADAPTER_VERSION = 1 +PUBLIC_METADATA_VISIBILITY = "public-metadata-only" +LOCAL_ONLY_SOURCE_ACCESS = "local-only" +ADAPTER_ID_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,79}$") +CONTRACT_LABEL_PATTERN = re.compile(r"^[a-z][a-z0-9-]{0,79}$") +SENSITIVE_VALUE_PATTERN = re.compile( + r"(?i)(?:api[_-]?key|access[_-]?token|secret|password|cookie|authorization|bearer\s|file://|[a-z]:[\\/]|\\\\)" +) +ROOT_FIELDS = frozenset( + { + "contract", + "version", + "adapter_id", + "visibility", + "source_access", + "workflow_contract", + "stages", + } +) +STAGE_FIELDS = frozenset( + { + "id", + "skill_label", + "role", + "input_contracts", + "output_contract", + "evidence", + } +) + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _reject_unknown_fields(value: dict[str, Any], allowed: frozenset[str], label: str, errors: list[str]) -> None: + unexpected = sorted(set(value) - allowed) + if unexpected: + fields = ", ".join(unexpected) + errors.append( + f"{label} contains unsupported fields: {fields}. " + "Public adapters may contain only metadata fields." + ) + + +def _validate_public_text(value: Any, label: str, errors: list[str], *, max_length: int = 240) -> None: + if not _text(value): + errors.append(f"{label} must be a non-empty string.") + return + if len(value) > max_length: + errors.append(f"{label} must be at most {max_length} characters.") + if SENSITIVE_VALUE_PATTERN.search(value): + errors.append( + f"{label} looks like it contains a credential or local source path; " + "publish labels only." + ) + + +def _validate_label(value: Any, label: str, errors: list[str]) -> None: + _validate_public_text(value, label, errors, max_length=80) + if isinstance(value, str) and not CONTRACT_LABEL_PATTERN.fullmatch(value): + errors.append(f"{label} must use lowercase letters, digits, and hyphens only.") + + +def validate_local_skill_adapter(adapter: Any) -> ValidationResult: + """Validate a public interface declaration for a local Skill stack. + + This only verifies manifest shape and coarse accidental-leak guards. It does + not inspect, execute, package, or make claims about a local Skill's output. + """ + + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(adapter, dict): + return ValidationResult(False, ["The local Skill adapter root must be a JSON object."], [], {}) + + _reject_unknown_fields(adapter, ROOT_FIELDS, "adapter", errors) + if adapter.get("contract") != ADAPTER_CONTRACT: + errors.append(f"contract must be '{ADAPTER_CONTRACT}'.") + if adapter.get("version") != ADAPTER_VERSION: + errors.append(f"version must be {ADAPTER_VERSION}.") + + adapter_id = adapter.get("adapter_id") + _validate_label(adapter_id, "adapter_id", errors) + if isinstance(adapter_id, str) and not ADAPTER_ID_PATTERN.fullmatch(adapter_id): + errors.append("adapter_id must use lowercase letters, digits, and hyphens only.") + + if adapter.get("visibility") != PUBLIC_METADATA_VISIBILITY: + errors.append(f"visibility must be '{PUBLIC_METADATA_VISIBILITY}'.") + if adapter.get("source_access") != LOCAL_ONLY_SOURCE_ACCESS: + errors.append(f"source_access must be '{LOCAL_ONLY_SOURCE_ACCESS}'.") + if adapter.get("workflow_contract") != WORKFLOW_CONTRACT: + errors.append(f"workflow_contract must be '{WORKFLOW_CONTRACT}'.") + + stages = adapter.get("stages") + stage_ids: set[str] = set() + skill_labels: list[str] = [] + output_contracts: list[str] = [] + input_contract_count = 0 + if not isinstance(stages, list) or not stages: + errors.append("stages must be a non-empty array.") + stages = [] + elif len(stages) > 32: + errors.append("stages cannot contain more than 32 entries.") + + for index, stage in enumerate(stages): + label = f"stages[{index}]" + if not isinstance(stage, dict): + errors.append(f"{label} must be an object.") + continue + _reject_unknown_fields(stage, STAGE_FIELDS, label, errors) + + stage_id = stage.get("id") + _validate_label(stage_id, f"{label}.id", errors) + if isinstance(stage_id, str): + if stage_id in stage_ids: + errors.append(f"{label}.id is duplicated: {stage_id}.") + else: + stage_ids.add(stage_id) + + skill_label = stage.get("skill_label") + _validate_label(skill_label, f"{label}.skill_label", errors) + if isinstance(skill_label, str) and CONTRACT_LABEL_PATTERN.fullmatch(skill_label): + skill_labels.append(skill_label) + + _validate_public_text(stage.get("role"), f"{label}.role", errors) + + input_contracts = stage.get("input_contracts") + if not isinstance(input_contracts, list) or not input_contracts: + errors.append(f"{label}.input_contracts must be a non-empty array.") + elif len(input_contracts) > 12: + errors.append(f"{label}.input_contracts cannot contain more than 12 entries.") + else: + seen_inputs: set[str] = set() + for input_index, input_contract in enumerate(input_contracts): + input_label = f"{label}.input_contracts[{input_index}]" + _validate_label(input_contract, input_label, errors) + if isinstance(input_contract, str): + if input_contract in seen_inputs: + errors.append(f"{input_label} is duplicated: {input_contract}.") + else: + seen_inputs.add(input_contract) + input_contract_count += len(input_contracts) + + output_contract = stage.get("output_contract") + _validate_label(output_contract, f"{label}.output_contract", errors) + if isinstance(output_contract, str) and CONTRACT_LABEL_PATTERN.fullmatch(output_contract): + output_contracts.append(output_contract) + + if stage.get("evidence") not in SUPPORTED_EVIDENCE: + errors.append(f"{label}.evidence must be one of {sorted(SUPPORTED_EVIDENCE)}.") + + return ValidationResult( + not errors, + errors, + warnings, + { + "stage_count": len(stages), + "skill_labels": skill_labels, + "input_contract_count": input_contract_count, + "output_contracts": output_contracts, + "visibility": adapter.get("visibility"), + "source_access": adapter.get("source_access"), + "workflow_contract": adapter.get("workflow_contract"), + }, + ) + + +def validate_local_skill_adapter_file(path: str | Path) -> ValidationResult: + """Load and validate a metadata-only local Skill adapter manifest.""" + + return validate_local_skill_adapter(load_json(path)) diff --git a/src/aigc_director_kit/cli.py b/src/aigc_director_kit/cli.py index 3f4e647..c6fb05e 100644 --- a/src/aigc_director_kit/cli.py +++ b/src/aigc_director_kit/cli.py @@ -9,6 +9,7 @@ from typing import Any from ._version import __version__ +from .adapter import validate_local_skill_adapter_file from .actions import compile_action_request, list_actions, load_action_library from .contract import validate_plan_file from .prompt import validate_prompt_pack_file @@ -39,6 +40,13 @@ def _build_parser() -> argparse.ArgumentParser: workflow.add_argument("--library", type=Path) workflow.add_argument("--json", action="store_true", dest="as_json") + adapter = subparsers.add_parser( + "validate-local-skill-adapter", + help="Validate a public metadata-only interface for local Skills.", + ) + adapter.add_argument("path", type=Path) + adapter.add_argument("--json", action="store_true", dest="as_json") + prompt = subparsers.add_parser( "validate-prompt-pack", help="Validate a reusable prompt pack, optionally against a shot plan.", @@ -135,6 +143,21 @@ def main(argv: list[str] | None = None) -> int: print(f"warning: {warning}") return 0 if result.valid else 2 + if args.command == "validate-local-skill-adapter": + result = validate_local_skill_adapter_file(args.path) + if args.as_json: + _dump(result.as_dict()) + else: + print(f"valid: {'yes' if result.valid else 'no'}") + print(f"stages: {result.summary.get('stage_count', 0)}") + print(f"skills: {len(result.summary.get('skill_labels', []))}") + print(f"workflow_contract: {result.summary.get('workflow_contract')}") + for error in result.errors: + print(f"error: {error}") + for warning in result.warnings: + print(f"warning: {warning}") + return 0 if result.valid else 2 + if args.command == "validate-prompt-pack": result = validate_prompt_pack_file(args.path, args.plan) if args.as_json: diff --git a/src/aigc_director_kit/verification.py b/src/aigc_director_kit/verification.py index ba7acc7..cc4cc14 100644 --- a/src/aigc_director_kit/verification.py +++ b/src/aigc_director_kit/verification.py @@ -10,6 +10,7 @@ from typing import Any from ._version import __version__ +from .adapter import validate_local_skill_adapter_file from .actions import compile_action_request, list_actions, load_action_library from .contract import ValidationResult, validate_plan_file from .prompt import validate_prompt_pack_file @@ -144,6 +145,12 @@ def run_public_example_verification(root: Path | None = None) -> dict[str, Any]: examples / "multi_actor_prop_case.json", lambda: validate_plan_file(examples / "multi_actor_prop_case.json"), ), + _validation_check( + "local-skill-adapter", + project_root, + examples / "local_skill_adapter_case.json", + lambda: validate_local_skill_adapter_file(examples / "local_skill_adapter_case.json"), + ), _validation_check( "skill-workflow", project_root, diff --git a/tests/test_adapter.py b/tests/test_adapter.py new file mode 100644 index 0000000..47ecc9d --- /dev/null +++ b/tests/test_adapter.py @@ -0,0 +1,60 @@ +import json +import unittest +from pathlib import Path + +from aigc_director_kit.adapter import ( + ADAPTER_CONTRACT, + validate_local_skill_adapter, + validate_local_skill_adapter_file, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class LocalSkillAdapterTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.adapter_path = ROOT / "examples" / "local_skill_adapter_case.json" + + def test_public_metadata_only_example_is_valid(self) -> None: + result = validate_local_skill_adapter_file(self.adapter_path) + + self.assertTrue(result.valid, result.errors) + self.assertEqual(result.summary["stage_count"], 5) + self.assertEqual(result.summary["visibility"], "public-metadata-only") + self.assertEqual(result.summary["source_access"], "local-only") + self.assertIn("aigc-output-qc", result.summary["skill_labels"]) + + def test_unknown_content_fields_are_rejected(self) -> None: + adapter = json.loads(self.adapter_path.read_text(encoding="utf-8")) + adapter["stages"][0]["prompt_text"] = "private prompt must not be published" + + result = validate_local_skill_adapter(adapter) + + self.assertFalse(result.valid) + self.assertTrue(any("unsupported fields" in error for error in result.errors)) + + def test_local_paths_are_rejected_even_in_an_allowed_text_field(self) -> None: + adapter = json.loads(self.adapter_path.read_text(encoding="utf-8")) + adapter["stages"][0]["role"] = r"Reads D:\private\brief.md before emitting metadata." + + result = validate_local_skill_adapter(adapter) + + self.assertFalse(result.valid) + self.assertTrue(any("credential or local source path" in error for error in result.errors)) + + def test_adapter_requires_the_public_metadata_boundary(self) -> None: + adapter = json.loads(self.adapter_path.read_text(encoding="utf-8")) + adapter["contract"] = "wrong-contract" + adapter["visibility"] = "private-export" + + result = validate_local_skill_adapter(adapter) + + self.assertFalse(result.valid) + self.assertEqual(ADAPTER_CONTRACT, "aigc-director-local-skill-adapter") + self.assertTrue(any("visibility" in error for error in result.errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index a07f2f1..0cb38d5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,6 +26,18 @@ def test_validate_plan_json_mode(self) -> None: # The CLI writes to stdout; this assertion exercises the actual command path. self.assertEqual(main(["validate-plan", str(ROOT / "examples" / "shot_plan.json"), "--json"]), 0) + def test_validate_local_skill_adapter_json_mode(self) -> None: + self.assertEqual( + main( + [ + "validate-local-skill-adapter", + str(ROOT / "examples" / "local_skill_adapter_case.json"), + "--json", + ] + ), + 0, + ) + def test_compile_action_refuses_overwrite(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: output = Path(temp_dir) / "action.json" diff --git a/tests/test_verification.py b/tests/test_verification.py index ec726b4..28da719 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -16,7 +16,7 @@ def test_verifies_all_public_examples_without_local_paths(self) -> None: self.assertTrue(report["valid"]) self.assertEqual(report["version"], __version__) - self.assertEqual(report["summary"], {"check_count": 11, "passed_check_count": 11}) + self.assertEqual(report["summary"], {"check_count": 12, "passed_check_count": 12}) self.assertNotIn(str(ROOT), json.dumps(report, ensure_ascii=False)) self.assertTrue(all(check["valid"] for check in report["checks"])) diff --git a/tests/test_version.py b/tests/test_version.py index f6e113e..5fb1ebd 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -13,7 +13,7 @@ def test_runtime_and_project_versions_match(self) -> None: project_text = (ROOT / "pyproject.toml").read_text(encoding="utf-8") project_version = re.search(r'^version\s*=\s*"([^"]+)"\s*$', project_text, re.MULTILINE) self.assertIsNotNone(project_version) - self.assertEqual(__version__, "0.2.2") + self.assertRegex(__version__, r"^\d+\.\d+\.\d+$") self.assertEqual(__version__, project_version.group(1))