ai-kit-python 1.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_kit_python-1.4.2.dist-info/METADATA +132 -0
- ai_kit_python-1.4.2.dist-info/RECORD +278 -0
- ai_kit_python-1.4.2.dist-info/WHEEL +5 -0
- ai_kit_python-1.4.2.dist-info/entry_points.txt +2 -0
- ai_kit_python-1.4.2.dist-info/licenses/LICENSE +21 -0
- ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/security-audit/LICENSE +21 -0
- ai_kit_python-1.4.2.dist-info/licenses/template/ai-kit/vendor/strix/LICENSE +201 -0
- ai_kit_python-1.4.2.dist-info/top_level.txt +1 -0
- aikit/.agents/skills/go-work/SKILL.md +8 -0
- aikit/.agents/skills/laravel-work/SKILL.md +8 -0
- aikit/.agents/skills/moodle-work/SKILL.md +8 -0
- aikit/.agents/skills/project-continuity/SKILL.md +34 -0
- aikit/.agents/skills/python-work/SKILL.md +8 -0
- aikit/.dockerignore +90 -0
- aikit/.gitattributes +3 -0
- aikit/.github/workflows/check-kit.yml +70 -0
- aikit/.github/workflows/publish-pypi.yml +108 -0
- aikit/.gitignore +60 -0
- aikit/BOOTSTRAP_PROMPT.md +18 -0
- aikit/CHANGELOG.md +85 -0
- aikit/CLAUDE.md +3 -0
- aikit/CONTRIBUTING.md +46 -0
- aikit/Dockerfile +44 -0
- aikit/KIMI.md +3 -0
- aikit/LICENSE +21 -0
- aikit/MANIFEST.in +22 -0
- aikit/MANUS.md +3 -0
- aikit/Makefile +70 -0
- aikit/README.md +1161 -0
- aikit/SECURITY.md +8 -0
- aikit/SKILL.md +105 -0
- aikit/VERSION +1 -0
- aikit/__init__.py +1 -0
- aikit/ai-kit/ADAPTERS.md +5 -0
- aikit/ai-kit/CONTAINERS.md +5 -0
- aikit/ai-kit/ENGINEERING.md +5 -0
- aikit/ai-kit/INTENT.md +5 -0
- aikit/ai-kit/MODELS.md +5 -0
- aikit/ai-kit/SKILLS.md +5 -0
- aikit/ai-kit/stacks/GO.md +5 -0
- aikit/ai-kit/stacks/LARAVEL.md +5 -0
- aikit/ai-kit/stacks/MOODLE.md +5 -0
- aikit/ai-kit/stacks/PYTHON.md +5 -0
- aikit/aikit_cli.py +59 -0
- aikit/deploy/k8s/install-preview-job.yaml +56 -0
- aikit/docker/build-requirements.txt +4 -0
- aikit/docker/check_version.py +29 -0
- aikit/docker/pypi-requirements.txt +3 -0
- aikit/docs/ADAPTER_PATH_FIX.md +23 -0
- aikit/docs/BENCHMARK.md +106 -0
- aikit/docs/CONTEXT7.md +33 -0
- aikit/docs/DECISIONS.md +22 -0
- aikit/docs/DEVELOPMENT_PLAN.md +16 -0
- aikit/docs/DIALOGUE_MEMORY.md +70 -0
- aikit/docs/DOCKER.md +74 -0
- aikit/docs/DOCKER_HUB_OVERVIEW.md +311 -0
- aikit/docs/EVALUATION.md +30 -0
- aikit/docs/IMPLEMENTATION.md +134 -0
- aikit/docs/MANUS_REVIEW.md +46 -0
- aikit/docs/PYPI.md +87 -0
- aikit/docs/PYPI_DESCRIPTION.md +103 -0
- aikit/docs/SECURITY_AUDIT.md +26 -0
- aikit/docs/STRIX.md +28 -0
- aikit/docs/V0_5_0.md +27 -0
- aikit/docs/V0_6_4.md +25 -0
- aikit/docs/V1_4_0.md +73 -0
- aikit/docs/V1_4_1.md +55 -0
- aikit/docs/V1_4_2.md +33 -0
- aikit/docs/WEB_DESIGN.md +72 -0
- aikit/docs/WINDOWS_CI_FIX.md +39 -0
- aikit/docs/adr/0000-template.md +24 -0
- aikit/docs/adr/0001-adaptive-model-routing.md +30 -0
- aikit/docs/adr/0002-template-separation.md +24 -0
- aikit/docs/adr/0003-agent-registry-and-router.md +24 -0
- aikit/docs/adr/0004-onboarding-and-project-facts.md +24 -0
- aikit/docs/adr/0005-client-settings-ownership.md +30 -0
- aikit/docs/adr/0006-routing-evidence-and-local-measurements.md +26 -0
- aikit/docs/adr/0007-generated-client-configuration-and-presets.md +34 -0
- aikit/docs/adr/0008-unified-cli-and-fact-drift.md +20 -0
- aikit/docs/adr/0009-provider-review-doctor-fix-and-ci-self-install.md +20 -0
- aikit/docs/adr/0010-metrics-analysis-and-gemini-verification.md +20 -0
- aikit/docs/adr/0011-evidence-lifecycle-and-evaluation.md +20 -0
- aikit/docs/adr/0012-measurement-led-routing.md +31 -0
- aikit/docs/adr/0013-docker-cli-image.md +24 -0
- aikit/docs/adr/0014-local-dialogue-memory.md +22 -0
- aikit/docs/history/original-bundle-checks.md +11 -0
- aikit/docs/history/v0.1-changelog.txt +37 -0
- aikit/docs/history/v0.10-changelog.md +12 -0
- aikit/docs/history/v0.2-changelog.md +35 -0
- aikit/docs/history/v0.3-changelog.md +25 -0
- aikit/docs/history/v0.4-changelog.md +31 -0
- aikit/docs/history/v0.5-changelog.md +14 -0
- aikit/docs/history/v0.6.4-changelog.md +12 -0
- aikit/docs/history/v0.6.5-v0.9-changelog.md +35 -0
- aikit/docs/history/v1.1.0-changelog.md +12 -0
- aikit/docs/history/v1.2.0-changelog.md +11 -0
- aikit/docs/history/verification-before-measurements.md +53 -0
- aikit/integrations/CONVENTIONS.md +3 -0
- aikit/integrations/README.md +7 -0
- aikit/integrations/agents.json +136 -0
- aikit/integrations/ai-kit-check.yml +66 -0
- aikit/integrations/ai-kit-cline.md +4 -0
- aikit/integrations/ai-kit-roo.md +4 -0
- aikit/integrations/ai-kit-windsurf.md +4 -0
- aikit/integrations/ai-kit.mdc +6 -0
- aikit/integrations/claude-settings.data-ask.json +23 -0
- aikit/integrations/claude-settings.git-ask.json +10 -0
- aikit/integrations/copilot-instructions.md +3 -0
- aikit/integrations/security-audit.json +135 -0
- aikit/integrations/strix.json +69 -0
- aikit/pyproject.toml +60 -0
- aikit/scripts/__init__.py +1 -0
- aikit/scripts/adapters.py +184 -0
- aikit/scripts/adaptive.py +167 -0
- aikit/scripts/adr.py +61 -0
- aikit/scripts/benchmark.py +543 -0
- aikit/scripts/changelog.py +55 -0
- aikit/scripts/check_distribution.py +178 -0
- aikit/scripts/check_kit.py +406 -0
- aikit/scripts/context.py +184 -0
- aikit/scripts/docker_test_usage.py +55 -0
- aikit/scripts/doctor.py +313 -0
- aikit/scripts/evaluate.py +125 -0
- aikit/scripts/install.py +1584 -0
- aikit/scripts/learning.py +260 -0
- aikit/scripts/memory.py +344 -0
- aikit/scripts/memory_import.py +177 -0
- aikit/scripts/memory_store.py +125 -0
- aikit/scripts/metrics.py +351 -0
- aikit/scripts/router.py +399 -0
- aikit/template/.agents/hooks/session-start.md +3 -0
- aikit/template/.agents/hooks/session-start.sh +21 -0
- aikit/template/.agents/skills/ai-kit-bootstrap/SKILL.md +14 -0
- aikit/template/.agents/skills/ai-kit-review/SKILL.md +11 -0
- aikit/template/.agents/skills/ai-kit-sync-context/SKILL.md +12 -0
- aikit/template/.agents/skills/context7-docs/SKILL.md +11 -0
- aikit/template/.agents/skills/design-extract/SKILL.md +9 -0
- aikit/template/.agents/skills/dotnet-work/SKILL.md +9 -0
- aikit/template/.agents/skills/filament-work/SKILL.md +9 -0
- aikit/template/.agents/skills/frontend-work/SKILL.md +9 -0
- aikit/template/.agents/skills/go-work/SKILL.md +9 -0
- aikit/template/.agents/skills/infra-work/SKILL.md +9 -0
- aikit/template/.agents/skills/java-work/SKILL.md +9 -0
- aikit/template/.agents/skills/laravel-blade-ui/SKILL.md +9 -0
- aikit/template/.agents/skills/laravel-work/SKILL.md +10 -0
- aikit/template/.agents/skills/library-work/SKILL.md +9 -0
- aikit/template/.agents/skills/moodle-work/SKILL.md +9 -0
- aikit/template/.agents/skills/php-work/SKILL.md +9 -0
- aikit/template/.agents/skills/project-continuity/SKILL.md +21 -0
- aikit/template/.agents/skills/python-work/SKILL.md +9 -0
- aikit/template/.agents/skills/rust-work/SKILL.md +9 -0
- aikit/template/.agents/skills/security-audit/SKILL.md +16 -0
- aikit/template/.agents/skills/security-audit/agents/openai.yaml +2 -0
- aikit/template/.agents/skills/strix/SKILL.md +16 -0
- aikit/template/.agents/skills/strix/agents/openai.yaml +2 -0
- aikit/template/.agents/skills/ui-review/SKILL.md +9 -0
- aikit/template/.agents/skills/web-design/SKILL.md +9 -0
- aikit/template/AGENTS.md +16 -0
- aikit/template/CHANGELOG.md +6 -0
- aikit/template/CLAUDE.md +5 -0
- aikit/template/GEMINI.md +4 -0
- aikit/template/KIMI.md +4 -0
- aikit/template/MANUS.md +4 -0
- aikit/template/PROJECT_CONTEXT.md +35 -0
- aikit/template/README.md +6 -0
- aikit/template/WIKI.md +37 -0
- aikit/template/ai-kit/ADAPTERS.md +100 -0
- aikit/template/ai-kit/BOOTSTRAP.md +15 -0
- aikit/template/ai-kit/CONTAINERS.md +63 -0
- aikit/template/ai-kit/CONTEXT7.md +96 -0
- aikit/template/ai-kit/CORE.md +23 -0
- aikit/template/ai-kit/ENGINEERING.md +34 -0
- aikit/template/ai-kit/MCP.md +17 -0
- aikit/template/ai-kit/METRICS.md +83 -0
- aikit/template/ai-kit/SECURITY-AUDIT.md +21 -0
- aikit/template/ai-kit/SECURITY.md +34 -0
- aikit/template/ai-kit/SKILLS.md +37 -0
- aikit/template/ai-kit/STRIX.md +44 -0
- aikit/template/ai-kit/UPSTREAM.md +38 -0
- aikit/template/ai-kit/WEB-DESIGN.md +37 -0
- aikit/template/ai-kit/profiles/OWNER.md +19 -0
- aikit/template/ai-kit/project.json +14 -0
- aikit/template/ai-kit/router/POLICY.md +79 -0
- aikit/template/ai-kit/router/providers/anthropic.json +44 -0
- aikit/template/ai-kit/router/providers/anthropic.md +14 -0
- aikit/template/ai-kit/router/providers/gemini.json +13 -0
- aikit/template/ai-kit/router/providers/gemini.md +10 -0
- aikit/template/ai-kit/router/providers/kimi.json +26 -0
- aikit/template/ai-kit/router/providers/kimi.md +13 -0
- aikit/template/ai-kit/router/providers/local.json +18 -0
- aikit/template/ai-kit/router/providers/local.md +10 -0
- aikit/template/ai-kit/router/providers/openai.json +48 -0
- aikit/template/ai-kit/router/providers/openai.md +14 -0
- aikit/template/ai-kit/router/routing.json +11 -0
- aikit/template/ai-kit/router/selection.json +14 -0
- aikit/template/ai-kit/settings.json +11 -0
- aikit/template/ai-kit/stacks/DOTNET.md +13 -0
- aikit/template/ai-kit/stacks/FILAMENT.md +18 -0
- aikit/template/ai-kit/stacks/FRONTEND.md +9 -0
- aikit/template/ai-kit/stacks/GO.md +19 -0
- aikit/template/ai-kit/stacks/INFRA.md +12 -0
- aikit/template/ai-kit/stacks/JAVA.md +13 -0
- aikit/template/ai-kit/stacks/LARAVEL.md +15 -0
- aikit/template/ai-kit/stacks/LIBRARY.md +9 -0
- aikit/template/ai-kit/stacks/MOODLE.md +12 -0
- aikit/template/ai-kit/stacks/PHP.md +11 -0
- aikit/template/ai-kit/stacks/PYTHON.md +11 -0
- aikit/template/ai-kit/stacks/RUST.md +18 -0
- aikit/template/ai-kit/vendor/security-audit/AI-AND-LLM.md +83 -0
- aikit/template/ai-kit/vendor/security-audit/ATTACK-CLASSES.md +130 -0
- aikit/template/ai-kit/vendor/security-audit/CLIENT-SIDE.md +83 -0
- aikit/template/ai-kit/vendor/security-audit/CLOUD-AND-DEPLOYMENT.md +86 -0
- aikit/template/ai-kit/vendor/security-audit/DATA-ISOLATION-AND-LIFECYCLE.md +84 -0
- aikit/template/ai-kit/vendor/security-audit/DESKTOP-MOBILE-AND-LOCAL-IPC.md +89 -0
- aikit/template/ai-kit/vendor/security-audit/HUNTING.md +251 -0
- aikit/template/ai-kit/vendor/security-audit/LICENSE +21 -0
- aikit/template/ai-kit/vendor/security-audit/MEMORY-SAFETY-AND-BINARY.md +101 -0
- aikit/template/ai-kit/vendor/security-audit/PROTOCOLS-RPC-AND-MESSAGING.md +81 -0
- aikit/template/ai-kit/vendor/security-audit/RECONNAISSANCE.md +156 -0
- aikit/template/ai-kit/vendor/security-audit/RESOURCE-EXHAUSTION-AND-AVAILABILITY.md +78 -0
- aikit/template/ai-kit/vendor/security-audit/SKILL.md +192 -0
- aikit/template/ai-kit/vendor/security-audit/SUPPLY-CHAIN-AND-RELEASE.md +73 -0
- aikit/template/ai-kit/vendor/security-audit/VALIDATION-AND-REPORTING.md +186 -0
- aikit/template/ai-kit/vendor/security-audit/WEB-PROTOCOL-AND-AUTH.md +105 -0
- aikit/template/ai-kit/vendor/security-audit/report-schema.json +461 -0
- aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.cjs +872 -0
- aikit/template/ai-kit/vendor/security-audit/validate-coverage-ledger.test.cjs +740 -0
- aikit/template/ai-kit/vendor/security-audit/validate-findings.cjs +773 -0
- aikit/template/ai-kit/vendor/security-audit/validate-findings.test.cjs +652 -0
- aikit/template/ai-kit/vendor/strix/LICENSE +201 -0
- aikit/template/ai-kit/vendor/strix/api-security-testing/SKILL.md +61 -0
- aikit/template/ai-kit/vendor/strix/application-security-testing/SKILL.md +66 -0
- aikit/template/ai-kit/vendor/strix/ci-security-scanning-with-strix/SKILL.md +149 -0
- aikit/template/ai-kit/vendor/strix/find-security-vulnerabilities-in-code/SKILL.md +62 -0
- aikit/template/ai-kit/vendor/strix/fix-security-vulnerabilities-with-strix/SKILL.md +77 -0
- aikit/template/ai-kit/vendor/strix/managed-pentesting-with-strix/SKILL.md +321 -0
- aikit/template/ai-kit/vendor/strix/owasp-top-10-testing/SKILL.md +64 -0
- aikit/template/ai-kit/vendor/strix/penetration-testing-with-strix/SKILL.md +162 -0
- aikit/template/ai-kit/vendor/strix/web-app-penetration-testing/SKILL.md +54 -0
- aikit/template/ai-kit/web-design/LARAVEL.md +51 -0
- aikit/template/ai-kit/web-design/LIBRARIES.md +39 -0
- aikit/template/ai-kit/web-design/REVIEW.md +37 -0
- aikit/template/ai-kit/web-design/TOOLS.md +43 -0
- aikit/template/docs/DECISIONS.md +7 -0
- aikit/template/docs/adr/0000-template.md +20 -0
- aikit/template/docs/design/ACCEPTANCE.md +24 -0
- aikit/template/docs/design/BRIEF.md +26 -0
- aikit/template/docs/design/SYSTEM.md +30 -0
- aikit/templates/agent-secrets.ignore +19 -0
- aikit/templates/gitignore.private +160 -0
- aikit/templates/gitignore.team +139 -0
- aikit/tests/build_distribution.py +73 -0
- aikit/tests/docker_smoke.py +172 -0
- aikit/tests/fixtures/create-junction.ps1 +18 -0
- aikit/tests/fixtures/golden/claude-settings-local.json +58 -0
- aikit/tests/test_adapters.py +105 -0
- aikit/tests/test_aikit.py +79 -0
- aikit/tests/test_benchmark.py +241 -0
- aikit/tests/test_check_kit.py +203 -0
- aikit/tests/test_context.py +76 -0
- aikit/tests/test_context7.py +153 -0
- aikit/tests/test_context_selection.py +70 -0
- aikit/tests/test_distribution.py +181 -0
- aikit/tests/test_docker_image.py +63 -0
- aikit/tests/test_docker_test_usage.py +54 -0
- aikit/tests/test_doctor.py +148 -0
- aikit/tests/test_evaluate.py +66 -0
- aikit/tests/test_install.py +939 -0
- aikit/tests/test_learning.py +247 -0
- aikit/tests/test_memory.py +371 -0
- aikit/tests/test_metrics.py +209 -0
- aikit/tests/test_native.py +342 -0
- aikit/tests/test_onboarding.py +301 -0
- aikit/tests/test_router.py +336 -0
- aikit/tests/test_security_audit.py +237 -0
- aikit/tests/test_strix.py +245 -0
- aikit/tests/test_web_design.py +219 -0
- aikit/tests/wheel_smoke.py +104 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Exercise onboarding (interactive/check), project facts, and the adr/changelog helpers."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
13
|
+
import adr
|
|
14
|
+
import changelog
|
|
15
|
+
import install
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DetectFactsTests(unittest.TestCase):
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-facts-")
|
|
21
|
+
self.addCleanup(self.temp.cleanup)
|
|
22
|
+
self.target = Path(self.temp.name).resolve() / "project"
|
|
23
|
+
self.target.mkdir()
|
|
24
|
+
|
|
25
|
+
def write(self, path, data):
|
|
26
|
+
destination = self.target / path
|
|
27
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
destination.write_text(data, encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
def test_go_version_and_commands(self):
|
|
31
|
+
self.write("go.mod", "module example.test/app\n\ngo 1.22\n")
|
|
32
|
+
module = install.detect_facts(self.target)["modules"][0]
|
|
33
|
+
self.assertEqual(module["path"], "/")
|
|
34
|
+
self.assertEqual(module["language"], "Go")
|
|
35
|
+
self.assertEqual(module["version"], "1.22")
|
|
36
|
+
self.assertEqual(module["profiles"], ["GO"])
|
|
37
|
+
self.assertEqual(module["commands"]["test"], "go test ./...")
|
|
38
|
+
|
|
39
|
+
def test_python_requires_and_commands(self):
|
|
40
|
+
self.write("pyproject.toml", '[project]\nname = "svc"\nrequires-python = ">=3.10"\n\n'
|
|
41
|
+
'[tool.pytest.ini_options]\ntestpaths = ["tests"]\n\n[tool.ruff]\n')
|
|
42
|
+
module = install.detect_facts(self.target)["modules"][0]
|
|
43
|
+
self.assertEqual(module["language"], "Python")
|
|
44
|
+
self.assertEqual(module["version"], ">=3.10")
|
|
45
|
+
self.assertEqual(module["commands"]["test"], "pytest")
|
|
46
|
+
self.assertEqual(module["commands"]["lint"], "ruff check .")
|
|
47
|
+
|
|
48
|
+
def test_python_without_tool_evidence_suggests_no_commands(self):
|
|
49
|
+
self.write("pyproject.toml", '[project]\nname = "svc"\n')
|
|
50
|
+
self.write("tests/test_app.py", "import unittest\n")
|
|
51
|
+
self.assertEqual(install.detect_facts(self.target)["modules"][0]["commands"], {})
|
|
52
|
+
|
|
53
|
+
def test_python_tools_from_requirements_and_runner_from_lockfile(self):
|
|
54
|
+
self.write("pyproject.toml", '[project]\nname = "svc"\n')
|
|
55
|
+
self.write("requirements-dev.txt", "pytest==8.3.0\nruff\n")
|
|
56
|
+
self.write("uv.lock", "version = 1\n")
|
|
57
|
+
commands = install.detect_facts(self.target)["modules"][0]["commands"]
|
|
58
|
+
self.assertEqual(commands, {"test": "uv run pytest", "lint": "uv run ruff check ."})
|
|
59
|
+
|
|
60
|
+
def test_python_config_files_are_tool_evidence(self):
|
|
61
|
+
self.write("requirements.txt", "requests\n")
|
|
62
|
+
self.write("conftest.py", "")
|
|
63
|
+
self.write("ruff.toml", "line-length = 100\n")
|
|
64
|
+
self.write("poetry.lock", "")
|
|
65
|
+
commands = install.detect_facts(self.target)["modules"][0]["commands"]
|
|
66
|
+
self.assertEqual(commands, {"test": "poetry run pytest", "lint": "poetry run ruff check ."})
|
|
67
|
+
|
|
68
|
+
def test_frontend_commands_from_scripts(self):
|
|
69
|
+
self.write("package.json", json.dumps({"scripts": {"test": "jest", "build": "tsc"}}))
|
|
70
|
+
module = install.detect_facts(self.target)["modules"][0]
|
|
71
|
+
self.assertEqual(module["commands"]["test"], "npm test")
|
|
72
|
+
self.assertEqual(module["commands"]["build"], "npm run build")
|
|
73
|
+
|
|
74
|
+
def test_node_manager_from_lockfile(self):
|
|
75
|
+
self.write("package.json", json.dumps({"scripts": {"test": "vitest", "build": "vite build"}}))
|
|
76
|
+
self.write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n")
|
|
77
|
+
commands = install.detect_facts(self.target)["modules"][0]["commands"]
|
|
78
|
+
self.assertEqual(commands, {"test": "pnpm test", "build": "pnpm run build"})
|
|
79
|
+
|
|
80
|
+
def test_bun_runs_the_test_script_not_its_own_runner(self):
|
|
81
|
+
self.write("package.json", json.dumps({"scripts": {"test": "vitest", "lint": "eslint ."}}))
|
|
82
|
+
self.write("bun.lock", "{}\n")
|
|
83
|
+
commands = install.detect_facts(self.target)["modules"][0]["commands"]
|
|
84
|
+
self.assertEqual(commands, {"test": "bun run test", "lint": "bun run lint"})
|
|
85
|
+
|
|
86
|
+
def test_package_manager_field_wins_over_lockfile(self):
|
|
87
|
+
self.write("package.json", json.dumps({"packageManager": "yarn@4.5.0", "scripts": {"test": "jest"}}))
|
|
88
|
+
self.write("package-lock.json", "{}\n")
|
|
89
|
+
self.assertEqual(install.detect_facts(self.target)["modules"][0]["commands"]["test"], "yarn test")
|
|
90
|
+
|
|
91
|
+
def test_workspace_root_lockfile_applies_to_nested_package(self):
|
|
92
|
+
self.write("package.json", json.dumps({"private": True}))
|
|
93
|
+
self.write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n")
|
|
94
|
+
self.write("packages/web/package.json", json.dumps({"scripts": {"test": "vitest"}}))
|
|
95
|
+
modules = {m["path"]: m for m in install.detect_facts(self.target)["modules"]}
|
|
96
|
+
self.assertEqual(modules["/packages/web/"]["commands"]["test"], "pnpm test")
|
|
97
|
+
|
|
98
|
+
def test_composer_and_laravel(self):
|
|
99
|
+
self.write("composer.json", json.dumps({"require": {"php": "^8.2"}}))
|
|
100
|
+
self.write("artisan", "#!/usr/bin/env php\n")
|
|
101
|
+
module = install.detect_facts(self.target)["modules"][0]
|
|
102
|
+
self.assertEqual(module["profiles"], ["LARAVEL", "PHP"])
|
|
103
|
+
self.assertEqual(module["version"], "^8.2")
|
|
104
|
+
self.assertEqual(module["commands"]["test"], "php artisan test")
|
|
105
|
+
|
|
106
|
+
def test_filament_direct_dependencies_and_module_scope(self):
|
|
107
|
+
self.write("admin/composer.json", json.dumps({"require": {"laravel/framework": "^12.0",
|
|
108
|
+
"filament/filament": "^5.0"}}))
|
|
109
|
+
self.write("admin/artisan", "#!/usr/bin/env php\n")
|
|
110
|
+
self.write("worker/composer.json", json.dumps({"require": {"php": "^8.2"}}))
|
|
111
|
+
facts = {module["path"]: module for module in install.detect_facts(self.target)["modules"]}
|
|
112
|
+
self.assertEqual(facts["/admin/"]["profiles"], ["FILAMENT", "LARAVEL", "PHP"])
|
|
113
|
+
self.assertEqual(facts["/worker/"]["profiles"], ["PHP"])
|
|
114
|
+
self.assertEqual(install.detect_profiles(self.target)["FILAMENT"], ["admin/"])
|
|
115
|
+
|
|
116
|
+
def test_filament_component_and_lock_evidence(self):
|
|
117
|
+
self.write("composer.json", json.dumps({"require-dev": {"filament/forms": "^4.0"}}))
|
|
118
|
+
self.assertEqual(install.detect_facts(self.target)["modules"][0]["profiles"], ["FILAMENT", "PHP"])
|
|
119
|
+
self.write("composer.json", "{}\n")
|
|
120
|
+
self.write("composer.lock", json.dumps({"packages": [7, {}, {"name": []}],
|
|
121
|
+
"packages-dev": [{"name": "filament/tables", "version": "v5.0.0"}]}))
|
|
122
|
+
facts = install.detect_facts(self.target)["modules"][0]
|
|
123
|
+
self.assertEqual(facts["profiles"], ["FILAMENT", "PHP"])
|
|
124
|
+
self.assertEqual(facts["manifests"], ["composer.json", "composer.lock"])
|
|
125
|
+
self.assertIsNone(facts["version"])
|
|
126
|
+
|
|
127
|
+
def test_filament_name_or_wrapper_is_not_dependency_evidence(self):
|
|
128
|
+
self.write("composer.json", json.dumps({"description": "Filament toolkit",
|
|
129
|
+
"require": {"example/filament-plugin": "*"}}))
|
|
130
|
+
self.write("app/Filament/Resources/Placeholder.php", "<?php\n")
|
|
131
|
+
self.write("composer.lock", "{broken\n")
|
|
132
|
+
self.assertEqual(install.detect_profiles(self.target), {"PHP": [""]})
|
|
133
|
+
|
|
134
|
+
def test_filament_lock_probe_does_not_follow_links(self):
|
|
135
|
+
with mock.patch.object(install, "is_link", return_value=True), mock.patch.object(
|
|
136
|
+
install, "_manifest_json") as read_manifest:
|
|
137
|
+
self.assertEqual(install._filament_manifests(self.target, {}), [])
|
|
138
|
+
read_manifest.assert_not_called()
|
|
139
|
+
|
|
140
|
+
def test_subdirectory_prefix(self):
|
|
141
|
+
self.write("api/go.mod", "module example.test/api\n")
|
|
142
|
+
module = install.detect_facts(self.target)["modules"][0]
|
|
143
|
+
self.assertEqual(module["path"], "/api/")
|
|
144
|
+
self.assertEqual(install.detect_profiles(self.target), {"GO": ["api/"]})
|
|
145
|
+
|
|
146
|
+
def test_no_manifests_yield_no_modules(self):
|
|
147
|
+
self.write("notes.txt", "nothing\n")
|
|
148
|
+
self.assertEqual(install.detect_facts(self.target)["modules"], [])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ProjectJsonTests(unittest.TestCase):
|
|
152
|
+
def setUp(self):
|
|
153
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-pjson-")
|
|
154
|
+
self.addCleanup(self.temp.cleanup)
|
|
155
|
+
self.base = Path(self.temp.name).resolve()
|
|
156
|
+
self.target = self.base / "project"
|
|
157
|
+
self.source = self.base / "reference"
|
|
158
|
+
shutil.copytree(install.ROOT, self.source, ignore=shutil.ignore_patterns("__pycache__", ".git"))
|
|
159
|
+
|
|
160
|
+
def write(self, path, data):
|
|
161
|
+
destination = self.target / path
|
|
162
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
destination.write_text(data, encoding="utf-8")
|
|
164
|
+
|
|
165
|
+
def apply(self, **options):
|
|
166
|
+
plan = install.build_plan(self.target, root=self.source, **options)
|
|
167
|
+
self.assertTrue(install.apply_plan(plan))
|
|
168
|
+
return plan
|
|
169
|
+
|
|
170
|
+
def test_project_json_drafted_on_fresh_install(self):
|
|
171
|
+
self.write("go.mod", "module example.test/app\n\ngo 1.21\n")
|
|
172
|
+
self.apply()
|
|
173
|
+
value = json.loads((self.target / "ai-kit/project.json").read_text(encoding="utf-8"))
|
|
174
|
+
self.assertEqual(value["schema"], 1)
|
|
175
|
+
self.assertEqual(len(value["modules"]), 1)
|
|
176
|
+
self.assertEqual(value["modules"][0]["language"], "Go")
|
|
177
|
+
self.assertEqual(value["modules"][0]["version"], "1.21")
|
|
178
|
+
|
|
179
|
+
def test_project_json_preserved_when_existing(self):
|
|
180
|
+
existing = json.dumps({"schema": 1, "modules": [], "operations": {}}) + "\n"
|
|
181
|
+
self.write("ai-kit/project.json", existing)
|
|
182
|
+
self.write("go.mod", "module example.test/app\n")
|
|
183
|
+
self.apply()
|
|
184
|
+
self.assertEqual((self.target / "ai-kit/project.json").read_text(encoding="utf-8"), existing)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class InteractiveTests(unittest.TestCase):
|
|
188
|
+
def test_selections_are_collected(self):
|
|
189
|
+
responses = iter(["", "team", "English", "standard", "codex,claude", "ci", "y"])
|
|
190
|
+
read = lambda prompt: next(responses)
|
|
191
|
+
selections = install.interactive_selections(read, agents_available={"codex", "claude"},
|
|
192
|
+
facts={"modules": []})
|
|
193
|
+
self.assertEqual(selections, {"preset": None, "mode": "team", "language": "English",
|
|
194
|
+
"conventions": "standard", "agents": ["codex", "claude"],
|
|
195
|
+
"extras": ["ci"]})
|
|
196
|
+
|
|
197
|
+
def test_defaults_apply_when_answers_are_blank(self):
|
|
198
|
+
responses = iter(["", "", "", "", "", "", "y"])
|
|
199
|
+
read = lambda prompt: next(responses)
|
|
200
|
+
selections = install.interactive_selections(read, agents_available={"codex"}, facts={"modules": []})
|
|
201
|
+
self.assertEqual(selections["mode"], "private")
|
|
202
|
+
self.assertEqual(selections["agents"], ["codex"])
|
|
203
|
+
self.assertEqual(selections["extras"], [])
|
|
204
|
+
self.assertIsNone(selections["preset"])
|
|
205
|
+
|
|
206
|
+
def test_preset_answer_sets_mode_and_extras_defaults(self):
|
|
207
|
+
responses = iter(["team", "", "", "", "", "", "y"])
|
|
208
|
+
prompts = []
|
|
209
|
+
|
|
210
|
+
def read(prompt):
|
|
211
|
+
prompts.append(prompt)
|
|
212
|
+
return next(responses)
|
|
213
|
+
|
|
214
|
+
selections = install.interactive_selections(read, agents_available={"codex"}, facts={"modules": []})
|
|
215
|
+
self.assertEqual(selections["preset"], "team")
|
|
216
|
+
self.assertEqual(selections["mode"], "team")
|
|
217
|
+
self.assertEqual(selections["extras"], list(install.PRESETS["team"]["extras"]))
|
|
218
|
+
self.assertIn("(team)", prompts[1])
|
|
219
|
+
|
|
220
|
+
def test_detected_clients_become_the_default_agent_answer(self):
|
|
221
|
+
responses = iter(["", "", "", "", "", "", "y"])
|
|
222
|
+
read = lambda prompt: next(responses)
|
|
223
|
+
output = io.StringIO()
|
|
224
|
+
with contextlib.redirect_stdout(output):
|
|
225
|
+
selections = install.interactive_selections(read, agents_available={"codex", "claude"},
|
|
226
|
+
facts={"modules": []},
|
|
227
|
+
detected_agents={"claude": ["CLAUDE.md"]})
|
|
228
|
+
self.assertEqual(selections["agents"], ["claude", "codex"])
|
|
229
|
+
self.assertIn("Detected client: claude (CLAUDE.md)", output.getvalue())
|
|
230
|
+
|
|
231
|
+
def test_decline_returns_none(self):
|
|
232
|
+
responses = iter(["", "private", "Russian", "owner", "codex", "", "n"])
|
|
233
|
+
read = lambda prompt: next(responses)
|
|
234
|
+
self.assertIsNone(install.interactive_selections(read, agents_available={"codex"},
|
|
235
|
+
facts={"modules": []}))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class CheckAfterApplyTests(unittest.TestCase):
|
|
239
|
+
def setUp(self):
|
|
240
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-check-")
|
|
241
|
+
self.addCleanup(self.temp.cleanup)
|
|
242
|
+
self.target = Path(self.temp.name).resolve() / "project"
|
|
243
|
+
|
|
244
|
+
def test_check_runs_doctor_after_apply(self):
|
|
245
|
+
output = io.StringIO()
|
|
246
|
+
with contextlib.redirect_stdout(output):
|
|
247
|
+
self.assertEqual(install.main([str(self.target), "--apply", "--check"]), 0)
|
|
248
|
+
self.assertIn('"doctor"', output.getvalue())
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class AdrTests(unittest.TestCase):
|
|
252
|
+
def setUp(self):
|
|
253
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-adr-")
|
|
254
|
+
self.addCleanup(self.temp.cleanup)
|
|
255
|
+
self.root = Path(self.temp.name).resolve() / "project"
|
|
256
|
+
adr_dir = self.root / "docs" / "adr"
|
|
257
|
+
adr_dir.mkdir(parents=True)
|
|
258
|
+
(adr_dir / "0000-template.md").write_text(
|
|
259
|
+
"# ADR-NNNN: Title\n\n- Date: use the verified decision date\n"
|
|
260
|
+
"- Status: proposed / accepted / superseded\n", encoding="utf-8")
|
|
261
|
+
|
|
262
|
+
def test_scaffold_creates_numbered_adr(self):
|
|
263
|
+
path = adr.scaffold("Use Postgres", self.root)
|
|
264
|
+
self.assertEqual(path.name, "0001-use-postgres.md")
|
|
265
|
+
content = path.read_text(encoding="utf-8")
|
|
266
|
+
self.assertIn("# ADR-0001: Use Postgres", content)
|
|
267
|
+
self.assertIn("- Status: proposed", content)
|
|
268
|
+
self.assertNotIn("use the verified decision date", content)
|
|
269
|
+
|
|
270
|
+
def test_second_adr_increments(self):
|
|
271
|
+
adr.scaffold("First", self.root)
|
|
272
|
+
self.assertEqual(adr.scaffold("Second", self.root).name, "0002-second.md")
|
|
273
|
+
|
|
274
|
+
def test_title_slug_is_normalized(self):
|
|
275
|
+
path = adr.scaffold("Use Postgres + Redis!", self.root)
|
|
276
|
+
self.assertEqual(path.name, "0001-use-postgres-redis.md")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class ChangelogTests(unittest.TestCase):
|
|
280
|
+
def setUp(self):
|
|
281
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-clog-")
|
|
282
|
+
self.addCleanup(self.temp.cleanup)
|
|
283
|
+
self.root = Path(self.temp.name).resolve() / "project"
|
|
284
|
+
self.root.mkdir()
|
|
285
|
+
|
|
286
|
+
def test_add_under_unreleased(self):
|
|
287
|
+
(self.root / "CHANGELOG.md").write_text("# Changelog\n\n## Unreleased\n\nNote text\n", encoding="utf-8")
|
|
288
|
+
changelog.add_entry(self.root, "Fix auth bug")
|
|
289
|
+
text = (self.root / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
290
|
+
self.assertIn("- Fix auth bug\n", text)
|
|
291
|
+
self.assertLess(text.index("- Fix auth bug"), text.index("Note text"))
|
|
292
|
+
|
|
293
|
+
def test_add_creates_unreleased_section(self):
|
|
294
|
+
(self.root / "CHANGELOG.md").write_text("# Changelog\n", encoding="utf-8")
|
|
295
|
+
changelog.add_entry(self.root, "Add feature")
|
|
296
|
+
text = (self.root / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
297
|
+
self.assertIn("## Unreleased\n- Add feature\n", text)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
unittest.main()
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Exercise the offline model router (route and configure) and the agent registry."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest import mock
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
13
|
+
import install
|
|
14
|
+
import router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def providers():
|
|
18
|
+
return router.load_providers(router.ROOT / "template" / "ai-kit" / "router" / "providers")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def selection(**overrides):
|
|
22
|
+
base = {"schema": 1, "default_provider": None,
|
|
23
|
+
"roles": {"cheap": None, "work": None, "escalation": None},
|
|
24
|
+
"local_models": {"cheap": None, "work": None, "escalation": None}}
|
|
25
|
+
base.update(overrides)
|
|
26
|
+
return base
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RouteTests(unittest.TestCase):
|
|
30
|
+
def test_provider_change_discards_inherited_effort_but_explicit_effort_wins(self):
|
|
31
|
+
original = selection(default_provider="first", roles={"work": {"model": "old", "effort": "max"}})
|
|
32
|
+
catalogue = {"first": {"roles": {"work": {"model": "old"}}, "supported_efforts": {"work": ["max"]}},
|
|
33
|
+
"second": {"roles": {"work": {"model": "new"}}, "supported_efforts": {"work": ["low"]}}}
|
|
34
|
+
before = json.dumps(original, sort_keys=True)
|
|
35
|
+
result = router.route_result("implement feature", original, catalogue, provider="second")
|
|
36
|
+
self.assertEqual(result["model"], "new")
|
|
37
|
+
self.assertIsNone(result["effort"])
|
|
38
|
+
result = router.route_result("implement feature", original, catalogue, provider="second", effort="low")
|
|
39
|
+
self.assertEqual(result["effort"], "low")
|
|
40
|
+
with self.assertRaises(ValueError):
|
|
41
|
+
router.route_result("implement feature", original, catalogue, provider="second", effort="max")
|
|
42
|
+
self.assertEqual(json.dumps(original, sort_keys=True), before)
|
|
43
|
+
custom = selection(default_provider="first", roles={"cheap": {"model": "small"},
|
|
44
|
+
"work": {"model": 'quoted"\nmodel: injected'}})
|
|
45
|
+
self.assertIn('model: "quoted\\"\\nmodel: injected"\n', router.aider_config(custom, catalogue))
|
|
46
|
+
self.assertEqual(router.aider_config(custom, catalogue).count("\n"), 3)
|
|
47
|
+
|
|
48
|
+
def test_ladder_matches_canonical_policy(self):
|
|
49
|
+
policy = (router.ROOT / "template/ai-kit/router/POLICY.md").read_text(encoding="utf-8")
|
|
50
|
+
rows = [line.split("|")[3].strip() for line in policy.splitlines()
|
|
51
|
+
if line.startswith("| ") and line.split("|")[1].strip().isdigit()]
|
|
52
|
+
expected = ["cheap / none if supported", "cheap / low", "work / medium", "work / high",
|
|
53
|
+
"work / xhigh", "work / max", "escalation / effort selected separately"]
|
|
54
|
+
self.assertEqual(rows, expected)
|
|
55
|
+
self.assertEqual(list(router.LEVEL_MAP.values()),
|
|
56
|
+
[("cheap", "none"), ("cheap", "low"), ("work", "medium"),
|
|
57
|
+
("work", "high"), ("work", "xhigh"), ("work", "max"), ("escalation", None)])
|
|
58
|
+
|
|
59
|
+
def test_operation_risk_and_word_boundaries(self):
|
|
60
|
+
cases = {"fix typo in research.md": 1, "add stack trace logs": 2,
|
|
61
|
+
"implement a parser": 2, "parse payments": 4,
|
|
62
|
+
"classify the authentication logs": 4,
|
|
63
|
+
"simple distributed consensus fix": 4, "rename `deadlock` to `lock`": 1,
|
|
64
|
+
"fix typo in architecture.yaml": 1, "add logging to debug.rs": 2,
|
|
65
|
+
"\u043f\u0435\u0440\u0435\u0438\u043c\u0435\u043d\u0443\u0439 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0443\u044e": 1, "\u0438\u0441\u043f\u0440\u0430\u0432\u044c \u043e\u043f\u0435\u0447\u0430\u0442\u043a\u0443 \u0432 research.md": 1,
|
|
66
|
+
"\u0434\u043e\u0431\u0430\u0432\u044c \u043d\u043e\u0432\u044b\u0439 \u0444\u0438\u043b\u044c\u0442\u0440": 2, "\u043e\u0442\u043b\u0430\u0434\u044c \u0432\u0437\u0430\u0438\u043c\u043d\u0443\u044e \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0443": 3,
|
|
67
|
+
"\u0441\u043f\u0440\u043e\u0435\u043a\u0442\u0438\u0440\u0443\u0439 \u0440\u0430\u0441\u043f\u0440\u0435\u0434\u0435\u043b\u0451\u043d\u043d\u0443\u044e \u0440\u0435\u043f\u043b\u0438\u043a\u0430\u0446\u0438\u044e": 4, "\u0440\u0430\u0437\u0431\u0435\u0440\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044e": 4,
|
|
68
|
+
"\u0434\u043e\u043a\u0430\u0436\u0438 \u0442\u0435\u043e\u0440\u0435\u043c\u0443": 5, "\u0438\u0437\u0432\u043b\u0435\u043a\u0438 \u043c\u0435\u0442\u043a\u0438 \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430": 0,
|
|
69
|
+
"\u0434\u043e\u0431\u0430\u0432\u044c \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443 \u043f\u043b\u0430\u0442\u0435\u0436\u0435\u0439": 4}
|
|
70
|
+
for task, expected in cases.items():
|
|
71
|
+
with self.subTest(task=task):
|
|
72
|
+
self.assertEqual(router.classify_level(task), expected)
|
|
73
|
+
|
|
74
|
+
def test_structured_evidence_and_manual_level(self):
|
|
75
|
+
result = router.route_result("unknown", selection(), providers(), operation="extract",
|
|
76
|
+
risks=("payments",), components=3)
|
|
77
|
+
self.assertEqual(result["level"], 4)
|
|
78
|
+
self.assertIn("explicit risk: payments", result["signals"])
|
|
79
|
+
self.assertEqual(router.classify_task("unknown", components=3)["level"], 3)
|
|
80
|
+
result = router.route_result("payment fix", selection(), providers(), level=1)
|
|
81
|
+
self.assertEqual(result["level"], 1)
|
|
82
|
+
self.assertEqual(result["level_source"], "explicit")
|
|
83
|
+
for kwargs in ({"operation": "missing"}, {"risks": ("missing",)},
|
|
84
|
+
{"components": 0}, {"components": True}):
|
|
85
|
+
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
|
|
86
|
+
router.classify_task("unknown", **kwargs)
|
|
87
|
+
|
|
88
|
+
def test_automatic_routing_never_claims_escalation(self):
|
|
89
|
+
for task in ("formal proof", "debug deadlock", "security audit"):
|
|
90
|
+
result = router.route_result(task, selection(default_provider="openai"), providers())
|
|
91
|
+
self.assertNotEqual(result["level"], 6)
|
|
92
|
+
self.assertNotEqual(result["role"], "escalation")
|
|
93
|
+
research = router.route_result("formal proof", selection(default_provider="openai"), providers())
|
|
94
|
+
self.assertEqual((research["role"], research["effort"]), ("work", "max"))
|
|
95
|
+
escalation = router.route_result("diagnosed shortfall", selection(default_provider="openai"),
|
|
96
|
+
providers(), level=6)
|
|
97
|
+
self.assertEqual(escalation["effort"], "high")
|
|
98
|
+
self.assertIsNone(escalation["recommended_effort"])
|
|
99
|
+
|
|
100
|
+
def test_selection_effort_is_respected_and_cli_wins(self):
|
|
101
|
+
settings = selection(default_provider="openai", roles={"work": {"effort": "low"}})
|
|
102
|
+
chosen = router.route_result("implement feature", settings, providers())
|
|
103
|
+
self.assertEqual(chosen["effort"], "low")
|
|
104
|
+
self.assertEqual(chosen["effort_source"], "selection")
|
|
105
|
+
chosen = router.route_result("implement feature", settings, providers(), effort="high")
|
|
106
|
+
self.assertEqual(chosen["effort"], "high")
|
|
107
|
+
self.assertEqual(settings["roles"]["work"]["effort"], "low")
|
|
108
|
+
|
|
109
|
+
def test_effort_controls_require_model_capability(self):
|
|
110
|
+
openai = router.route_result("classify tickets", selection(default_provider="openai"), providers())
|
|
111
|
+
self.assertEqual(openai["effort"], "none")
|
|
112
|
+
fallback = router.route_result("classify tickets", selection(default_provider="anthropic"), providers())
|
|
113
|
+
self.assertEqual(fallback["recommended_effort"], "none")
|
|
114
|
+
self.assertEqual(fallback["effort"], "low")
|
|
115
|
+
self.assertTrue(fallback["adjustments"])
|
|
116
|
+
for provider_id in ("kimi", "local", None, "gemini"):
|
|
117
|
+
result = router.route_result("implement feature", selection(default_provider=provider_id,
|
|
118
|
+
local_models={"work": "user-model"}), providers())
|
|
119
|
+
self.assertIsNone(result["effort"])
|
|
120
|
+
for kwargs in ({"provider": "anthropic", "effort": "none"},
|
|
121
|
+
{"provider": "openai", "model": "unknown-model", "effort": "high"}):
|
|
122
|
+
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
|
|
123
|
+
router.route_result("classify tickets", selection(), providers(), **kwargs)
|
|
124
|
+
|
|
125
|
+
def test_provider_and_model_overrides_do_not_mutate_selection(self):
|
|
126
|
+
settings = selection(default_provider="openai")
|
|
127
|
+
result = router.route_result("feature", settings, providers(), provider="kimi", model="custom-id")
|
|
128
|
+
self.assertEqual((result["provider"], result["model"]), ("kimi", "custom-id"))
|
|
129
|
+
self.assertIsNone(result["effort"])
|
|
130
|
+
self.assertEqual(settings["default_provider"], "openai")
|
|
131
|
+
self.assertIsNone(settings["roles"]["work"])
|
|
132
|
+
settings["roles"]["work"] = {"provider": "openai", "model": "owner-openai-model"}
|
|
133
|
+
result = router.route_result("feature", settings, providers(), provider="kimi")
|
|
134
|
+
self.assertEqual(result["model"], providers()["kimi"]["roles"]["work"]["model"])
|
|
135
|
+
self.assertEqual(settings["roles"]["work"]["model"], "owner-openai-model")
|
|
136
|
+
|
|
137
|
+
def test_classify_levels(self):
|
|
138
|
+
self.assertEqual(router.classify("classify these tickets")["level"], 0)
|
|
139
|
+
self.assertEqual(router.classify("rename the variable")["level"], 1)
|
|
140
|
+
self.assertEqual(router.classify("implement the feature")["level"], 2)
|
|
141
|
+
self.assertEqual(router.classify("debug the concurrency race")["level"], 3)
|
|
142
|
+
self.assertEqual(router.classify("design a distributed consensus layer")["level"], 4)
|
|
143
|
+
self.assertEqual(router.classify("prove a novel algorithm")["level"], 5)
|
|
144
|
+
|
|
145
|
+
def test_route_resolves_model_from_default_provider(self):
|
|
146
|
+
result = router.route_result("implement the feature", selection(default_provider="openai"),
|
|
147
|
+
providers())
|
|
148
|
+
self.assertEqual(result["level"], 2)
|
|
149
|
+
self.assertEqual(result["role"], "work")
|
|
150
|
+
self.assertEqual(result["provider"], "openai")
|
|
151
|
+
self.assertEqual(result["model"], "gpt-6.1-sol")
|
|
152
|
+
|
|
153
|
+
def test_route_resolves_verified_gemini_model(self):
|
|
154
|
+
result = router.route_result("implement the feature", selection(default_provider="gemini"),
|
|
155
|
+
providers())
|
|
156
|
+
self.assertEqual(result["provider"], "gemini")
|
|
157
|
+
self.assertEqual(result["model"], "gemini-3.8-flash")
|
|
158
|
+
self.assertIsNone(result["effort"])
|
|
159
|
+
|
|
160
|
+
def test_route_resolves_local_model(self):
|
|
161
|
+
selection_data = selection(roles={"work": "local"},
|
|
162
|
+
local_models={"work": "llama3.3:70b"})
|
|
163
|
+
result = router.route_result("implement the feature", selection_data, providers())
|
|
164
|
+
self.assertEqual(result["provider"], "local")
|
|
165
|
+
self.assertEqual(result["model"], "llama3.3:70b")
|
|
166
|
+
|
|
167
|
+
def test_route_skips_pending_provider(self):
|
|
168
|
+
provs = providers()
|
|
169
|
+
provs["experimental"] = {"tier": "cloud", "verification_status": "pending",
|
|
170
|
+
"roles": {"cheap": {}, "work": {}, "escalation": {}}}
|
|
171
|
+
result = router.route_result("implement the feature", selection(default_provider="experimental"),
|
|
172
|
+
provs)
|
|
173
|
+
self.assertIsNone(result["model"])
|
|
174
|
+
self.assertEqual(result["provider"], "experimental")
|
|
175
|
+
|
|
176
|
+
def test_route_explicit_overrides(self):
|
|
177
|
+
result = router.route_result("anything", selection(default_provider="openai"), providers(),
|
|
178
|
+
level=5, role="escalation", effort="max")
|
|
179
|
+
self.assertEqual(result["level"], 5)
|
|
180
|
+
self.assertEqual(result["role"], "escalation")
|
|
181
|
+
self.assertEqual(result["effort"], "max")
|
|
182
|
+
self.assertEqual(result["model"], "gpt-6-astra")
|
|
183
|
+
|
|
184
|
+
def test_route_unknown_provider_is_refused(self):
|
|
185
|
+
with self.assertRaises(ValueError):
|
|
186
|
+
router.route_result("anything", selection(default_provider="missing"), providers())
|
|
187
|
+
|
|
188
|
+
def test_selection_validation_rejects_bad_shapes(self):
|
|
189
|
+
for bad in [{"schema": 99, "roles": {}, "local_models": {}},
|
|
190
|
+
{"schema": 1, "roles": {"work": 7}, "local_models": {}},
|
|
191
|
+
{"schema": 1, "roles": {}, "local_models": {"work": 7}},
|
|
192
|
+
{"schema": 1, "roles": {"work": {"unknown": "x"}}, "local_models": {}}]:
|
|
193
|
+
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
|
194
|
+
router.validate_selection(bad)
|
|
195
|
+
|
|
196
|
+
def test_route_cli_prints_recommendation(self):
|
|
197
|
+
output = io.StringIO()
|
|
198
|
+
with contextlib.redirect_stdout(output):
|
|
199
|
+
self.assertEqual(router.main(["route", "implement the feature"]), 0)
|
|
200
|
+
self.assertIn('"level": 2', output.getvalue())
|
|
201
|
+
|
|
202
|
+
def test_providers_lists_status_and_sources(self):
|
|
203
|
+
report = router.providers_report(providers())
|
|
204
|
+
by_name = {item["provider"]: item for item in report["providers"]}
|
|
205
|
+
self.assertEqual(set(by_name), {"openai", "anthropic", "kimi", "local", "gemini"})
|
|
206
|
+
self.assertEqual(by_name["openai"]["status"], "verified")
|
|
207
|
+
self.assertEqual(by_name["local"]["status"], "local")
|
|
208
|
+
self.assertEqual(by_name["gemini"]["status"], "verified")
|
|
209
|
+
self.assertTrue(by_name["openai"]["sources"])
|
|
210
|
+
|
|
211
|
+
def test_providers_cli_prints_report(self):
|
|
212
|
+
output = io.StringIO()
|
|
213
|
+
with contextlib.redirect_stdout(output):
|
|
214
|
+
self.assertEqual(router.main(["providers"]), 0)
|
|
215
|
+
self.assertIn('"provider": "gemini"', output.getvalue())
|
|
216
|
+
|
|
217
|
+
def test_cli_structured_flags_and_invalid_effort(self):
|
|
218
|
+
output = io.StringIO()
|
|
219
|
+
with contextlib.redirect_stdout(output):
|
|
220
|
+
status = router.main(["route", "extract tickets", "--operation", "implement",
|
|
221
|
+
"--risk", "payments", "--components", "2", "--provider", "openai"])
|
|
222
|
+
self.assertEqual(status, 0)
|
|
223
|
+
result = json.loads(output.getvalue())
|
|
224
|
+
self.assertEqual((result["level"], result["effort"]), (4, "xhigh"))
|
|
225
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
226
|
+
self.assertEqual(router.main(["route", "feature", "--provider", "openai", "--effort", "invalid"]), 1)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ConfigureTests(unittest.TestCase):
|
|
230
|
+
def setUp(self):
|
|
231
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-router-")
|
|
232
|
+
self.addCleanup(self.temp.cleanup)
|
|
233
|
+
self.base = Path(self.temp.name).resolve()
|
|
234
|
+
self.target = self.base / "project"
|
|
235
|
+
self.source = self.base / "reference"
|
|
236
|
+
shutil.copytree(install.ROOT, self.source, ignore=shutil.ignore_patterns("__pycache__", ".git"))
|
|
237
|
+
|
|
238
|
+
def apply(self, **options):
|
|
239
|
+
plan = install.build_plan(self.target, root=self.source, **options)
|
|
240
|
+
self.assertTrue(install.apply_plan(plan))
|
|
241
|
+
return plan
|
|
242
|
+
|
|
243
|
+
def write(self, path, data):
|
|
244
|
+
destination = self.target / path
|
|
245
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
destination.write_bytes(data)
|
|
247
|
+
|
|
248
|
+
def test_configure_preview_writes_nothing(self):
|
|
249
|
+
self.apply(agents=["codex", "aider"])
|
|
250
|
+
self.write("ai-kit/router/selection.json",
|
|
251
|
+
(json.dumps(selection(default_provider="openai")) + "\n").encode())
|
|
252
|
+
plan = router.configure_plan(self.target)
|
|
253
|
+
self.assertTrue(plan["actions"])
|
|
254
|
+
self.assertFalse((self.target / "ai-kit/router/resolved.json").exists())
|
|
255
|
+
|
|
256
|
+
def test_configure_apply_writes_resolved_and_aider(self):
|
|
257
|
+
self.apply(agents=["codex", "aider"])
|
|
258
|
+
self.write("ai-kit/router/selection.json",
|
|
259
|
+
(json.dumps(selection(default_provider="openai")) + "\n").encode())
|
|
260
|
+
plan = router.configure_plan(self.target)
|
|
261
|
+
self.assertTrue(router.apply_configure(plan))
|
|
262
|
+
resolved = json.loads((self.target / "ai-kit/router/resolved.json").read_text())
|
|
263
|
+
self.assertEqual(resolved["schema"], 1)
|
|
264
|
+
self.assertEqual(resolved["roles"]["work"]["model"], "gpt-6.1-sol")
|
|
265
|
+
aider = (self.target / ".aider.conf.yml").read_text(encoding="utf-8")
|
|
266
|
+
self.assertIn('model: "gpt-6.1-sol"', aider)
|
|
267
|
+
self.assertIn('weak_model: "gpt-6-luna"', aider)
|
|
268
|
+
|
|
269
|
+
def test_configure_skips_aider_without_resolution(self):
|
|
270
|
+
self.apply(agents=["codex", "aider"])
|
|
271
|
+
# default selection has no provider, so no model resolves
|
|
272
|
+
plan = router.configure_plan(self.target)
|
|
273
|
+
self.assertTrue(plan["actions"])
|
|
274
|
+
self.assertNotIn(".aider.conf.yml", plan["actions"])
|
|
275
|
+
|
|
276
|
+
def test_configure_conflict_preserves_existing_aider_config(self):
|
|
277
|
+
self.apply(agents=["codex", "aider"])
|
|
278
|
+
self.write("ai-kit/router/selection.json",
|
|
279
|
+
(json.dumps(selection(default_provider="openai")) + "\n").encode())
|
|
280
|
+
self.write(".aider.conf.yml", b"model: my-custom-model\n")
|
|
281
|
+
plan = router.configure_plan(self.target)
|
|
282
|
+
self.assertIn(".aider.conf.yml", plan["conflicts"])
|
|
283
|
+
self.assertFalse(router.apply_configure(plan))
|
|
284
|
+
self.assertEqual((self.target / ".aider.conf.yml").read_text(encoding="utf-8"),
|
|
285
|
+
"model: my-custom-model\n")
|
|
286
|
+
|
|
287
|
+
def test_configure_unknown_provider_is_refused(self):
|
|
288
|
+
self.apply()
|
|
289
|
+
self.write("ai-kit/router/selection.json",
|
|
290
|
+
(json.dumps(selection(default_provider="missing")) + "\n").encode())
|
|
291
|
+
with self.assertRaises(ValueError):
|
|
292
|
+
router.configure_plan(self.target)
|
|
293
|
+
|
|
294
|
+
def test_route_project_uses_installed_selection(self):
|
|
295
|
+
self.apply()
|
|
296
|
+
self.write("ai-kit/router/selection.json",
|
|
297
|
+
(json.dumps(selection(default_provider="openai")) + "\n").encode())
|
|
298
|
+
output = io.StringIO()
|
|
299
|
+
with contextlib.redirect_stdout(output):
|
|
300
|
+
self.assertEqual(router.main(["route", "implement the feature",
|
|
301
|
+
"--project", str(self.target)]), 0)
|
|
302
|
+
self.assertIn('"model": "gpt-6.1-sol"', output.getvalue())
|
|
303
|
+
with mock.patch("metrics.read_records", side_effect=AssertionError("Recommended mode must not load optional history")):
|
|
304
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
305
|
+
self.assertEqual(router.main(["route", "implement feature", "--project", str(self.target)]), 0)
|
|
306
|
+
original = install.is_link
|
|
307
|
+
with mock.patch.object(install, "is_link", side_effect=lambda p: p.name == "selection.json" or original(p)):
|
|
308
|
+
with self.assertRaisesRegex(ValueError, "linked"):
|
|
309
|
+
router._selection_and_providers(self.target, self.source)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class RegistryTests(unittest.TestCase):
|
|
313
|
+
def test_registry_names_match_expected(self):
|
|
314
|
+
registry = install.load_agent_registry()
|
|
315
|
+
self.assertEqual(registry["names"],
|
|
316
|
+
{"codex", "claude", "kimi", "manus", "copilot", "cursor", "aider",
|
|
317
|
+
"gemini", "windsurf", "cline", "roo"})
|
|
318
|
+
self.assertEqual(registry["entries"]["CLAUDE.md"], "claude")
|
|
319
|
+
self.assertEqual(registry["skills_copies"]["claude"], ".claude/skills/")
|
|
320
|
+
self.assertEqual(registry["optional"]["aider"], [("CONVENTIONS.md", "CONVENTIONS.md")])
|
|
321
|
+
|
|
322
|
+
def test_registry_rejects_duplicate_entry_ownership(self):
|
|
323
|
+
temp = tempfile.TemporaryDirectory(prefix="ai-kit-registry-")
|
|
324
|
+
self.addCleanup(temp.cleanup)
|
|
325
|
+
root = Path(temp.name) / "reference"
|
|
326
|
+
shutil.copytree(install.ROOT, root, ignore=shutil.ignore_patterns("__pycache__", ".git"))
|
|
327
|
+
path = root / "integrations/agents.json"
|
|
328
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
329
|
+
data["agents"]["gemini"]["entries"] = ["CLAUDE.md"]
|
|
330
|
+
path.write_text(json.dumps(data) + "\n", encoding="utf-8")
|
|
331
|
+
with self.assertRaises(ValueError):
|
|
332
|
+
install.load_agent_registry(root)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
unittest.main()
|