spec-kitty-cli 0.12.1__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.
Files changed (242) hide show
  1. spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
  2. spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
  3. spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
  4. spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
  5. spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
  6. specify_cli/__init__.py +171 -0
  7. specify_cli/acceptance.py +627 -0
  8. specify_cli/agent_utils/README.md +157 -0
  9. specify_cli/agent_utils/__init__.py +9 -0
  10. specify_cli/agent_utils/status.py +356 -0
  11. specify_cli/cli/__init__.py +6 -0
  12. specify_cli/cli/commands/__init__.py +46 -0
  13. specify_cli/cli/commands/accept.py +189 -0
  14. specify_cli/cli/commands/agent/__init__.py +22 -0
  15. specify_cli/cli/commands/agent/config.py +382 -0
  16. specify_cli/cli/commands/agent/context.py +191 -0
  17. specify_cli/cli/commands/agent/feature.py +1057 -0
  18. specify_cli/cli/commands/agent/release.py +11 -0
  19. specify_cli/cli/commands/agent/tasks.py +1253 -0
  20. specify_cli/cli/commands/agent/workflow.py +801 -0
  21. specify_cli/cli/commands/context.py +246 -0
  22. specify_cli/cli/commands/dashboard.py +85 -0
  23. specify_cli/cli/commands/implement.py +973 -0
  24. specify_cli/cli/commands/init.py +827 -0
  25. specify_cli/cli/commands/init_help.py +62 -0
  26. specify_cli/cli/commands/merge.py +755 -0
  27. specify_cli/cli/commands/mission.py +240 -0
  28. specify_cli/cli/commands/ops.py +265 -0
  29. specify_cli/cli/commands/orchestrate.py +640 -0
  30. specify_cli/cli/commands/repair.py +175 -0
  31. specify_cli/cli/commands/research.py +165 -0
  32. specify_cli/cli/commands/sync.py +364 -0
  33. specify_cli/cli/commands/upgrade.py +249 -0
  34. specify_cli/cli/commands/validate_encoding.py +186 -0
  35. specify_cli/cli/commands/validate_tasks.py +186 -0
  36. specify_cli/cli/commands/verify.py +310 -0
  37. specify_cli/cli/helpers.py +123 -0
  38. specify_cli/cli/step_tracker.py +91 -0
  39. specify_cli/cli/ui.py +192 -0
  40. specify_cli/core/__init__.py +53 -0
  41. specify_cli/core/agent_context.py +311 -0
  42. specify_cli/core/config.py +96 -0
  43. specify_cli/core/context_validation.py +362 -0
  44. specify_cli/core/dependency_graph.py +351 -0
  45. specify_cli/core/git_ops.py +129 -0
  46. specify_cli/core/multi_parent_merge.py +323 -0
  47. specify_cli/core/paths.py +260 -0
  48. specify_cli/core/project_resolver.py +110 -0
  49. specify_cli/core/stale_detection.py +263 -0
  50. specify_cli/core/tool_checker.py +79 -0
  51. specify_cli/core/utils.py +43 -0
  52. specify_cli/core/vcs/__init__.py +114 -0
  53. specify_cli/core/vcs/detection.py +341 -0
  54. specify_cli/core/vcs/exceptions.py +85 -0
  55. specify_cli/core/vcs/git.py +1304 -0
  56. specify_cli/core/vcs/jujutsu.py +1208 -0
  57. specify_cli/core/vcs/protocol.py +285 -0
  58. specify_cli/core/vcs/types.py +249 -0
  59. specify_cli/core/version_checker.py +261 -0
  60. specify_cli/core/worktree.py +506 -0
  61. specify_cli/dashboard/__init__.py +28 -0
  62. specify_cli/dashboard/diagnostics.py +204 -0
  63. specify_cli/dashboard/handlers/__init__.py +17 -0
  64. specify_cli/dashboard/handlers/api.py +143 -0
  65. specify_cli/dashboard/handlers/base.py +65 -0
  66. specify_cli/dashboard/handlers/features.py +390 -0
  67. specify_cli/dashboard/handlers/router.py +81 -0
  68. specify_cli/dashboard/handlers/static.py +50 -0
  69. specify_cli/dashboard/lifecycle.py +541 -0
  70. specify_cli/dashboard/scanner.py +437 -0
  71. specify_cli/dashboard/server.py +123 -0
  72. specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
  73. specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
  74. specify_cli/dashboard/static/spec-kitty.png +0 -0
  75. specify_cli/dashboard/templates/__init__.py +36 -0
  76. specify_cli/dashboard/templates/index.html +258 -0
  77. specify_cli/doc_generators.py +621 -0
  78. specify_cli/doc_state.py +408 -0
  79. specify_cli/frontmatter.py +384 -0
  80. specify_cli/gap_analysis.py +915 -0
  81. specify_cli/gitignore_manager.py +300 -0
  82. specify_cli/guards.py +145 -0
  83. specify_cli/legacy_detector.py +83 -0
  84. specify_cli/manifest.py +286 -0
  85. specify_cli/merge/__init__.py +63 -0
  86. specify_cli/merge/executor.py +653 -0
  87. specify_cli/merge/forecast.py +215 -0
  88. specify_cli/merge/ordering.py +126 -0
  89. specify_cli/merge/preflight.py +230 -0
  90. specify_cli/merge/state.py +185 -0
  91. specify_cli/merge/status_resolver.py +354 -0
  92. specify_cli/mission.py +654 -0
  93. specify_cli/missions/documentation/command-templates/implement.md +309 -0
  94. specify_cli/missions/documentation/command-templates/plan.md +275 -0
  95. specify_cli/missions/documentation/command-templates/review.md +344 -0
  96. specify_cli/missions/documentation/command-templates/specify.md +206 -0
  97. specify_cli/missions/documentation/command-templates/tasks.md +189 -0
  98. specify_cli/missions/documentation/mission.yaml +113 -0
  99. specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
  100. specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
  101. specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
  102. specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
  103. specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
  104. specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
  105. specify_cli/missions/documentation/templates/plan-template.md +269 -0
  106. specify_cli/missions/documentation/templates/release-template.md +222 -0
  107. specify_cli/missions/documentation/templates/spec-template.md +172 -0
  108. specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
  109. specify_cli/missions/documentation/templates/tasks-template.md +159 -0
  110. specify_cli/missions/research/command-templates/merge.md +388 -0
  111. specify_cli/missions/research/command-templates/plan.md +125 -0
  112. specify_cli/missions/research/command-templates/review.md +144 -0
  113. specify_cli/missions/research/command-templates/tasks.md +225 -0
  114. specify_cli/missions/research/mission.yaml +115 -0
  115. specify_cli/missions/research/templates/data-model-template.md +33 -0
  116. specify_cli/missions/research/templates/plan-template.md +161 -0
  117. specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
  118. specify_cli/missions/research/templates/research/source-register.csv +18 -0
  119. specify_cli/missions/research/templates/research-template.md +35 -0
  120. specify_cli/missions/research/templates/spec-template.md +64 -0
  121. specify_cli/missions/research/templates/task-prompt-template.md +148 -0
  122. specify_cli/missions/research/templates/tasks-template.md +114 -0
  123. specify_cli/missions/software-dev/command-templates/accept.md +75 -0
  124. specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
  125. specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
  126. specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
  127. specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
  128. specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
  129. specify_cli/missions/software-dev/command-templates/implement.md +41 -0
  130. specify_cli/missions/software-dev/command-templates/merge.md +383 -0
  131. specify_cli/missions/software-dev/command-templates/plan.md +171 -0
  132. specify_cli/missions/software-dev/command-templates/review.md +32 -0
  133. specify_cli/missions/software-dev/command-templates/specify.md +321 -0
  134. specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
  135. specify_cli/missions/software-dev/mission.yaml +100 -0
  136. specify_cli/missions/software-dev/templates/plan-template.md +132 -0
  137. specify_cli/missions/software-dev/templates/spec-template.md +116 -0
  138. specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
  139. specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
  140. specify_cli/orchestrator/__init__.py +75 -0
  141. specify_cli/orchestrator/agent_config.py +224 -0
  142. specify_cli/orchestrator/agents/__init__.py +170 -0
  143. specify_cli/orchestrator/agents/augment.py +112 -0
  144. specify_cli/orchestrator/agents/base.py +243 -0
  145. specify_cli/orchestrator/agents/claude.py +112 -0
  146. specify_cli/orchestrator/agents/codex.py +106 -0
  147. specify_cli/orchestrator/agents/copilot.py +137 -0
  148. specify_cli/orchestrator/agents/cursor.py +139 -0
  149. specify_cli/orchestrator/agents/gemini.py +115 -0
  150. specify_cli/orchestrator/agents/kilocode.py +94 -0
  151. specify_cli/orchestrator/agents/opencode.py +132 -0
  152. specify_cli/orchestrator/agents/qwen.py +96 -0
  153. specify_cli/orchestrator/config.py +455 -0
  154. specify_cli/orchestrator/executor.py +642 -0
  155. specify_cli/orchestrator/integration.py +1230 -0
  156. specify_cli/orchestrator/monitor.py +898 -0
  157. specify_cli/orchestrator/scheduler.py +832 -0
  158. specify_cli/orchestrator/state.py +508 -0
  159. specify_cli/orchestrator/testing/__init__.py +122 -0
  160. specify_cli/orchestrator/testing/availability.py +346 -0
  161. specify_cli/orchestrator/testing/fixtures.py +684 -0
  162. specify_cli/orchestrator/testing/paths.py +218 -0
  163. specify_cli/plan_validation.py +107 -0
  164. specify_cli/scripts/debug-dashboard-scan.py +61 -0
  165. specify_cli/scripts/tasks/acceptance_support.py +695 -0
  166. specify_cli/scripts/tasks/task_helpers.py +506 -0
  167. specify_cli/scripts/tasks/tasks_cli.py +848 -0
  168. specify_cli/scripts/validate_encoding.py +180 -0
  169. specify_cli/task_metadata_validation.py +274 -0
  170. specify_cli/tasks_support.py +447 -0
  171. specify_cli/template/__init__.py +47 -0
  172. specify_cli/template/asset_generator.py +206 -0
  173. specify_cli/template/github_client.py +334 -0
  174. specify_cli/template/manager.py +193 -0
  175. specify_cli/template/renderer.py +99 -0
  176. specify_cli/templates/AGENTS.md +190 -0
  177. specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
  178. specify_cli/templates/agent-file-template.md +35 -0
  179. specify_cli/templates/checklist-template.md +42 -0
  180. specify_cli/templates/claudeignore-template +58 -0
  181. specify_cli/templates/command-templates/accept.md +141 -0
  182. specify_cli/templates/command-templates/analyze.md +253 -0
  183. specify_cli/templates/command-templates/checklist.md +352 -0
  184. specify_cli/templates/command-templates/clarify.md +224 -0
  185. specify_cli/templates/command-templates/constitution.md +432 -0
  186. specify_cli/templates/command-templates/dashboard.md +175 -0
  187. specify_cli/templates/command-templates/implement.md +190 -0
  188. specify_cli/templates/command-templates/merge.md +374 -0
  189. specify_cli/templates/command-templates/plan.md +171 -0
  190. specify_cli/templates/command-templates/research.md +88 -0
  191. specify_cli/templates/command-templates/review.md +510 -0
  192. specify_cli/templates/command-templates/specify.md +321 -0
  193. specify_cli/templates/command-templates/status.md +92 -0
  194. specify_cli/templates/command-templates/tasks.md +199 -0
  195. specify_cli/templates/git-hooks/pre-commit +22 -0
  196. specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
  197. specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
  198. specify_cli/templates/plan-template.md +108 -0
  199. specify_cli/templates/spec-template.md +118 -0
  200. specify_cli/templates/task-prompt-template.md +165 -0
  201. specify_cli/templates/tasks-template.md +161 -0
  202. specify_cli/templates/vscode-settings.json +13 -0
  203. specify_cli/text_sanitization.py +225 -0
  204. specify_cli/upgrade/__init__.py +18 -0
  205. specify_cli/upgrade/detector.py +239 -0
  206. specify_cli/upgrade/metadata.py +182 -0
  207. specify_cli/upgrade/migrations/__init__.py +65 -0
  208. specify_cli/upgrade/migrations/base.py +80 -0
  209. specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
  210. specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
  211. specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
  212. specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
  213. specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
  214. specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
  215. specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
  216. specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
  217. specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
  218. specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
  219. specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
  220. specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
  221. specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
  222. specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
  223. specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
  224. specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
  225. specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
  226. specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
  227. specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
  228. specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
  229. specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
  230. specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
  231. specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
  232. specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
  233. specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
  234. specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
  235. specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
  236. specify_cli/upgrade/registry.py +121 -0
  237. specify_cli/upgrade/runner.py +284 -0
  238. specify_cli/validators/__init__.py +14 -0
  239. specify_cli/validators/paths.py +154 -0
  240. specify_cli/validators/research.py +428 -0
  241. specify_cli/verify_enhanced.py +270 -0
  242. specify_cli/workspace_context.py +224 -0
@@ -0,0 +1,154 @@
1
+ """Path convention validation helpers for Spec Kitty missions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Dict, Iterable, List
8
+
9
+ from specify_cli.mission import Mission
10
+
11
+ __all__ = [
12
+ "PathValidationError",
13
+ "PathValidationResult",
14
+ "suggest_directory_creation",
15
+ "validate_mission_paths",
16
+ ]
17
+
18
+
19
+ class PathValidationError(Exception):
20
+ """Raised when required mission paths are missing in strict mode."""
21
+
22
+ def __init__(self, result: "PathValidationResult") -> None:
23
+ self.result = result
24
+ message = result.format_errors() or "Path convention validation failed."
25
+ super().__init__(message)
26
+
27
+
28
+ @dataclass
29
+ class PathValidationResult:
30
+ """Result of validating mission-declared paths against the workspace."""
31
+
32
+ mission_name: str
33
+ required_paths: Dict[str, str]
34
+ existing_paths: List[str] = field(default_factory=list)
35
+ missing_paths: List[str] = field(default_factory=list)
36
+ warnings: List[str] = field(default_factory=list)
37
+ suggestions: List[str] = field(default_factory=list)
38
+
39
+ @property
40
+ def is_valid(self) -> bool:
41
+ """True when every required path exists."""
42
+ return not self.missing_paths
43
+
44
+ def format_warnings(self) -> str:
45
+ """Return human-readable warning text."""
46
+ if not self.warnings:
47
+ return ""
48
+
49
+ lines = ["Path Convention Warnings:"]
50
+ for warning in self.warnings:
51
+ lines.append(f" - {warning}")
52
+
53
+ if self.suggestions:
54
+ lines.append("")
55
+ lines.append("Suggestions:")
56
+ for suggestion in self.suggestions:
57
+ lines.append(f" - {suggestion}")
58
+
59
+ return "\n".join(lines)
60
+
61
+ def format_errors(self) -> str:
62
+ """Return human-readable error text for strict enforcement."""
63
+ if self.is_valid:
64
+ return ""
65
+
66
+ lines = ["Path Convention Errors:"]
67
+ for warning in self.warnings:
68
+ lines.append(f" - {warning}")
69
+
70
+ if self.suggestions:
71
+ lines.append("")
72
+ lines.append("Required Actions:")
73
+ for suggestion in self.suggestions:
74
+ lines.append(f" - {suggestion}")
75
+
76
+ lines.append("")
77
+ lines.append(
78
+ "These directories are required by the active mission. "
79
+ "Create them before continuing."
80
+ )
81
+ return "\n".join(lines)
82
+
83
+
84
+ def suggest_directory_creation(missing_paths: Iterable[str]) -> List[str]:
85
+ """Generate shell-friendly suggestions for fixing missing paths."""
86
+
87
+ missing = list(missing_paths)
88
+ suggestions: List[str] = []
89
+
90
+ for path_str in missing:
91
+ path = Path(path_str)
92
+ if path_str.endswith("/"):
93
+ suggestions.append(f"mkdir -p {path_str}")
94
+ elif "." in path.name:
95
+ parent = path.parent
96
+ if parent and str(parent) not in {"", "."}:
97
+ suggestions.append(f"mkdir -p {parent} && touch {path_str}")
98
+ else:
99
+ suggestions.append(f"touch {path_str}")
100
+ else:
101
+ suggestions.append(f"mkdir -p {path_str}")
102
+
103
+ dir_paths = [p for p in missing if p.endswith("/")]
104
+ if len(dir_paths) > 1:
105
+ joined = " ".join(dir_paths)
106
+ suggestions.insert(0, f"Create directories in one go: mkdir -p {joined}")
107
+
108
+ return suggestions
109
+
110
+
111
+ def validate_mission_paths(
112
+ mission: Mission,
113
+ project_root: Path,
114
+ *,
115
+ strict: bool = False,
116
+ ) -> PathValidationResult:
117
+ """Validate that project directories follow mission-defined conventions.
118
+
119
+ Args:
120
+ mission: Mission containing declared path conventions.
121
+ project_root: Root of the active workspace/worktree.
122
+ strict: When True, raise PathValidationError if paths are missing.
123
+
124
+ Returns:
125
+ PathValidationResult summarising the state of each required path.
126
+ """
127
+
128
+ required_paths = dict(mission.config.paths or {})
129
+ result = PathValidationResult(
130
+ mission_name=mission.name,
131
+ required_paths=required_paths,
132
+ )
133
+
134
+ if not required_paths:
135
+ return result
136
+
137
+ for key, relative_path in required_paths.items():
138
+ candidate = Path(relative_path)
139
+ full_path = candidate if candidate.is_absolute() else project_root / candidate
140
+ if full_path.exists():
141
+ result.existing_paths.append(relative_path)
142
+ continue
143
+
144
+ result.missing_paths.append(relative_path)
145
+ result.warnings.append(
146
+ f"{mission.name} expects {key} path: {relative_path} (not found)"
147
+ )
148
+
149
+ if result.missing_paths:
150
+ result.suggestions = suggest_directory_creation(result.missing_paths)
151
+ if strict:
152
+ raise PathValidationError(result)
153
+
154
+ return result
@@ -0,0 +1,428 @@
1
+ """Citation and bibliography validation for the research mission.
2
+
3
+ This module keeps the research CSV artifacts (evidence log + source
4
+ register) healthy by catching missing citations, invalid enumerations,
5
+ and malformed entries. Validation follows a progressive approach:
6
+
7
+ * Level 1 (errors) – Completeness issues that block the workflow.
8
+ * Level 2 (warnings) – Citation formatting issues (BibTeX / APA / Simple).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import re
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Iterable, List, Literal
19
+
20
+ BIBTEX_PATTERN = r"@\w+\{[\w-]+,"
21
+ APA_PATTERN = r"^[\w\s\.,&]+?,\s?.+\(\d{4}\)\."
22
+ SIMPLE_PATTERN = r"^.+\(\d{4}\)\..+\."
23
+
24
+ VALID_SOURCE_TYPES = ["journal", "conference", "book", "web", "preprint"]
25
+ VALID_CONFIDENCE_LEVELS = ["high", "medium", "low"]
26
+ VALID_RELEVANCE_LEVELS = ["high", "medium", "low"]
27
+ VALID_SOURCE_STATUS = ["reviewed", "pending", "archived"]
28
+
29
+ EVIDENCE_REQUIRED_COLUMNS = [
30
+ "timestamp",
31
+ "source_type",
32
+ "citation",
33
+ "key_finding",
34
+ "confidence",
35
+ "notes",
36
+ ]
37
+
38
+ SOURCE_REGISTER_REQUIRED_COLUMNS = [
39
+ "source_id",
40
+ "citation",
41
+ "url",
42
+ "accessed_date",
43
+ "relevance",
44
+ "status",
45
+ ]
46
+
47
+
48
+ class ResearchValidationError(Exception):
49
+ """Raised when research validation fails unexpectedly."""
50
+
51
+
52
+ class CitationFormat(str, Enum):
53
+ """Supported citation formats."""
54
+
55
+ BIBTEX = "bibtex"
56
+ APA = "apa"
57
+ SIMPLE = "simple"
58
+ UNKNOWN = "unknown"
59
+
60
+
61
+ @dataclass
62
+ class CitationIssue:
63
+ """Single citation validation issue."""
64
+
65
+ line_number: int
66
+ field: str
67
+ issue_type: Literal["error", "warning"]
68
+ message: str
69
+
70
+
71
+ @dataclass
72
+ class CitationValidationResult:
73
+ """Result of citation validation."""
74
+
75
+ file_path: Path
76
+ total_entries: int
77
+ valid_entries: int
78
+ issues: List[CitationIssue]
79
+
80
+ @property
81
+ def has_errors(self) -> bool:
82
+ """Return True if any issues are errors (blocking)."""
83
+
84
+ return any(issue.issue_type == "error" for issue in self.issues)
85
+
86
+ @property
87
+ def error_count(self) -> int:
88
+ return sum(1 for issue in self.issues if issue.issue_type == "error")
89
+
90
+ @property
91
+ def warning_count(self) -> int:
92
+ return sum(1 for issue in self.issues if issue.issue_type == "warning")
93
+
94
+ def format_report(self) -> str:
95
+ """Format issues in a reviewer-friendly string."""
96
+
97
+ output = [
98
+ f"Citation Validation: {self.file_path.name}",
99
+ f"Total entries: {self.total_entries}",
100
+ f"Valid: {self.valid_entries}",
101
+ f"Errors: {self.error_count}",
102
+ f"Warnings: {self.warning_count}",
103
+ "",
104
+ ]
105
+
106
+ if self.issues:
107
+ errors = [i for i in self.issues if i.issue_type == "error"]
108
+ warnings = [i for i in self.issues if i.issue_type == "warning"]
109
+
110
+ if errors:
111
+ output.append("ERRORS (must fix):")
112
+ for issue in errors:
113
+ output.append(f" Line {issue.line_number} ({issue.field}): {issue.message}")
114
+ output.append("")
115
+
116
+ if warnings:
117
+ output.append("WARNINGS (recommended fixes):")
118
+ for issue in warnings:
119
+ output.append(f" Line {issue.line_number} ({issue.field}): {issue.message}")
120
+
121
+ return "\n".join(output)
122
+
123
+
124
+ def _missing_columns(fieldnames: Iterable[str] | None, required: list[str]) -> list[str]:
125
+ if not fieldnames:
126
+ return required.copy()
127
+ return [col for col in required if col not in fieldnames]
128
+
129
+
130
+ def is_bibtex_format(citation: str) -> bool:
131
+ """Return True when the citation appears to use BibTeX syntax."""
132
+
133
+ return bool(re.match(BIBTEX_PATTERN, citation.strip()))
134
+
135
+
136
+ def is_apa_format(citation: str) -> bool:
137
+ """Return True when the citation appears to use APA style."""
138
+
139
+ return bool(re.match(APA_PATTERN, citation.strip()))
140
+
141
+
142
+ def is_simple_format(citation: str) -> bool:
143
+ """Return True when the citation matches the simplified fallback format."""
144
+
145
+ return bool(re.match(SIMPLE_PATTERN, citation.strip()))
146
+
147
+
148
+ def detect_citation_format(citation: str) -> CitationFormat:
149
+ """Detect the most likely citation format for the given string."""
150
+
151
+ if is_bibtex_format(citation):
152
+ return CitationFormat.BIBTEX
153
+ if is_apa_format(citation):
154
+ return CitationFormat.APA
155
+ if is_simple_format(citation):
156
+ return CitationFormat.SIMPLE
157
+ return CitationFormat.UNKNOWN
158
+
159
+
160
+ def _missing_file_result(path: Path, kind: str) -> CitationValidationResult:
161
+ return CitationValidationResult(
162
+ file_path=path,
163
+ total_entries=0,
164
+ valid_entries=0,
165
+ issues=[
166
+ CitationIssue(
167
+ line_number=0,
168
+ field="file",
169
+ issue_type="error",
170
+ message=f"{kind} not found: {path}",
171
+ )
172
+ ],
173
+ )
174
+
175
+
176
+ def validate_citations(evidence_log_path: Path) -> CitationValidationResult:
177
+ """Validate research/evidence-log.csv."""
178
+
179
+ if not evidence_log_path.exists():
180
+ return _missing_file_result(evidence_log_path, "Evidence log")
181
+
182
+ issues: list[CitationIssue] = []
183
+ total = 0
184
+ valid = 0
185
+
186
+ try:
187
+ with evidence_log_path.open("r", encoding="utf-8-sig") as handle:
188
+ reader = csv.DictReader(handle)
189
+ missing_columns = _missing_columns(reader.fieldnames, EVIDENCE_REQUIRED_COLUMNS)
190
+ if missing_columns:
191
+ issues.append(
192
+ CitationIssue(
193
+ line_number=1,
194
+ field="headers",
195
+ issue_type="error",
196
+ message=f"Missing required columns: {', '.join(missing_columns)}",
197
+ )
198
+ )
199
+ return CitationValidationResult(evidence_log_path, 0, 0, issues)
200
+
201
+ for line_number, row in enumerate(reader, start=2):
202
+ total += 1
203
+ entry_valid = True
204
+
205
+ citation = (row.get("citation") or "").strip()
206
+ source_type = (row.get("source_type") or "").strip()
207
+ confidence = (row.get("confidence") or "").strip()
208
+ key_finding = (row.get("key_finding") or "").strip()
209
+
210
+ if not citation:
211
+ issues.append(
212
+ CitationIssue(
213
+ line_number=line_number,
214
+ field="citation",
215
+ issue_type="error",
216
+ message="Citation is empty",
217
+ )
218
+ )
219
+ entry_valid = False
220
+
221
+ if source_type not in VALID_SOURCE_TYPES:
222
+ issues.append(
223
+ CitationIssue(
224
+ line_number=line_number,
225
+ field="source_type",
226
+ issue_type="error",
227
+ message=(
228
+ f"Invalid source_type '{source_type}'. "
229
+ f"Must be one of: {', '.join(VALID_SOURCE_TYPES)}"
230
+ ),
231
+ )
232
+ )
233
+ entry_valid = False
234
+
235
+ if confidence and confidence not in VALID_CONFIDENCE_LEVELS:
236
+ issues.append(
237
+ CitationIssue(
238
+ line_number=line_number,
239
+ field="confidence",
240
+ issue_type="error",
241
+ message=(
242
+ f"Invalid confidence '{confidence}'. "
243
+ f"Must be one of: {', '.join(VALID_CONFIDENCE_LEVELS)}"
244
+ ),
245
+ )
246
+ )
247
+ entry_valid = False
248
+
249
+ if not key_finding:
250
+ issues.append(
251
+ CitationIssue(
252
+ line_number=line_number,
253
+ field="key_finding",
254
+ issue_type="warning",
255
+ message="Key finding is empty – document the main takeaway for traceability",
256
+ )
257
+ )
258
+
259
+ if citation:
260
+ fmt = detect_citation_format(citation)
261
+ if fmt is CitationFormat.UNKNOWN:
262
+ issues.append(
263
+ CitationIssue(
264
+ line_number=line_number,
265
+ field="citation",
266
+ issue_type="warning",
267
+ message="Citation format not recognized. Prefer BibTeX or APA for consistency.",
268
+ )
269
+ )
270
+
271
+ if entry_valid:
272
+ valid += 1
273
+
274
+ except csv.Error as exc:
275
+ issues.append(
276
+ CitationIssue(
277
+ line_number=0,
278
+ field="file",
279
+ issue_type="error",
280
+ message=f"CSV parsing error: {exc}",
281
+ )
282
+ )
283
+
284
+ return CitationValidationResult(evidence_log_path, total, valid, issues)
285
+
286
+
287
+ def validate_source_register(source_register_path: Path) -> CitationValidationResult:
288
+ """Validate research/source-register.csv."""
289
+
290
+ if not source_register_path.exists():
291
+ return _missing_file_result(source_register_path, "Source register")
292
+
293
+ issues: list[CitationIssue] = []
294
+ total = 0
295
+ valid = 0
296
+ seen_ids: set[str] = set()
297
+
298
+ try:
299
+ with source_register_path.open("r", encoding="utf-8-sig") as handle:
300
+ reader = csv.DictReader(handle)
301
+ missing_columns = _missing_columns(reader.fieldnames, SOURCE_REGISTER_REQUIRED_COLUMNS)
302
+ if missing_columns:
303
+ issues.append(
304
+ CitationIssue(
305
+ line_number=1,
306
+ field="headers",
307
+ issue_type="error",
308
+ message=f"Missing required columns: {', '.join(missing_columns)}",
309
+ )
310
+ )
311
+ return CitationValidationResult(source_register_path, 0, 0, issues)
312
+
313
+ for line_number, row in enumerate(reader, start=2):
314
+ total += 1
315
+ entry_valid = True
316
+
317
+ source_id = (row.get("source_id") or "").strip()
318
+ citation = (row.get("citation") or "").strip()
319
+ relevance = (row.get("relevance") or "").strip()
320
+ status = (row.get("status") or "").strip()
321
+
322
+ if not source_id:
323
+ issues.append(
324
+ CitationIssue(
325
+ line_number=line_number,
326
+ field="source_id",
327
+ issue_type="error",
328
+ message="source_id is empty",
329
+ )
330
+ )
331
+ entry_valid = False
332
+ elif source_id in seen_ids:
333
+ issues.append(
334
+ CitationIssue(
335
+ line_number=line_number,
336
+ field="source_id",
337
+ issue_type="error",
338
+ message=f"Duplicate source_id '{source_id}' (must be unique)",
339
+ )
340
+ )
341
+ entry_valid = False
342
+ else:
343
+ seen_ids.add(source_id)
344
+
345
+ if not citation:
346
+ issues.append(
347
+ CitationIssue(
348
+ line_number=line_number,
349
+ field="citation",
350
+ issue_type="error",
351
+ message="Citation is empty",
352
+ )
353
+ )
354
+ entry_valid = False
355
+ else:
356
+ fmt = detect_citation_format(citation)
357
+ if fmt is CitationFormat.UNKNOWN:
358
+ issues.append(
359
+ CitationIssue(
360
+ line_number=line_number,
361
+ field="citation",
362
+ issue_type="warning",
363
+ message="Citation format not recognized. Prefer BibTeX or APA for consistency.",
364
+ )
365
+ )
366
+
367
+ if relevance and relevance not in VALID_RELEVANCE_LEVELS:
368
+ issues.append(
369
+ CitationIssue(
370
+ line_number=line_number,
371
+ field="relevance",
372
+ issue_type="error",
373
+ message=(
374
+ f"Invalid relevance '{relevance}'. "
375
+ f"Must be: {', '.join(VALID_RELEVANCE_LEVELS)}"
376
+ ),
377
+ )
378
+ )
379
+ entry_valid = False
380
+
381
+ if status and status not in VALID_SOURCE_STATUS:
382
+ issues.append(
383
+ CitationIssue(
384
+ line_number=line_number,
385
+ field="status",
386
+ issue_type="error",
387
+ message=(
388
+ f"Invalid status '{status}'. Must be: {', '.join(VALID_SOURCE_STATUS)}"
389
+ ),
390
+ )
391
+ )
392
+ entry_valid = False
393
+
394
+ if entry_valid:
395
+ valid += 1
396
+
397
+ except csv.Error as exc:
398
+ issues.append(
399
+ CitationIssue(
400
+ line_number=0,
401
+ field="file",
402
+ issue_type="error",
403
+ message=f"CSV parsing error: {exc}",
404
+ )
405
+ )
406
+
407
+ return CitationValidationResult(source_register_path, total, valid, issues)
408
+
409
+
410
+ __all__ = [
411
+ "APA_PATTERN",
412
+ "BIBTEX_PATTERN",
413
+ "CitationFormat",
414
+ "CitationIssue",
415
+ "CitationValidationResult",
416
+ "ResearchValidationError",
417
+ "SIMPLE_PATTERN",
418
+ "VALID_CONFIDENCE_LEVELS",
419
+ "VALID_RELEVANCE_LEVELS",
420
+ "VALID_SOURCE_STATUS",
421
+ "VALID_SOURCE_TYPES",
422
+ "detect_citation_format",
423
+ "is_apa_format",
424
+ "is_bibtex_format",
425
+ "is_simple_format",
426
+ "validate_citations",
427
+ "validate_source_register",
428
+ ]