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,209 @@
|
|
|
1
|
+
"""Local measurement accounting, preservation, validation, and path guards."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import copy
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
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 metrics
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def task(task_id="pair-01", **overrides):
|
|
17
|
+
value = {"schema": 1, "task_id": task_id, "experiment": "trial", "arm": "kit",
|
|
18
|
+
"kit_version": "test-fixture", "outcome": "correct", "checks": "passed",
|
|
19
|
+
"rework": 0, "escalations": 0,
|
|
20
|
+
"attempts": [{"provider": "test-provider", "model": "test-model", "effort": "low",
|
|
21
|
+
"duration_seconds": 10, "input_tokens": 100, "cached_input_tokens": 20,
|
|
22
|
+
"output_tokens": 40, "reasoning_tokens": 10,
|
|
23
|
+
"cost": {"amount": "0.10", "currency": "USD", "source": "provider-usage"}}]}
|
|
24
|
+
value.update(overrides)
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AccountingTests(unittest.TestCase):
|
|
29
|
+
def test_failed_and_recovery_costs_are_included(self):
|
|
30
|
+
successful = task(rework=1, escalations=1)
|
|
31
|
+
successful["attempts"].append(copy.deepcopy(successful["attempts"][0]))
|
|
32
|
+
failed = task("pair-02", outcome="incorrect", checks="failed")
|
|
33
|
+
group = metrics.summarize([successful, failed])["groups"][0]
|
|
34
|
+
self.assertEqual(group["correct"], 1)
|
|
35
|
+
self.assertEqual(group["attempts"], 3)
|
|
36
|
+
self.assertEqual(group["cost_per_correct_result"], "0.30")
|
|
37
|
+
self.assertEqual(group["duration_seconds"], 30)
|
|
38
|
+
self.assertEqual(group["tokens"]["input_tokens"]["known_total"], 300)
|
|
39
|
+
self.assertEqual((group["rework"], group["escalations"]), (1, 1))
|
|
40
|
+
|
|
41
|
+
def test_unknown_cost_time_and_tokens_are_not_zero(self):
|
|
42
|
+
value = task()
|
|
43
|
+
value["attempts"].append({})
|
|
44
|
+
group = metrics.summarize([value])["groups"][0]
|
|
45
|
+
self.assertIsNone(group["cost_per_correct_result"])
|
|
46
|
+
self.assertIsNone(group["duration_seconds"])
|
|
47
|
+
self.assertEqual(group["cost_coverage"], {"known": 1, "total": 2, "estimated": 0})
|
|
48
|
+
self.assertEqual(group["tokens"]["input_tokens"]["known_attempts"], 1)
|
|
49
|
+
|
|
50
|
+
def test_currencies_and_estimates_are_explicit(self):
|
|
51
|
+
value = task()
|
|
52
|
+
other = copy.deepcopy(value["attempts"][0])
|
|
53
|
+
other["cost"] = {"amount": "0.20", "currency": "EUR", "source": "estimate"}
|
|
54
|
+
value["attempts"].append(other)
|
|
55
|
+
group = metrics.summarize([value])["groups"][0]
|
|
56
|
+
self.assertIsNone(group["cost_per_correct_result"])
|
|
57
|
+
self.assertEqual(group["known_cost_by_currency"], {"EUR": "0.20", "USD": "0.10"})
|
|
58
|
+
self.assertEqual(group["cost_coverage"]["estimated"], 1)
|
|
59
|
+
|
|
60
|
+
def test_no_correct_result_has_no_cost_per_correct_result(self):
|
|
61
|
+
group = metrics.summarize([task(outcome="incomplete", checks="not-run")])["groups"][0]
|
|
62
|
+
self.assertIsNone(group["cost_per_correct_result"])
|
|
63
|
+
self.assertEqual(group["checks_not_run"], 1)
|
|
64
|
+
|
|
65
|
+
def test_experiment_arms_are_separate(self):
|
|
66
|
+
results = metrics.summarize([task(), task(arm="baseline"), task(experiment="other")])["groups"]
|
|
67
|
+
self.assertEqual(len(results), 3)
|
|
68
|
+
|
|
69
|
+
def test_analyze_aggregates_per_provider(self):
|
|
70
|
+
openai = task()
|
|
71
|
+
openai["attempts"][0].update({"provider": "openai", "model": "gpt-6.1-sol"})
|
|
72
|
+
anthropic = task("pair-02", outcome="incorrect", checks="failed")
|
|
73
|
+
anthropic["attempts"][0].update({"provider": "anthropic", "model": "claude-opus-5-5"})
|
|
74
|
+
report = metrics.analyze([openai, anthropic])
|
|
75
|
+
by_name = {item["provider"]: item for item in report["providers"]}
|
|
76
|
+
self.assertEqual(set(by_name), {"openai", "anthropic"})
|
|
77
|
+
self.assertEqual(by_name["openai"]["correct_tasks"], 1)
|
|
78
|
+
self.assertEqual(by_name["openai"]["success_rate"], 1.0)
|
|
79
|
+
self.assertEqual(by_name["openai"]["cost_per_correct"], "0.10")
|
|
80
|
+
self.assertEqual(by_name["anthropic"]["correct_tasks"], 0)
|
|
81
|
+
self.assertEqual(by_name["anthropic"]["success_rate"], 0.0)
|
|
82
|
+
self.assertIsNone(by_name["anthropic"]["cost_per_correct"])
|
|
83
|
+
|
|
84
|
+
def test_analyze_keeps_baseline_and_kit_task_identities_distinct(self):
|
|
85
|
+
baseline = task(schema=2, arm="baseline")
|
|
86
|
+
baseline["attempts"][0]["disposition"] = "succeeded"
|
|
87
|
+
kit = task(schema=2, arm="kit", outcome="incorrect", checks="failed")
|
|
88
|
+
kit["attempts"][0]["disposition"] = "failed"
|
|
89
|
+
report = metrics.analyze([baseline, kit])
|
|
90
|
+
provider = report["providers"][0]
|
|
91
|
+
self.assertEqual((provider["tasks"], provider["correct_tasks"]), (2, 1))
|
|
92
|
+
self.assertEqual(provider["attempt_dispositions"],
|
|
93
|
+
{"succeeded": 1, "failed": 1, "inconclusive": 0})
|
|
94
|
+
self.assertEqual(provider["attempt_success_rate"], 0.5)
|
|
95
|
+
|
|
96
|
+
def test_schema_two_requires_attempt_disposition(self):
|
|
97
|
+
value = task(schema=2)
|
|
98
|
+
with self.assertRaises(ValueError):
|
|
99
|
+
metrics.validate_record(value)
|
|
100
|
+
value["attempts"][0]["disposition"] = "inconclusive"
|
|
101
|
+
self.assertEqual(metrics.validate_record(value)["schema"], 2)
|
|
102
|
+
|
|
103
|
+
def test_record_validation_refuses_bad_data_and_task_text(self):
|
|
104
|
+
invalid = [task(schema=True), task(task_id="please fix this bug"), task(prompt="secret"),
|
|
105
|
+
task(outcome=[]), task(checks=[]), task(rework=True), task(escalations=-1),
|
|
106
|
+
task(outcome="correct", checks="failed"), task(attempts=[]), task(attempts=[3])]
|
|
107
|
+
for field, value in (("duration_seconds", float("nan")), ("duration_seconds", True),
|
|
108
|
+
("duration_seconds", 10 ** 400),
|
|
109
|
+
("input_tokens", -1), ("input_tokens", True),
|
|
110
|
+
("cached_input_tokens", 101), ("reasoning_tokens", 41),
|
|
111
|
+
("model", "model with task text"), ("prompt", "secret")):
|
|
112
|
+
record = task()
|
|
113
|
+
record["attempts"][0][field] = value
|
|
114
|
+
invalid.append(record)
|
|
115
|
+
for cost in ({"amount": "-1", "currency": "USD", "source": "billing"},
|
|
116
|
+
{"amount": "NaN", "currency": "USD", "source": "billing"},
|
|
117
|
+
{"amount": 0.1, "currency": "USD", "source": "billing"},
|
|
118
|
+
{"amount": "1", "currency": "usd", "source": "billing"},
|
|
119
|
+
{"amount": "1", "currency": "USD", "source": []}):
|
|
120
|
+
record = task()
|
|
121
|
+
record["attempts"][0]["cost"] = cost
|
|
122
|
+
invalid.append(record)
|
|
123
|
+
for record in invalid:
|
|
124
|
+
with self.subTest(record=record), self.assertRaises(ValueError):
|
|
125
|
+
metrics.validate_record(record)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class JournalTests(unittest.TestCase):
|
|
129
|
+
def setUp(self):
|
|
130
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-metrics-")
|
|
131
|
+
self.addCleanup(self.temp.cleanup)
|
|
132
|
+
self.project = Path(self.temp.name).resolve()
|
|
133
|
+
self.journal = self.project / metrics.JOURNAL
|
|
134
|
+
|
|
135
|
+
def test_preview_and_empty_summary_write_nothing(self):
|
|
136
|
+
result = metrics.record_task(self.project, task())
|
|
137
|
+
self.assertTrue(result["preview"])
|
|
138
|
+
self.assertEqual(metrics.summarize(metrics.read_records(self.project))["groups"], [])
|
|
139
|
+
self.assertEqual(list(self.project.iterdir()), [])
|
|
140
|
+
|
|
141
|
+
def test_apply_and_duplicate_preserve_data(self):
|
|
142
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
143
|
+
original = self.journal.read_bytes()
|
|
144
|
+
with self.assertRaises(ValueError):
|
|
145
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
146
|
+
self.assertEqual(self.journal.read_bytes(), original)
|
|
147
|
+
metrics.record_task(self.project, task("pair-02"), apply=True)
|
|
148
|
+
self.assertEqual(len(metrics.read_records(self.project)), 2)
|
|
149
|
+
self.assertFalse((self.project / metrics.LOCK).exists())
|
|
150
|
+
|
|
151
|
+
def test_malformed_journal_refuses_without_overwrite(self):
|
|
152
|
+
self.journal.parent.mkdir(parents=True)
|
|
153
|
+
self.journal.write_bytes(b"not-json\n")
|
|
154
|
+
with self.assertRaises(ValueError):
|
|
155
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
156
|
+
self.assertEqual(self.journal.read_bytes(), b"not-json\n")
|
|
157
|
+
|
|
158
|
+
def test_duplicate_existing_journal_is_refused(self):
|
|
159
|
+
self.journal.parent.mkdir(parents=True)
|
|
160
|
+
line = json.dumps(task()) + "\n"
|
|
161
|
+
self.journal.write_text(line * 2, encoding="utf-8")
|
|
162
|
+
with self.assertRaises(ValueError):
|
|
163
|
+
metrics.read_records(self.project)
|
|
164
|
+
|
|
165
|
+
def test_busy_writer_lock_is_preserved(self):
|
|
166
|
+
lock = self.project / metrics.LOCK
|
|
167
|
+
lock.parent.mkdir(parents=True)
|
|
168
|
+
lock.write_bytes(b"other writer\n")
|
|
169
|
+
with self.assertRaises(FileExistsError):
|
|
170
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
171
|
+
self.assertEqual(lock.read_bytes(), b"other writer\n")
|
|
172
|
+
self.assertFalse(self.journal.exists())
|
|
173
|
+
|
|
174
|
+
def test_write_failure_keeps_journal_and_cleans_owned_lock(self):
|
|
175
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
176
|
+
original = self.journal.read_bytes()
|
|
177
|
+
with mock.patch.object(metrics.install, "atomic_write", side_effect=OSError("disk failure")):
|
|
178
|
+
with self.assertRaises(OSError):
|
|
179
|
+
metrics.record_task(self.project, task("pair-02"), apply=True)
|
|
180
|
+
self.assertEqual(self.journal.read_bytes(), original)
|
|
181
|
+
self.assertFalse((self.project / metrics.LOCK).exists())
|
|
182
|
+
|
|
183
|
+
def test_file_parent_refused_before_writes(self):
|
|
184
|
+
(self.project / "ai-kit").write_bytes(b"native data")
|
|
185
|
+
with self.assertRaises(NotADirectoryError):
|
|
186
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
187
|
+
self.assertEqual((self.project / "ai-kit").read_bytes(), b"native data")
|
|
188
|
+
|
|
189
|
+
def test_linked_root_parent_and_destination_refused(self):
|
|
190
|
+
for candidate in (self.project, self.journal.parent, self.journal, self.project / metrics.LOCK):
|
|
191
|
+
with self.subTest(candidate=candidate), mock.patch.object(
|
|
192
|
+
metrics.install, "is_link", side_effect=lambda path: path == candidate):
|
|
193
|
+
with self.assertRaises(ValueError):
|
|
194
|
+
metrics.record_task(self.project, task(), apply=True)
|
|
195
|
+
self.assertEqual(list(self.project.iterdir()), [])
|
|
196
|
+
|
|
197
|
+
def test_cli_preview_apply_summary_and_invalid_input(self):
|
|
198
|
+
source = self.project / "record.json"
|
|
199
|
+
source.write_text(json.dumps(task()), encoding="utf-8")
|
|
200
|
+
output = io.StringIO()
|
|
201
|
+
with contextlib.redirect_stdout(output):
|
|
202
|
+
self.assertEqual(metrics.main(["record", str(self.project), "--from-json", str(source)]), 0)
|
|
203
|
+
self.assertFalse(self.journal.exists())
|
|
204
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
205
|
+
self.assertEqual(metrics.main(["record", str(self.project), "--from-json", str(source), "--apply"]), 0)
|
|
206
|
+
self.assertEqual(metrics.main(["summary", str(self.project)]), 0)
|
|
207
|
+
source.write_text("{}\n", encoding="utf-8")
|
|
208
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
209
|
+
self.assertEqual(metrics.main(["record", str(self.project), "--from-json", str(source)]), 1)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Exercise generated client settings, scoped module rules, presets, and workflow skills."""
|
|
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
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|
12
|
+
import doctor
|
|
13
|
+
import install
|
|
14
|
+
|
|
15
|
+
ALL_SCOPED = ["codex", "claude", "cursor", "copilot", "windsurf", "cline"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NativeConfigurationTests(unittest.TestCase):
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.temp = tempfile.TemporaryDirectory(prefix="ai-kit-native-")
|
|
21
|
+
self.addCleanup(self.temp.cleanup)
|
|
22
|
+
self.base = Path(self.temp.name).resolve()
|
|
23
|
+
self.target = self.base / "project with spaces"
|
|
24
|
+
self.source = self.base / "reference"
|
|
25
|
+
shutil.copytree(install.ROOT, self.source, ignore=shutil.ignore_patterns("__pycache__", ".git"))
|
|
26
|
+
|
|
27
|
+
def plan(self, **options):
|
|
28
|
+
return install.build_plan(self.target, root=self.source, **options)
|
|
29
|
+
|
|
30
|
+
def apply(self, **options):
|
|
31
|
+
plan = self.plan(**options)
|
|
32
|
+
self.assertFalse(plan["conflicts"], plan["conflicts"])
|
|
33
|
+
self.assertTrue(install.apply_plan(plan))
|
|
34
|
+
return plan
|
|
35
|
+
|
|
36
|
+
def write(self, path, data):
|
|
37
|
+
destination = self.target / path
|
|
38
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
destination.write_text(data, encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
def read_json(self, path):
|
|
42
|
+
return json.loads((self.target / path).read_text(encoding="utf-8"))
|
|
43
|
+
|
|
44
|
+
def laravel_and_go(self):
|
|
45
|
+
self.write("web/composer.json", json.dumps({"require": {"php": "^8.3", "laravel/framework": "^12.0"}}))
|
|
46
|
+
self.write("web/artisan", "#!/usr/bin/env php\n")
|
|
47
|
+
self.write("api/go.mod", "module example.test/api\n\ngo 1.23\n")
|
|
48
|
+
|
|
49
|
+
# Native settings (item 6)
|
|
50
|
+
|
|
51
|
+
def test_private_native_settings_use_the_local_claude_file(self):
|
|
52
|
+
self.laravel_and_go()
|
|
53
|
+
self.apply(agents=["claude"], extras=["native-settings"])
|
|
54
|
+
self.assertFalse((self.target / ".claude/settings.json").exists())
|
|
55
|
+
settings = self.read_json(".claude/settings.local.json")
|
|
56
|
+
self.assertIn("Bash(php artisan test *)", settings["permissions"]["allow"])
|
|
57
|
+
self.assertIn("Bash(go test ./... *)", settings["permissions"]["allow"])
|
|
58
|
+
deny = settings["permissions"]["deny"]
|
|
59
|
+
for rule in ("Read(.env)", "Read(.env.*)", "Read(auth.json)", "Read(/web/storage/*.key)", "Read(/secrets/**)"):
|
|
60
|
+
self.assertIn(rule, deny)
|
|
61
|
+
# Gitignore negations stay after the rules they carve out of.
|
|
62
|
+
self.assertGreater(deny.index("Read(!.env.example)"), deny.index("Read(.env.*)"))
|
|
63
|
+
self.assertNotIn("Read(.pypirc)", deny)
|
|
64
|
+
state = self.read_json("ai-kit/.install-state.json")
|
|
65
|
+
self.assertEqual(sorted(state["managed_json"]), [".claude/settings.local.json"])
|
|
66
|
+
self.assertFalse(self.plan()["actions"])
|
|
67
|
+
|
|
68
|
+
def test_moodle_and_python_secrets_are_module_scoped(self):
|
|
69
|
+
self.write("lms/config-dist.php", "<?php\n")
|
|
70
|
+
self.write("lms/lib/moodlelib.php", "<?php\n")
|
|
71
|
+
self.write("tools/pyproject.toml", '[project]\nname = "tools"\n')
|
|
72
|
+
self.apply(agents=["claude"], extras=["native-settings"])
|
|
73
|
+
deny = self.read_json(".claude/settings.local.json")["permissions"]["deny"]
|
|
74
|
+
self.assertIn("Read(/lms/config.php)", deny)
|
|
75
|
+
self.assertIn("Read(.pypirc)", deny)
|
|
76
|
+
self.assertNotIn("Read(config.php)", deny)
|
|
77
|
+
|
|
78
|
+
def test_team_mode_uses_shared_file_and_switching_moves_only_owned_entries(self):
|
|
79
|
+
self.laravel_and_go()
|
|
80
|
+
self.write(".claude/settings.local.json", json.dumps({"permissions": {"allow": ["Bash(make dev)"]}}))
|
|
81
|
+
self.apply(agents=["claude"], extras=["native-settings", "guards"])
|
|
82
|
+
self.assertIn("Bash(git push *)", self.read_json(".claude/settings.local.json")["permissions"]["ask"])
|
|
83
|
+
self.apply(mode="team")
|
|
84
|
+
local = self.read_json(".claude/settings.local.json")
|
|
85
|
+
self.assertEqual(local, {"permissions": {"allow": ["Bash(make dev)"]}})
|
|
86
|
+
shared = self.read_json(".claude/settings.json")
|
|
87
|
+
self.assertIn("Bash(git push *)", shared["permissions"]["ask"])
|
|
88
|
+
self.assertIn("Read(.env)", shared["permissions"]["deny"])
|
|
89
|
+
state = self.read_json("ai-kit/.install-state.json")
|
|
90
|
+
self.assertEqual(sorted(state["managed_json"]), [".claude/settings.json"])
|
|
91
|
+
self.assertFalse(self.plan()["actions"])
|
|
92
|
+
|
|
93
|
+
def test_previous_private_entries_in_shared_file_move_to_local_file(self):
|
|
94
|
+
self.apply(agents=["claude"])
|
|
95
|
+
hook = install.SESSION_START_COMMAND
|
|
96
|
+
user_and_old = {"model": "user", "permissions": {"ask": ["Bash(git push *)"]},
|
|
97
|
+
"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": hook}]}]}}
|
|
98
|
+
self.write(".claude/settings.json", json.dumps(user_and_old))
|
|
99
|
+
state_path = self.target / "ai-kit/.install-state.json"
|
|
100
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
101
|
+
state["managed_json"] = {".claude/settings.json": {"permissions.ask": ["Bash(git push *)"],
|
|
102
|
+
"hooks.SessionStart": [hook]}}
|
|
103
|
+
state_path.write_text(json.dumps(state), encoding="utf-8")
|
|
104
|
+
self.apply(extras=["guards", "session-start"])
|
|
105
|
+
self.assertEqual(self.read_json(".claude/settings.json"), {"model": "user"})
|
|
106
|
+
local = self.read_json(".claude/settings.local.json")
|
|
107
|
+
self.assertIn("Bash(git push *)", local["permissions"]["ask"])
|
|
108
|
+
self.assertEqual(local["hooks"]["SessionStart"][0]["hooks"][0]["command"], hook)
|
|
109
|
+
|
|
110
|
+
def test_unsafe_recorded_commands_are_not_allowed(self):
|
|
111
|
+
self.write("pkg/package.json", json.dumps({"scripts": {"test": "jest"}}))
|
|
112
|
+
self.apply(agents=["claude"])
|
|
113
|
+
project = self.read_json("ai-kit/project.json")
|
|
114
|
+
project["modules"][0]["commands"] = {"test": "npm test && rm -rf /", "lint": "npm run lint",
|
|
115
|
+
"build": "echo $HOME"}
|
|
116
|
+
self.write("ai-kit/project.json", json.dumps(project))
|
|
117
|
+
self.apply(extras=["native-settings"])
|
|
118
|
+
allow = self.read_json(".claude/settings.local.json")["permissions"]["allow"]
|
|
119
|
+
self.assertEqual(allow, ["Bash(npm run lint *)"])
|
|
120
|
+
|
|
121
|
+
def test_project_json_facts_drive_generated_settings(self):
|
|
122
|
+
self.laravel_and_go()
|
|
123
|
+
self.apply(agents=["claude"], extras=["native-settings"])
|
|
124
|
+
project = self.read_json("ai-kit/project.json")
|
|
125
|
+
for module in project["modules"]:
|
|
126
|
+
if module["path"] == "/web/":
|
|
127
|
+
module["commands"]["test"] = "composer test"
|
|
128
|
+
self.write("ai-kit/project.json", json.dumps(project))
|
|
129
|
+
self.apply()
|
|
130
|
+
allow = self.read_json(".claude/settings.local.json")["permissions"]["allow"]
|
|
131
|
+
self.assertIn("Bash(composer test *)", allow)
|
|
132
|
+
self.assertNotIn("Bash(php artisan test *)", allow)
|
|
133
|
+
|
|
134
|
+
def test_client_ignore_files_get_a_marked_secret_block(self):
|
|
135
|
+
self.laravel_and_go()
|
|
136
|
+
self.write(".cursorignore", "docs/drafts/\n")
|
|
137
|
+
self.apply(agents=["codex", "cursor", "aider", "gemini"], extras=["native-settings"])
|
|
138
|
+
cursor = (self.target / ".cursorignore").read_text(encoding="utf-8")
|
|
139
|
+
self.assertTrue(cursor.startswith("docs/drafts/\n"))
|
|
140
|
+
self.assertIn(install.START, cursor)
|
|
141
|
+
self.assertIn("\n.env\n", cursor)
|
|
142
|
+
self.assertIn("\n/web/storage/*.key\n", cursor)
|
|
143
|
+
for name in (".aiderignore", ".geminiignore"):
|
|
144
|
+
self.assertIn("!.env.example", (self.target / name).read_text(encoding="utf-8"))
|
|
145
|
+
self.assertFalse(self.plan()["actions"])
|
|
146
|
+
settings_path = self.target / "ai-kit/settings.json"
|
|
147
|
+
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
148
|
+
settings["native_settings"] = False
|
|
149
|
+
settings_path.write_text(json.dumps(settings), encoding="utf-8")
|
|
150
|
+
self.apply()
|
|
151
|
+
self.assertEqual((self.target / ".cursorignore").read_text(encoding="utf-8"), "docs/drafts/\n")
|
|
152
|
+
|
|
153
|
+
def test_ignore_files_are_skipped_for_unselected_clients(self):
|
|
154
|
+
self.apply(agents=["codex", "claude"], extras=["native-settings"])
|
|
155
|
+
for name in (".cursorignore", ".aiderignore", ".geminiignore"):
|
|
156
|
+
self.assertFalse((self.target / name).exists(), name)
|
|
157
|
+
|
|
158
|
+
def test_data_guards_add_universal_and_detected_stack_asks(self):
|
|
159
|
+
self.laravel_and_go()
|
|
160
|
+
self.apply(agents=["claude"], extras=["data-guards"])
|
|
161
|
+
ask = self.read_json(".claude/settings.local.json")["permissions"]["ask"]
|
|
162
|
+
self.assertIn("Bash(rm -rf *)", ask)
|
|
163
|
+
self.assertIn("Bash(php artisan migrate*)", ask)
|
|
164
|
+
self.assertNotIn("Bash(php admin/cli/upgrade.php*)", ask)
|
|
165
|
+
self.assertNotIn("Bash(git push *)", ask)
|
|
166
|
+
|
|
167
|
+
def test_doctor_accepts_generated_configuration(self):
|
|
168
|
+
self.laravel_and_go()
|
|
169
|
+
self.apply(agents=ALL_SCOPED + ["aider", "gemini"], preset="strict")
|
|
170
|
+
report = doctor.examine(self.target, root=self.source)
|
|
171
|
+
self.assertEqual(report["errors"], [])
|
|
172
|
+
self.assertEqual(report["warnings"], [])
|
|
173
|
+
|
|
174
|
+
def test_generated_claude_settings_match_golden_snapshot(self):
|
|
175
|
+
self.laravel_and_go()
|
|
176
|
+
self.apply(agents=["claude"], preset="strict")
|
|
177
|
+
golden = install.ROOT / "tests/fixtures/golden/claude-settings-local.json"
|
|
178
|
+
actual = (self.target / ".claude/settings.local.json").read_bytes()
|
|
179
|
+
# Git autocrlf can check the fixture out with CRLF on Windows; compare content, not endings.
|
|
180
|
+
self.assertEqual(actual.replace(b"\r\n", b"\n"), golden.read_bytes().replace(b"\r\n", b"\n"),
|
|
181
|
+
"Generated .claude/settings.local.json drifted from the golden snapshot; "
|
|
182
|
+
"update the golden file if the change is intentional.")
|
|
183
|
+
|
|
184
|
+
# Scoped rules (item 7)
|
|
185
|
+
|
|
186
|
+
def test_scoped_rules_use_each_client_native_format(self):
|
|
187
|
+
self.laravel_and_go()
|
|
188
|
+
self.apply(agents=ALL_SCOPED, extras=["scoped-rules"])
|
|
189
|
+
claude = (self.target / ".claude/rules/ai-kit-web.md").read_text(encoding="utf-8")
|
|
190
|
+
self.assertTrue(claude.startswith('---\npaths:\n - "web/**"\n---\n'))
|
|
191
|
+
self.assertIn("[LARAVEL](../../ai-kit/stacks/LARAVEL.md)", claude)
|
|
192
|
+
self.assertIn("test `php artisan test`", claude)
|
|
193
|
+
cursor = (self.target / ".cursor/rules/ai-kit-api.mdc").read_text(encoding="utf-8")
|
|
194
|
+
self.assertTrue(cursor.startswith("---\nglobs: api/**\nalwaysApply: false\n---\n"))
|
|
195
|
+
copilot = (self.target / ".github/instructions/ai-kit-api.instructions.md").read_text(encoding="utf-8")
|
|
196
|
+
self.assertTrue(copilot.startswith('---\napplyTo: "api/**"\n---\n'))
|
|
197
|
+
windsurf = (self.target / ".windsurf/rules/ai-kit-web.md").read_text(encoding="utf-8")
|
|
198
|
+
self.assertTrue(windsurf.startswith("---\ntrigger: glob\nglobs: web/**\n---\n"))
|
|
199
|
+
cline = (self.target / ".clinerules/ai-kit-web.md").read_text(encoding="utf-8")
|
|
200
|
+
self.assertIn("[PHP](../ai-kit/stacks/PHP.md)", cline)
|
|
201
|
+
# Codex reads AGENTS.md from the root down to the working directory only; no nested files.
|
|
202
|
+
self.assertFalse((self.target / "web/AGENTS.md").exists())
|
|
203
|
+
self.assertEqual(doctor.link_warnings(self.target), [])
|
|
204
|
+
self.assertFalse(self.plan()["actions"])
|
|
205
|
+
|
|
206
|
+
def test_root_module_uses_always_on_forms(self):
|
|
207
|
+
self.write("go.mod", "module example.test/app\n")
|
|
208
|
+
self.apply(agents=ALL_SCOPED, extras=["scoped-rules"])
|
|
209
|
+
self.assertTrue((self.target / ".claude/rules/ai-kit-root.md").read_text(encoding="utf-8")
|
|
210
|
+
.startswith("# AI-KIT module rules: /\n"))
|
|
211
|
+
self.assertTrue((self.target / ".cursor/rules/ai-kit-root.mdc").read_text(encoding="utf-8")
|
|
212
|
+
.startswith("---\nalwaysApply: true\n---\n"))
|
|
213
|
+
self.assertTrue((self.target / ".github/instructions/ai-kit-root.instructions.md")
|
|
214
|
+
.read_text(encoding="utf-8").startswith('---\napplyTo: "**"\n---\n'))
|
|
215
|
+
self.assertTrue((self.target / ".windsurf/rules/ai-kit-root.md").read_text(encoding="utf-8")
|
|
216
|
+
.startswith("---\ntrigger: always_on\n---\n"))
|
|
217
|
+
|
|
218
|
+
def test_scoped_rules_follow_project_json_and_edited_rules_conflict(self):
|
|
219
|
+
self.laravel_and_go()
|
|
220
|
+
self.apply(agents=["claude"], extras=["scoped-rules"])
|
|
221
|
+
project = self.read_json("ai-kit/project.json")
|
|
222
|
+
for module in project["modules"]:
|
|
223
|
+
if module["path"] == "/web/":
|
|
224
|
+
module["profiles"] = ["FILAMENT", "LARAVEL", "PHP"]
|
|
225
|
+
self.write("ai-kit/project.json", json.dumps(project))
|
|
226
|
+
self.apply()
|
|
227
|
+
self.assertIn("[FILAMENT]", (self.target / ".claude/rules/ai-kit-web.md").read_text(encoding="utf-8"))
|
|
228
|
+
rule = self.target / ".claude/rules/ai-kit-api.md"
|
|
229
|
+
rule.write_text(rule.read_text(encoding="utf-8") + "Local note.\n", encoding="utf-8")
|
|
230
|
+
for module in project["modules"]:
|
|
231
|
+
module["commands"] = {}
|
|
232
|
+
self.write("ai-kit/project.json", json.dumps(project))
|
|
233
|
+
plan = self.plan()
|
|
234
|
+
self.assertIn(".claude/rules/ai-kit-api.md", plan["conflicts"])
|
|
235
|
+
|
|
236
|
+
def test_unsupported_module_paths_are_skipped_with_a_warning(self):
|
|
237
|
+
self.apply(agents=["claude"])
|
|
238
|
+
self.write("ai-kit/project.json", json.dumps({"schema": 1, "operations": {}, "modules": [
|
|
239
|
+
{"path": "/odd dir/", "profiles": ["GO"], "commands": {}},
|
|
240
|
+
{"path": "/../escape/", "profiles": ["GO"], "commands": {}},
|
|
241
|
+
{"path": "/svc", "profiles": ["GO"], "commands": {}}]}))
|
|
242
|
+
plan = self.apply(extras=["scoped-rules"])
|
|
243
|
+
self.assertEqual(sum("Skipping module path" in w for w in plan["warnings"]), 2)
|
|
244
|
+
self.assertTrue((self.target / ".claude/rules/ai-kit-svc.md").is_file())
|
|
245
|
+
self.assertEqual(len(list((self.target / ".claude/rules").iterdir())), 1)
|
|
246
|
+
|
|
247
|
+
def test_colliding_module_slugs_stay_unique(self):
|
|
248
|
+
self.write("a-b/go.mod", "module example.test/one\n")
|
|
249
|
+
self.write("a/b/go.mod", "module example.test/two\n")
|
|
250
|
+
self.apply(agents=["claude"], extras=["scoped-rules"])
|
|
251
|
+
self.assertEqual(len(list((self.target / ".claude/rules").glob("ai-kit-a-b*.md"))), 2)
|
|
252
|
+
|
|
253
|
+
def test_deselecting_a_client_with_scoped_rules_requires_retirement_review(self):
|
|
254
|
+
self.laravel_and_go()
|
|
255
|
+
self.apply(agents=["codex", "cursor"], extras=["scoped-rules"])
|
|
256
|
+
plan = self.plan(agents=["codex"])
|
|
257
|
+
self.assertIn(".cursor/rules/ai-kit-web.mdc", plan["adapter_conflicts"])
|
|
258
|
+
|
|
259
|
+
# Presets (item 8)
|
|
260
|
+
|
|
261
|
+
def test_presets_set_mode_and_managed_extras(self):
|
|
262
|
+
plan = self.plan(preset="solo")
|
|
263
|
+
self.assertEqual(plan["mode"], "private")
|
|
264
|
+
self.assertEqual(plan["extras"], ["native-settings", "scoped-rules", "session-start"])
|
|
265
|
+
plan = self.plan(preset="team")
|
|
266
|
+
self.assertEqual(plan["mode"], "team")
|
|
267
|
+
self.assertIn("guards", plan["extras"])
|
|
268
|
+
plan = self.plan(preset="team", mode="private", extras=["ci"])
|
|
269
|
+
self.assertEqual(plan["mode"], "private")
|
|
270
|
+
self.assertIn("ci", plan["extras"])
|
|
271
|
+
|
|
272
|
+
def test_minimal_preset_turns_managed_extras_off_and_keeps_mode(self):
|
|
273
|
+
self.laravel_and_go()
|
|
274
|
+
self.apply(agents=["claude"], preset="team")
|
|
275
|
+
self.assertTrue((self.target / ".claude/settings.json").is_file())
|
|
276
|
+
plan = self.apply(preset="minimal")
|
|
277
|
+
self.assertEqual(plan["mode"], "team")
|
|
278
|
+
self.assertEqual(plan["extras"], [])
|
|
279
|
+
self.assertTrue(any("Preset minimal turns off previously enabled extras: session-start, guards, "
|
|
280
|
+
"native-settings, scoped-rules" in w for w in plan["warnings"]))
|
|
281
|
+
settings = self.read_json("ai-kit/settings.json")
|
|
282
|
+
self.assertIs(settings["guards"], False)
|
|
283
|
+
self.assertIs(settings["scoped_rules"], False)
|
|
284
|
+
self.assertNotIn("managed_json", self.read_json("ai-kit/.install-state.json"))
|
|
285
|
+
self.assertNotIn("permissions", self.read_json(".claude/settings.json"))
|
|
286
|
+
# Generated rules are never deleted; the preview names them for review.
|
|
287
|
+
self.assertTrue(any("retained without a current source: .claude/rules/ai-kit-web.md" in w
|
|
288
|
+
for w in plan["warnings"]))
|
|
289
|
+
|
|
290
|
+
def test_explicit_flag_keeps_an_extra_the_preset_would_turn_off(self):
|
|
291
|
+
self.apply(agents=["claude"], extras=["guards"])
|
|
292
|
+
plan = self.apply(preset="solo", extras=["guards"])
|
|
293
|
+
self.assertIn("guards", plan["extras"])
|
|
294
|
+
self.assertFalse(any("turns off" in w for w in plan["warnings"]))
|
|
295
|
+
self.assertIn("Bash(git push *)", self.read_json(".claude/settings.local.json")["permissions"]["ask"])
|
|
296
|
+
|
|
297
|
+
def test_unknown_preset_is_refused(self):
|
|
298
|
+
with self.assertRaises(ValueError):
|
|
299
|
+
self.plan(preset="everything")
|
|
300
|
+
|
|
301
|
+
def test_cli_preset_and_new_flags(self):
|
|
302
|
+
output = io.StringIO()
|
|
303
|
+
with contextlib.redirect_stdout(output):
|
|
304
|
+
code = install.main([str(self.target), "--preset", "strict", "--with-ci"])
|
|
305
|
+
self.assertEqual(code, 0)
|
|
306
|
+
report = json.loads(output.getvalue())
|
|
307
|
+
self.assertEqual(report["preset"], "strict")
|
|
308
|
+
self.assertEqual(report["extras"], sorted(install.PRESET_EXTRAS + ("ci",)))
|
|
309
|
+
output = io.StringIO()
|
|
310
|
+
with contextlib.redirect_stdout(output):
|
|
311
|
+
install.main([str(self.target), "--with-native-settings", "--with-scoped-rules", "--with-data-guards"])
|
|
312
|
+
self.assertEqual(json.loads(output.getvalue())["extras"], ["data-guards", "native-settings", "scoped-rules"])
|
|
313
|
+
|
|
314
|
+
# Workflow skills (item 9)
|
|
315
|
+
|
|
316
|
+
def test_workflow_skills_install_with_native_copies(self):
|
|
317
|
+
self.apply(agents=["codex", "claude"])
|
|
318
|
+
for name in ("ai-kit-bootstrap", "ai-kit-review", "ai-kit-sync-context"):
|
|
319
|
+
source = self.target / ".agents/skills" / name / "SKILL.md"
|
|
320
|
+
self.assertTrue(source.is_file(), name)
|
|
321
|
+
self.assertEqual(source.read_bytes(), (self.target / ".claude/skills" / name / "SKILL.md").read_bytes())
|
|
322
|
+
self.assertIn("Run only when the user explicitly asks", source.read_text(encoding="utf-8"))
|
|
323
|
+
self.assertEqual(doctor.link_warnings(self.target), [])
|
|
324
|
+
|
|
325
|
+
def test_registry_refuses_invalid_scoped_rules_and_ignore_files(self):
|
|
326
|
+
registry_path = self.source / install.AGENT_REGISTRY_PATH
|
|
327
|
+
original = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
328
|
+
for field, value in (("scoped_rules", {"directory": "../rules/", "format": "claude-paths"}),
|
|
329
|
+
("scoped_rules", {"directory": ".claude/rules/", "format": "unknown"}),
|
|
330
|
+
("scoped_rules", ".claude/rules/"),
|
|
331
|
+
("ignore_file", "../.cursorignore"),
|
|
332
|
+
("ignore_file", "sub/.ignore")):
|
|
333
|
+
with self.subTest(field=field, value=value):
|
|
334
|
+
registry = json.loads(json.dumps(original))
|
|
335
|
+
registry["agents"]["cursor"][field] = value
|
|
336
|
+
registry_path.write_text(json.dumps(registry), encoding="utf-8")
|
|
337
|
+
with self.assertRaises(ValueError):
|
|
338
|
+
install.load_agent_registry(self.source)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
if __name__ == "__main__":
|
|
342
|
+
unittest.main()
|