sanityops-cli 0.1.8__tar.gz → 0.1.9__tar.gz

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.
Files changed (135) hide show
  1. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/CHANGELOG.md +20 -0
  2. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/PKG-INFO +1 -1
  3. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/pyproject.toml +1 -1
  4. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/__init__.py +1 -1
  5. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/__init__.py +35 -0
  6. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/checker.py +484 -0
  7. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/markdown_reporter.py +191 -0
  8. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/models.py +202 -0
  9. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/renderer.py +183 -0
  10. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/rules/__init__.py +55 -0
  11. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/rules/qc0_baseline.py +489 -0
  12. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/rules/qc1_syntax.py +401 -0
  13. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/rules/qc2_resource.py +345 -0
  14. sanityops_cli-0.1.9/src/sanityops_cli/code_contract_checker/rules/qc3_pattern.py +337 -0
  15. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/commands/inspect.py +67 -10
  16. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/defect_checker/checker.py +9 -2
  17. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/defect_checker/markdown_reporter.py +167 -20
  18. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/defect_checker/renderer.py +39 -1
  19. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/utils/artifact_packer.py +34 -17
  20. sanityops_cli-0.1.9/tests/unit/code_contract_checker/test_checker.py +165 -0
  21. sanityops_cli-0.1.9/tests/unit/code_contract_checker/test_renderer.py +73 -0
  22. sanityops_cli-0.1.9/tests/unit/code_contract_checker/test_rules.py +328 -0
  23. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/commands/test_inspect.py +106 -0
  24. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/defect_checker/test_markdown_reporter.py +49 -0
  25. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/defect_checker/test_renderer.py +65 -1
  26. sanityops_cli-0.1.9/tests/unit/progress/__init__.py +0 -0
  27. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/utils/test_artifact_packer.py +63 -4
  28. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  29. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  30. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  31. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/dependabot.yml +0 -0
  33. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/scripts/pr_review_bot.py +0 -0
  34. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/workflows/ai-review.yml +0 -0
  35. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/workflows/ci.yml +0 -0
  36. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.github/workflows/release.yml +0 -0
  37. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/.gitignore +0 -0
  38. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/CODE_OF_CONDUCT.md +0 -0
  39. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/CONTRIBUTING.md +0 -0
  40. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/LICENSE +0 -0
  41. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/NOTICE +0 -0
  42. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/README.md +0 -0
  43. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/SECURITY.md +0 -0
  44. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-08-13-model-config-comments-in-init-template.md +0 -0
  45. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-08-14-loading-animations.md +0 -0
  46. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-08-14-skills-files-banner-readme.md +0 -0
  47. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-08-28-copyright-compliance.md +0 -0
  48. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-09-08-config-command.md +0 -0
  49. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-09-20-cross-artifact-rendering.md +0 -0
  50. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-09-20-help-panel-migration.md +0 -0
  51. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/plans/2026-09-20-repair-feature.md +0 -0
  52. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-08-13-model-config-comments-in-init-template-design.md +0 -0
  53. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-08-14-loading-animations-design.md +0 -0
  54. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-08-14-skills-files-banner-readme-design.md +0 -0
  55. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-08-28-copyright-compliance-design.md +0 -0
  56. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-09-08-config-command-design.md +0 -0
  57. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-09-20-cross-artifact-rendering-design.md +0 -0
  58. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-09-20-help-panel-migration-design.md +0 -0
  59. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/docs/superpowers/specs/2026-09-20-repair-feature-design.md +0 -0
  60. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/entry.py +0 -0
  61. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/release.sh +0 -0
  62. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/scripts/generate-checksums.ps1 +0 -0
  63. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/scripts/install.ps1 +0 -0
  64. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/scripts/install.sh +0 -0
  65. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/__init__.py +0 -0
  66. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/repair_agent/__init__.py +0 -0
  67. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/repair_agent/agent.py +0 -0
  68. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/repair_agent/prompts.py +0 -0
  69. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/repair_agent/tools/__init__.py +0 -0
  70. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/repair_agent/tools/store_repairs_tool.py +0 -0
  71. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/agent.py +0 -0
  72. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/hooks/progress_hook.py +0 -0
  73. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/models/finding.py +0 -0
  74. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/prompts.py +0 -0
  75. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/tools/grep_tool.py +0 -0
  76. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/tools/listfiles_tool.py +0 -0
  77. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/tools/readfile_tool.py +0 -0
  78. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/agents/scanner_agent/tools/storefindings_tool.py +0 -0
  79. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/api/__init__.py +0 -0
  80. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/api/client.py +0 -0
  81. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/commands/__init__.py +0 -0
  82. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/commands/config.py +0 -0
  83. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/commands/init.py +0 -0
  84. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/constants/__init__.py +0 -0
  85. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/constants/config_defaults.py +0 -0
  86. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/constants/exit_codes.py +0 -0
  87. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/defect_checker/__init__.py +0 -0
  88. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/defect_checker/llm_config.py +0 -0
  89. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/exceptions/__init__.py +0 -0
  90. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/exceptions/api_exceptions.py +0 -0
  91. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/exceptions/base_exceptions.py +0 -0
  92. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/help_panel.py +0 -0
  93. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/logging/__init__.py +0 -0
  94. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/logging/logger.py +0 -0
  95. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/main.py +0 -0
  96. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/progress/__init__.py +0 -0
  97. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/progress/tracker.py +0 -0
  98. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/renderers/__init__.py +0 -0
  99. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/renderers/command_renderer/inspect_command_renderer.py +0 -0
  100. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/templates/__init__.py +0 -0
  101. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/templates/inspect_config.yaml +0 -0
  102. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/utils/__init__.py +0 -0
  103. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/utils/config_loader.py +0 -0
  104. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/utils/config_resolver.py +0 -0
  105. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/src/sanityops_cli/utils/validators.py +0 -0
  106. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/__init__.py +0 -0
  107. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/commands/__init__.py +0 -0
  108. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/commands/test_config.py +0 -0
  109. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/commands/test_init.py +0 -0
  110. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/commands/test_inspect_repair.py +0 -0
  111. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/test_help_panel.py +0 -0
  112. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/__init__.py +0 -0
  113. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/__init__.py +0 -0
  114. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/repair_agent/__init__.py +0 -0
  115. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/repair_agent/test_agent.py +0 -0
  116. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/repair_agent/test_store_repairs_tool.py +0 -0
  117. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/__init__.py +0 -0
  118. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/models/__init__.py +0 -0
  119. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/models/test_finding.py +0 -0
  120. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/test_agent.py +0 -0
  121. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/test_deterministic_capture.py +0 -0
  122. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/test_progress_hook.py +0 -0
  123. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/agents/scanner_agent/test_prompts.py +0 -0
  124. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/api/__init__.py +0 -0
  125. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/api/test_client.py +0 -0
  126. {sanityops_cli-0.1.8/tests/unit/defect_checker → sanityops_cli-0.1.9/tests/unit/code_contract_checker}/__init__.py +0 -0
  127. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/commands/test_version.py +0 -0
  128. {sanityops_cli-0.1.8/tests/unit/logging → sanityops_cli-0.1.9/tests/unit/defect_checker}/__init__.py +0 -0
  129. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/defect_checker/test_checker.py +0 -0
  130. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/defect_checker/test_llm_config.py +0 -0
  131. {sanityops_cli-0.1.8/tests/unit/progress → sanityops_cli-0.1.9/tests/unit/logging}/__init__.py +0 -0
  132. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/logging/test_logger.py +0 -0
  133. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/progress/test_tracker.py +0 -0
  134. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/unit/utils/test_config_loader.py +0 -0
  135. {sanityops_cli-0.1.8 → sanityops_cli-0.1.9}/tests/utils/test_config_resolver.py +0 -0
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - Code Contract (QC) lint check before LLM analysis: AST-based static inspection
13
+ of embedded code blocks and Python source files (syntax, undefined names,
14
+ resource leaks, code patterns) with `--skip-qc-lint` to opt out
15
+
16
+ ### Changed
17
+
18
+ - **Breaking**: tool schema uploads now emit the provider-neutral flat format
19
+ `{name, description, parameters}` in both upload paths (push and inspect).
20
+ The OpenAI `{type, function}` wrapper is stripped during packing; the
21
+ backend converts to the selected provider format when a model endpoint is set.
22
+
23
+ ### Fixed
24
+
25
+ - Failed module inspections now render a CAUTION callout instead of a
26
+ misleading "No defects found" in markdown reports
27
+ - Duplicate defects reported under both `$.inputSchema` and `$.parameters`
28
+ are deduplicated in terminal and markdown output
29
+
10
30
  ## [0.0.8] - 2026-09-21
11
31
 
12
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sanityops-cli
3
- Version: 0.1.8
3
+ Version: 0.1.9
4
4
  Summary: CLI tool for static defect inspection of AI logical artifacts
5
5
  Project-URL: Homepage, https://sanityops.org
6
6
  Project-URL: Documentation, https://sanityops.org
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sanityops-cli"
7
- version = "0.1.8"
7
+ version = "0.1.9"
8
8
  description = "CLI tool for static defect inspection of AI logical artifacts"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -13,4 +13,4 @@
13
13
  # limitations under the License.
14
14
  #
15
15
 
16
- __version__ = "0.1.8"
16
+ __version__ = "0.1.9"
@@ -0,0 +1,35 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Code Contract inspection module for Logic Artifacts."""
16
+
17
+ from .models import (
18
+ RULE_DEFINITIONS,
19
+ GateResult,
20
+ QCArtifactResult,
21
+ QCDefect,
22
+ QCInspectionResult,
23
+ QCRuleID,
24
+ Severity,
25
+ )
26
+
27
+ __all__ = [
28
+ "QCArtifactResult",
29
+ "QCDefect",
30
+ "QCInspectionResult",
31
+ "QCRuleID",
32
+ "RULE_DEFINITIONS",
33
+ "Severity",
34
+ "GateResult",
35
+ ]
@@ -0,0 +1,484 @@
1
+ # Copyright 2026 zipsonken
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Main Code Contract inspector for Logic Artifacts."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import re
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+ from time import perf_counter
25
+ from typing import Any
26
+
27
+ import yaml
28
+
29
+ from .models import (
30
+ QCArtifactResult,
31
+ QCInspectionResult,
32
+ )
33
+ from .rules import (
34
+ check_qc_0_1_syntax_parsability,
35
+ check_qc_0_3_structural_completeness,
36
+ check_qc_1_1_undefined_name,
37
+ check_qc_1_4_type_mismatch,
38
+ check_qc_2_1_unclosed_file_handle,
39
+ check_qc_2_2_unreleased_network,
40
+ check_qc_2_3_missing_exception_handling,
41
+ check_qc_3_1_unused_import,
42
+ check_qc_3_2_missing_import,
43
+ check_qc_3_3_hardcoded_demo_value,
44
+ extract_code_blocks,
45
+ )
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ #: Maximum nesting depth when recursing into nested tool parameter schemas.
50
+ #: Prevents stack overflow on malformed or adversarial deeply-nested schemas.
51
+ MAX_TOOL_SCHEMA_DEPTH = 10
52
+
53
+ #: All QC checks applied to every Python code snippet, in order.
54
+ #: Adding a new rule here automatically applies it to both embedded
55
+ #: code blocks and whole Python source files.
56
+ #: Note: QC-0.2 (language declaration) is intentionally absent here - it
57
+ #: operates on the fence marker rather than the code body, so it is run
58
+ #: inside extract_code_blocks during block discovery instead.
59
+ _QC_PYTHON_CHECKS = (
60
+ check_qc_0_1_syntax_parsability,
61
+ check_qc_0_3_structural_completeness,
62
+ check_qc_1_1_undefined_name,
63
+ check_qc_1_4_type_mismatch,
64
+ check_qc_2_1_unclosed_file_handle,
65
+ check_qc_2_2_unreleased_network,
66
+ check_qc_2_3_missing_exception_handling,
67
+ check_qc_3_1_unused_import,
68
+ check_qc_3_2_missing_import,
69
+ check_qc_3_3_hardcoded_demo_value,
70
+ )
71
+
72
+
73
+ class CodeContractChecker:
74
+ """Inspects Logic Artifacts for Code Contract violations.
75
+
76
+ This checker runs WITHOUT LLM - it uses pure AST parsing and
77
+ pattern matching to detect code defects in embedded code snippets.
78
+ """
79
+
80
+ def __init__(self, verbose: bool = False):
81
+ """Initialize the checker.
82
+
83
+ Args:
84
+ verbose: Enable verbose output.
85
+ """
86
+ self.verbose = verbose
87
+
88
+ def check_from_paths(
89
+ self,
90
+ prompts: list[str],
91
+ tools: list[str],
92
+ skills: list[str],
93
+ ) -> QCInspectionResult:
94
+ """Inspect artifacts from file paths.
95
+
96
+ Args:
97
+ prompts: List of prompt file paths.
98
+ tools: List of tool schema file paths.
99
+ skills: List of skill file paths.
100
+
101
+ Returns:
102
+ QCInspectionResult with all defects found.
103
+ """
104
+ start_time = perf_counter()
105
+ # Single timestamp for the whole run so all artifacts in one
106
+ # inspection share a consistent checked_at value.
107
+ checked_at = datetime.now().isoformat()
108
+ result = QCInspectionResult()
109
+
110
+ # Check prompts
111
+ for path in prompts:
112
+ artifact_result = self._check_prompt(path, checked_at)
113
+ result.prompts.append(artifact_result)
114
+
115
+ # Check tools
116
+ for path in tools:
117
+ artifact_result = self._check_tool(path, checked_at)
118
+ result.tools.append(artifact_result)
119
+
120
+ # Check skills
121
+ for path in skills:
122
+ artifact_result = self._check_skill(path, checked_at)
123
+ result.skills.append(artifact_result)
124
+
125
+ result.inspection_time_seconds = perf_counter() - start_time
126
+ return result
127
+
128
+ def _read_artifact(self, path: str) -> str | None:
129
+ """Read an artifact file, returning None on read failure."""
130
+ try:
131
+ return Path(path).read_text(encoding="utf-8")
132
+ except (OSError, UnicodeDecodeError) as e:
133
+ # OSError covers FileNotFoundError, PermissionError,
134
+ # IsADirectoryError and other path-related failures;
135
+ # UnicodeDecodeError (a ValueError) covers binary files.
136
+ logger.debug("Could not read %s: %s", path, e)
137
+ if self.verbose:
138
+ logger.warning("Could not read %s: %s", path, e)
139
+ return None
140
+
141
+ def _check_prompt(self, path: str, checked_at: str) -> QCArtifactResult:
142
+ """Check a prompt file for code contract violations.
143
+
144
+ Args:
145
+ path: Absolute path to the prompt file.
146
+ checked_at: ISO timestamp shared across the inspection run.
147
+
148
+ Returns:
149
+ QCArtifactResult with any defects found.
150
+ """
151
+ artifact_result = QCArtifactResult(
152
+ artifact_type="prompt",
153
+ artifact_path=path,
154
+ artifact_name=Path(path).name,
155
+ checked_at=checked_at,
156
+ )
157
+
158
+ content = self._read_artifact(path)
159
+ if content is None:
160
+ return artifact_result
161
+
162
+ # Extract and check code blocks
163
+ code_blocks = extract_code_blocks(content, artifact_result, location_prefix="$.content")
164
+
165
+ for idx, (code, language, line_number) in enumerate(code_blocks):
166
+ location = f"$.content.code_blocks[{idx}]"
167
+ self._run_qc_checks(code, language, artifact_result, location, line_number)
168
+
169
+ return artifact_result
170
+
171
+ def _check_tool(self, path: str, checked_at: str) -> QCArtifactResult:
172
+ """Check a tool schema file for code contract violations.
173
+
174
+ Code snippets can appear in:
175
+ - description field
176
+ - parameters.properties.*.description
177
+ - parameters.properties.*.default (for code-like values)
178
+ - parameters.properties.*.examples
179
+
180
+ For Python source files (.py), the entire file is checked directly.
181
+
182
+ Args:
183
+ path: Absolute path to the tool schema file.
184
+ checked_at: ISO timestamp shared across the inspection run.
185
+
186
+ Returns:
187
+ QCArtifactResult with any defects found.
188
+ """
189
+ artifact_result = QCArtifactResult(
190
+ artifact_type="tool",
191
+ artifact_path=path,
192
+ artifact_name=Path(path).stem,
193
+ checked_at=checked_at,
194
+ )
195
+
196
+ content = self._read_artifact(path)
197
+ if content is None:
198
+ return artifact_result
199
+
200
+ # Handle Python source files directly
201
+ if Path(path).suffix.lower() == ".py":
202
+ # For .py files, check the entire file as Python code
203
+ self._run_qc_checks(content, "python", artifact_result, "$.content", line_offset=0)
204
+ return artifact_result
205
+
206
+ # Parse JSON or YAML
207
+ try:
208
+ if Path(path).suffix.lower() == ".json":
209
+ data = json.loads(content)
210
+ else:
211
+ data = yaml.safe_load(content)
212
+ except (json.JSONDecodeError, yaml.YAMLError) as e:
213
+ # Not valid JSON/YAML - try to extract code blocks from raw content.
214
+ # Log at DEBUG so a fallback stays explainable without spamming
215
+ # normal output.
216
+ logger.debug(
217
+ "Could not parse %s as JSON/YAML, falling back to code blocks: %s", path, e
218
+ )
219
+ code_blocks = extract_code_blocks(content, artifact_result, location_prefix="$.content")
220
+ self._check_code_blocks(code_blocks, artifact_result, location_prefix="$.content")
221
+ return artifact_result
222
+
223
+ # Check description field
224
+ description = data.get("description", "")
225
+ if description and isinstance(description, str):
226
+ code_blocks = extract_code_blocks(
227
+ description, artifact_result, location_prefix="$.description"
228
+ )
229
+ self._check_code_blocks(code_blocks, artifact_result, location_prefix="$.description")
230
+
231
+ # Check parameter descriptions and examples
232
+ parameters = data.get("parameters", {})
233
+ if isinstance(parameters, dict):
234
+ self._check_tool_parameters(parameters, artifact_result, "$.parameters")
235
+
236
+ return artifact_result
237
+
238
+ def _run_qc_checks(
239
+ self,
240
+ code: str,
241
+ language: str,
242
+ artifact_result: QCArtifactResult,
243
+ location: str,
244
+ line_offset: int,
245
+ ) -> None:
246
+ """Apply every QC check to a single Python code snippet.
247
+
248
+ Shared by embedded code blocks and whole Python source files.
249
+
250
+ Args:
251
+ code: The code snippet to check.
252
+ language: The declared language.
253
+ artifact_result: The artifact result to add defects to.
254
+ location: JSONPath-like location of the code.
255
+ line_offset: Line offset added to each AST node's own line number
256
+ to produce the absolute line in the source. Pass 0 for whole
257
+ files; for embedded blocks pass the block's starting line.
258
+ """
259
+ for check in _QC_PYTHON_CHECKS:
260
+ check(code, language, artifact_result, location, line_offset)
261
+
262
+ def _check_tool_parameters(
263
+ self,
264
+ parameters: dict[str, Any],
265
+ artifact_result: QCArtifactResult,
266
+ location_prefix: str,
267
+ *,
268
+ depth: int = 0,
269
+ max_depth: int = MAX_TOOL_SCHEMA_DEPTH,
270
+ ) -> None:
271
+ """Recursively check tool parameters for code snippets.
272
+
273
+ Args:
274
+ parameters: The parameters dict to check.
275
+ artifact_result: The artifact result to add defects to.
276
+ location_prefix: Location prefix for defects.
277
+ depth: Current recursion depth.
278
+ max_depth: Maximum nesting depth before stopping (prevents stack overflow
279
+ on malformed or adversarial deeply-nested schemas).
280
+ """
281
+ if depth >= max_depth:
282
+ return
283
+
284
+ properties = parameters.get("properties", {})
285
+ if not isinstance(properties, dict):
286
+ return
287
+
288
+ for prop_name, prop_def in properties.items():
289
+ if not isinstance(prop_def, dict):
290
+ continue
291
+
292
+ prop_location = f"{location_prefix}.properties.{prop_name}"
293
+
294
+ # Check description
295
+ description = prop_def.get("description", "")
296
+ if description and isinstance(description, str):
297
+ code_blocks = extract_code_blocks(
298
+ description, artifact_result, location_prefix=f"{prop_location}.description"
299
+ )
300
+ self._check_code_blocks(
301
+ code_blocks, artifact_result, location_prefix=f"{prop_location}.description"
302
+ )
303
+
304
+ # Check default value if it looks like code
305
+ default = prop_def.get("default")
306
+ if isinstance(default, str) and "\n" in default:
307
+ # Multi-line default might be code
308
+ code_blocks = extract_code_blocks(
309
+ default, artifact_result, location_prefix=f"{prop_location}.default"
310
+ )
311
+ self._check_code_blocks(
312
+ code_blocks, artifact_result, location_prefix=f"{prop_location}.default"
313
+ )
314
+
315
+ # Check examples
316
+ examples = prop_def.get("examples", [])
317
+ if isinstance(examples, list):
318
+ for idx, example in enumerate(examples):
319
+ if isinstance(example, str) and "\n" in example:
320
+ code_blocks = extract_code_blocks(
321
+ example,
322
+ artifact_result,
323
+ location_prefix=f"{prop_location}.examples[{idx}]",
324
+ )
325
+ self._check_code_blocks(
326
+ code_blocks,
327
+ artifact_result,
328
+ location_prefix=f"{prop_location}.examples[{idx}]",
329
+ )
330
+
331
+ # Check nested object properties
332
+ if prop_def.get("type") == "object":
333
+ nested_props = prop_def.get("properties", {})
334
+ if isinstance(nested_props, dict):
335
+ self._check_tool_parameters(
336
+ prop_def,
337
+ artifact_result,
338
+ prop_location,
339
+ depth=depth + 1,
340
+ max_depth=max_depth,
341
+ )
342
+
343
+ # Check items for array types
344
+ items = prop_def.get("items", {})
345
+ if isinstance(items, dict) and items.get("type") == "object":
346
+ self._check_tool_parameters(
347
+ items,
348
+ artifact_result,
349
+ f"{prop_location}.items",
350
+ depth=depth + 1,
351
+ max_depth=max_depth,
352
+ )
353
+
354
+ def _check_skill(self, path: str, checked_at: str) -> QCArtifactResult:
355
+ """Check a skill file for code contract violations.
356
+
357
+ Code snippets can appear in:
358
+ - description field (frontmatter)
359
+ - section content (markdown body)
360
+
361
+ Args:
362
+ path: Absolute path to the skill file.
363
+ checked_at: ISO timestamp shared across the inspection run.
364
+
365
+ Returns:
366
+ QCArtifactResult with any defects found.
367
+ """
368
+ artifact_result = QCArtifactResult(
369
+ artifact_type="skill",
370
+ artifact_path=path,
371
+ artifact_name=Path(path).stem,
372
+ checked_at=checked_at,
373
+ )
374
+
375
+ content = self._read_artifact(path)
376
+ if content is None:
377
+ return artifact_result
378
+
379
+ # Parse frontmatter
380
+ frontmatter, body = self._parse_frontmatter(content)
381
+
382
+ # Check description in frontmatter
383
+ description = frontmatter.get("description", "")
384
+ if description and isinstance(description, str):
385
+ code_blocks = extract_code_blocks(
386
+ description, artifact_result, location_prefix="$.frontmatter.description"
387
+ )
388
+ self._check_code_blocks(
389
+ code_blocks, artifact_result, location_prefix="$.frontmatter.description"
390
+ )
391
+
392
+ # Check markdown body sections
393
+ sections = self._parse_markdown_sections(body)
394
+ for section_title, section_content in sections:
395
+ safe_title = section_title.replace(".", "_").replace("[", "_")
396
+ code_blocks = extract_code_blocks(
397
+ section_content,
398
+ artifact_result,
399
+ location_prefix=f"$.sections.{safe_title}",
400
+ )
401
+ self._check_code_blocks(
402
+ code_blocks, artifact_result, location_prefix=f"$.sections.{safe_title}"
403
+ )
404
+
405
+ return artifact_result
406
+
407
+ def _parse_frontmatter(self, content: str) -> tuple[dict[str, Any], str]:
408
+ """Parse YAML frontmatter from markdown content.
409
+
410
+ Args:
411
+ content: The full file content.
412
+
413
+ Returns:
414
+ Tuple of (frontmatter_dict, body_content).
415
+ """
416
+ if not content.startswith("---"):
417
+ return {}, content
418
+
419
+ # Find the closing ---
420
+ end_idx = content.find("\n---", 4)
421
+ if end_idx == -1:
422
+ return {}, content
423
+
424
+ frontmatter_str = content[4:end_idx]
425
+ body = content[end_idx + 5 :].lstrip()
426
+
427
+ try:
428
+ frontmatter = yaml.safe_load(frontmatter_str) or {}
429
+ except yaml.YAMLError:
430
+ frontmatter = {}
431
+
432
+ return frontmatter, body
433
+
434
+ def _parse_markdown_sections(self, content: str) -> list[tuple[str, str]]:
435
+ """Parse markdown into sections by headings of any level.
436
+
437
+ Every ATX heading (``#`` through ``######``) starts a new section so
438
+ code blocks under ``###``/``####`` headings are attributed to their
439
+ own section rather than their parent ``##`` block.
440
+
441
+ Args:
442
+ content: Markdown content.
443
+
444
+ Returns:
445
+ List of (section_title, section_content) tuples.
446
+ """
447
+ heading_re = re.compile(r"^(#{1,6})\s+(.*)$")
448
+ sections: list[tuple[str, str]] = []
449
+ current_title = ""
450
+ current_content: list[str] = []
451
+
452
+ for line in content.split("\n"):
453
+ match = heading_re.match(line)
454
+ if match:
455
+ # Save previous section
456
+ if current_content:
457
+ sections.append((current_title, "\n".join(current_content)))
458
+ current_title = match.group(2).strip()
459
+ current_content = []
460
+ else:
461
+ current_content.append(line)
462
+
463
+ # Save last section
464
+ if current_content:
465
+ sections.append((current_title, "\n".join(current_content)))
466
+
467
+ return sections
468
+
469
+ def _check_code_blocks(
470
+ self,
471
+ code_blocks: list[tuple[str, str, int]],
472
+ artifact_result: QCArtifactResult,
473
+ location_prefix: str = "$",
474
+ ) -> None:
475
+ """Apply all QC checks to a list of code blocks.
476
+
477
+ Args:
478
+ code_blocks: List of (code, language, line_number) tuples.
479
+ artifact_result: The artifact result to add defects to.
480
+ location_prefix: Prefix for defect locations.
481
+ """
482
+ for idx, (code, language, line_number) in enumerate(code_blocks):
483
+ location = f"{location_prefix}.code_blocks[{idx}]"
484
+ self._run_qc_checks(code, language, artifact_result, location, line_number)