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.
- spec_kitty_cli-0.12.1.dist-info/METADATA +1767 -0
- spec_kitty_cli-0.12.1.dist-info/RECORD +242 -0
- spec_kitty_cli-0.12.1.dist-info/WHEEL +4 -0
- spec_kitty_cli-0.12.1.dist-info/entry_points.txt +2 -0
- spec_kitty_cli-0.12.1.dist-info/licenses/LICENSE +21 -0
- specify_cli/__init__.py +171 -0
- specify_cli/acceptance.py +627 -0
- specify_cli/agent_utils/README.md +157 -0
- specify_cli/agent_utils/__init__.py +9 -0
- specify_cli/agent_utils/status.py +356 -0
- specify_cli/cli/__init__.py +6 -0
- specify_cli/cli/commands/__init__.py +46 -0
- specify_cli/cli/commands/accept.py +189 -0
- specify_cli/cli/commands/agent/__init__.py +22 -0
- specify_cli/cli/commands/agent/config.py +382 -0
- specify_cli/cli/commands/agent/context.py +191 -0
- specify_cli/cli/commands/agent/feature.py +1057 -0
- specify_cli/cli/commands/agent/release.py +11 -0
- specify_cli/cli/commands/agent/tasks.py +1253 -0
- specify_cli/cli/commands/agent/workflow.py +801 -0
- specify_cli/cli/commands/context.py +246 -0
- specify_cli/cli/commands/dashboard.py +85 -0
- specify_cli/cli/commands/implement.py +973 -0
- specify_cli/cli/commands/init.py +827 -0
- specify_cli/cli/commands/init_help.py +62 -0
- specify_cli/cli/commands/merge.py +755 -0
- specify_cli/cli/commands/mission.py +240 -0
- specify_cli/cli/commands/ops.py +265 -0
- specify_cli/cli/commands/orchestrate.py +640 -0
- specify_cli/cli/commands/repair.py +175 -0
- specify_cli/cli/commands/research.py +165 -0
- specify_cli/cli/commands/sync.py +364 -0
- specify_cli/cli/commands/upgrade.py +249 -0
- specify_cli/cli/commands/validate_encoding.py +186 -0
- specify_cli/cli/commands/validate_tasks.py +186 -0
- specify_cli/cli/commands/verify.py +310 -0
- specify_cli/cli/helpers.py +123 -0
- specify_cli/cli/step_tracker.py +91 -0
- specify_cli/cli/ui.py +192 -0
- specify_cli/core/__init__.py +53 -0
- specify_cli/core/agent_context.py +311 -0
- specify_cli/core/config.py +96 -0
- specify_cli/core/context_validation.py +362 -0
- specify_cli/core/dependency_graph.py +351 -0
- specify_cli/core/git_ops.py +129 -0
- specify_cli/core/multi_parent_merge.py +323 -0
- specify_cli/core/paths.py +260 -0
- specify_cli/core/project_resolver.py +110 -0
- specify_cli/core/stale_detection.py +263 -0
- specify_cli/core/tool_checker.py +79 -0
- specify_cli/core/utils.py +43 -0
- specify_cli/core/vcs/__init__.py +114 -0
- specify_cli/core/vcs/detection.py +341 -0
- specify_cli/core/vcs/exceptions.py +85 -0
- specify_cli/core/vcs/git.py +1304 -0
- specify_cli/core/vcs/jujutsu.py +1208 -0
- specify_cli/core/vcs/protocol.py +285 -0
- specify_cli/core/vcs/types.py +249 -0
- specify_cli/core/version_checker.py +261 -0
- specify_cli/core/worktree.py +506 -0
- specify_cli/dashboard/__init__.py +28 -0
- specify_cli/dashboard/diagnostics.py +204 -0
- specify_cli/dashboard/handlers/__init__.py +17 -0
- specify_cli/dashboard/handlers/api.py +143 -0
- specify_cli/dashboard/handlers/base.py +65 -0
- specify_cli/dashboard/handlers/features.py +390 -0
- specify_cli/dashboard/handlers/router.py +81 -0
- specify_cli/dashboard/handlers/static.py +50 -0
- specify_cli/dashboard/lifecycle.py +541 -0
- specify_cli/dashboard/scanner.py +437 -0
- specify_cli/dashboard/server.py +123 -0
- specify_cli/dashboard/static/dashboard/dashboard.css +722 -0
- specify_cli/dashboard/static/dashboard/dashboard.js +1424 -0
- specify_cli/dashboard/static/spec-kitty.png +0 -0
- specify_cli/dashboard/templates/__init__.py +36 -0
- specify_cli/dashboard/templates/index.html +258 -0
- specify_cli/doc_generators.py +621 -0
- specify_cli/doc_state.py +408 -0
- specify_cli/frontmatter.py +384 -0
- specify_cli/gap_analysis.py +915 -0
- specify_cli/gitignore_manager.py +300 -0
- specify_cli/guards.py +145 -0
- specify_cli/legacy_detector.py +83 -0
- specify_cli/manifest.py +286 -0
- specify_cli/merge/__init__.py +63 -0
- specify_cli/merge/executor.py +653 -0
- specify_cli/merge/forecast.py +215 -0
- specify_cli/merge/ordering.py +126 -0
- specify_cli/merge/preflight.py +230 -0
- specify_cli/merge/state.py +185 -0
- specify_cli/merge/status_resolver.py +354 -0
- specify_cli/mission.py +654 -0
- specify_cli/missions/documentation/command-templates/implement.md +309 -0
- specify_cli/missions/documentation/command-templates/plan.md +275 -0
- specify_cli/missions/documentation/command-templates/review.md +344 -0
- specify_cli/missions/documentation/command-templates/specify.md +206 -0
- specify_cli/missions/documentation/command-templates/tasks.md +189 -0
- specify_cli/missions/documentation/mission.yaml +113 -0
- specify_cli/missions/documentation/templates/divio/explanation-template.md +192 -0
- specify_cli/missions/documentation/templates/divio/howto-template.md +168 -0
- specify_cli/missions/documentation/templates/divio/reference-template.md +179 -0
- specify_cli/missions/documentation/templates/divio/tutorial-template.md +146 -0
- specify_cli/missions/documentation/templates/generators/jsdoc.json.template +18 -0
- specify_cli/missions/documentation/templates/generators/sphinx-conf.py.template +36 -0
- specify_cli/missions/documentation/templates/plan-template.md +269 -0
- specify_cli/missions/documentation/templates/release-template.md +222 -0
- specify_cli/missions/documentation/templates/spec-template.md +172 -0
- specify_cli/missions/documentation/templates/task-prompt-template.md +140 -0
- specify_cli/missions/documentation/templates/tasks-template.md +159 -0
- specify_cli/missions/research/command-templates/merge.md +388 -0
- specify_cli/missions/research/command-templates/plan.md +125 -0
- specify_cli/missions/research/command-templates/review.md +144 -0
- specify_cli/missions/research/command-templates/tasks.md +225 -0
- specify_cli/missions/research/mission.yaml +115 -0
- specify_cli/missions/research/templates/data-model-template.md +33 -0
- specify_cli/missions/research/templates/plan-template.md +161 -0
- specify_cli/missions/research/templates/research/evidence-log.csv +18 -0
- specify_cli/missions/research/templates/research/source-register.csv +18 -0
- specify_cli/missions/research/templates/research-template.md +35 -0
- specify_cli/missions/research/templates/spec-template.md +64 -0
- specify_cli/missions/research/templates/task-prompt-template.md +148 -0
- specify_cli/missions/research/templates/tasks-template.md +114 -0
- specify_cli/missions/software-dev/command-templates/accept.md +75 -0
- specify_cli/missions/software-dev/command-templates/analyze.md +183 -0
- specify_cli/missions/software-dev/command-templates/checklist.md +286 -0
- specify_cli/missions/software-dev/command-templates/clarify.md +157 -0
- specify_cli/missions/software-dev/command-templates/constitution.md +432 -0
- specify_cli/missions/software-dev/command-templates/dashboard.md +101 -0
- specify_cli/missions/software-dev/command-templates/implement.md +41 -0
- specify_cli/missions/software-dev/command-templates/merge.md +383 -0
- specify_cli/missions/software-dev/command-templates/plan.md +171 -0
- specify_cli/missions/software-dev/command-templates/review.md +32 -0
- specify_cli/missions/software-dev/command-templates/specify.md +321 -0
- specify_cli/missions/software-dev/command-templates/tasks.md +566 -0
- specify_cli/missions/software-dev/mission.yaml +100 -0
- specify_cli/missions/software-dev/templates/plan-template.md +132 -0
- specify_cli/missions/software-dev/templates/spec-template.md +116 -0
- specify_cli/missions/software-dev/templates/task-prompt-template.md +140 -0
- specify_cli/missions/software-dev/templates/tasks-template.md +159 -0
- specify_cli/orchestrator/__init__.py +75 -0
- specify_cli/orchestrator/agent_config.py +224 -0
- specify_cli/orchestrator/agents/__init__.py +170 -0
- specify_cli/orchestrator/agents/augment.py +112 -0
- specify_cli/orchestrator/agents/base.py +243 -0
- specify_cli/orchestrator/agents/claude.py +112 -0
- specify_cli/orchestrator/agents/codex.py +106 -0
- specify_cli/orchestrator/agents/copilot.py +137 -0
- specify_cli/orchestrator/agents/cursor.py +139 -0
- specify_cli/orchestrator/agents/gemini.py +115 -0
- specify_cli/orchestrator/agents/kilocode.py +94 -0
- specify_cli/orchestrator/agents/opencode.py +132 -0
- specify_cli/orchestrator/agents/qwen.py +96 -0
- specify_cli/orchestrator/config.py +455 -0
- specify_cli/orchestrator/executor.py +642 -0
- specify_cli/orchestrator/integration.py +1230 -0
- specify_cli/orchestrator/monitor.py +898 -0
- specify_cli/orchestrator/scheduler.py +832 -0
- specify_cli/orchestrator/state.py +508 -0
- specify_cli/orchestrator/testing/__init__.py +122 -0
- specify_cli/orchestrator/testing/availability.py +346 -0
- specify_cli/orchestrator/testing/fixtures.py +684 -0
- specify_cli/orchestrator/testing/paths.py +218 -0
- specify_cli/plan_validation.py +107 -0
- specify_cli/scripts/debug-dashboard-scan.py +61 -0
- specify_cli/scripts/tasks/acceptance_support.py +695 -0
- specify_cli/scripts/tasks/task_helpers.py +506 -0
- specify_cli/scripts/tasks/tasks_cli.py +848 -0
- specify_cli/scripts/validate_encoding.py +180 -0
- specify_cli/task_metadata_validation.py +274 -0
- specify_cli/tasks_support.py +447 -0
- specify_cli/template/__init__.py +47 -0
- specify_cli/template/asset_generator.py +206 -0
- specify_cli/template/github_client.py +334 -0
- specify_cli/template/manager.py +193 -0
- specify_cli/template/renderer.py +99 -0
- specify_cli/templates/AGENTS.md +190 -0
- specify_cli/templates/POWERSHELL_SYNTAX.md +229 -0
- specify_cli/templates/agent-file-template.md +35 -0
- specify_cli/templates/checklist-template.md +42 -0
- specify_cli/templates/claudeignore-template +58 -0
- specify_cli/templates/command-templates/accept.md +141 -0
- specify_cli/templates/command-templates/analyze.md +253 -0
- specify_cli/templates/command-templates/checklist.md +352 -0
- specify_cli/templates/command-templates/clarify.md +224 -0
- specify_cli/templates/command-templates/constitution.md +432 -0
- specify_cli/templates/command-templates/dashboard.md +175 -0
- specify_cli/templates/command-templates/implement.md +190 -0
- specify_cli/templates/command-templates/merge.md +374 -0
- specify_cli/templates/command-templates/plan.md +171 -0
- specify_cli/templates/command-templates/research.md +88 -0
- specify_cli/templates/command-templates/review.md +510 -0
- specify_cli/templates/command-templates/specify.md +321 -0
- specify_cli/templates/command-templates/status.md +92 -0
- specify_cli/templates/command-templates/tasks.md +199 -0
- specify_cli/templates/git-hooks/pre-commit +22 -0
- specify_cli/templates/git-hooks/pre-commit-agent-check +37 -0
- specify_cli/templates/git-hooks/pre-commit-encoding-check +142 -0
- specify_cli/templates/plan-template.md +108 -0
- specify_cli/templates/spec-template.md +118 -0
- specify_cli/templates/task-prompt-template.md +165 -0
- specify_cli/templates/tasks-template.md +161 -0
- specify_cli/templates/vscode-settings.json +13 -0
- specify_cli/text_sanitization.py +225 -0
- specify_cli/upgrade/__init__.py +18 -0
- specify_cli/upgrade/detector.py +239 -0
- specify_cli/upgrade/metadata.py +182 -0
- specify_cli/upgrade/migrations/__init__.py +65 -0
- specify_cli/upgrade/migrations/base.py +80 -0
- specify_cli/upgrade/migrations/m_0_10_0_python_only.py +359 -0
- specify_cli/upgrade/migrations/m_0_10_12_constitution_cleanup.py +99 -0
- specify_cli/upgrade/migrations/m_0_10_14_update_implement_slash_command.py +176 -0
- specify_cli/upgrade/migrations/m_0_10_1_populate_slash_commands.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_2_update_slash_commands.py +172 -0
- specify_cli/upgrade/migrations/m_0_10_6_workflow_simplification.py +174 -0
- specify_cli/upgrade/migrations/m_0_10_8_fix_memory_structure.py +252 -0
- specify_cli/upgrade/migrations/m_0_10_9_repair_templates.py +168 -0
- specify_cli/upgrade/migrations/m_0_11_0_workspace_per_wp.py +182 -0
- specify_cli/upgrade/migrations/m_0_11_1_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_1_update_implement_slash_command.py +160 -0
- specify_cli/upgrade/migrations/m_0_11_2_improved_workflow_templates.py +173 -0
- specify_cli/upgrade/migrations/m_0_11_3_workflow_agent_flag.py +114 -0
- specify_cli/upgrade/migrations/m_0_12_0_documentation_mission.py +155 -0
- specify_cli/upgrade/migrations/m_0_12_1_remove_kitty_specs_from_gitignore.py +183 -0
- specify_cli/upgrade/migrations/m_0_2_0_specify_to_kittify.py +80 -0
- specify_cli/upgrade/migrations/m_0_4_8_gitignore_agents.py +118 -0
- specify_cli/upgrade/migrations/m_0_5_0_encoding_hooks.py +141 -0
- specify_cli/upgrade/migrations/m_0_6_5_commands_rename.py +169 -0
- specify_cli/upgrade/migrations/m_0_6_7_ensure_missions.py +228 -0
- specify_cli/upgrade/migrations/m_0_7_2_worktree_commands_dedup.py +89 -0
- specify_cli/upgrade/migrations/m_0_7_3_update_scripts.py +114 -0
- specify_cli/upgrade/migrations/m_0_8_0_remove_active_mission.py +82 -0
- specify_cli/upgrade/migrations/m_0_8_0_worktree_agents_symlink.py +148 -0
- specify_cli/upgrade/migrations/m_0_9_0_frontmatter_only_lanes.py +346 -0
- specify_cli/upgrade/migrations/m_0_9_1_complete_lane_migration.py +656 -0
- specify_cli/upgrade/migrations/m_0_9_2_research_mission_templates.py +221 -0
- specify_cli/upgrade/registry.py +121 -0
- specify_cli/upgrade/runner.py +284 -0
- specify_cli/validators/__init__.py +14 -0
- specify_cli/validators/paths.py +154 -0
- specify_cli/validators/research.py +428 -0
- specify_cli/verify_enhanced.py +270 -0
- 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
|
+
]
|