doit-toolkit-cli 0.1.10__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.
Potentially problematic release.
This version of doit-toolkit-cli might be problematic. Click here for more details.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/roadmapit_command.py +10 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +51 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1123 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +389 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
- doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
- doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Service to detect synchronization drift between commands and prompts."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..models.sync_models import CommandTemplate, PromptFile, SyncStatus, SyncStatusEnum
|
|
7
|
+
from .template_reader import TemplateReader
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DriftDetector:
|
|
11
|
+
"""Detects when command templates and prompts are out of sync."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_PROMPTS_DIR = ".github/prompts"
|
|
14
|
+
|
|
15
|
+
def __init__(self, project_root: Path | None = None):
|
|
16
|
+
"""Initialize the drift detector.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
project_root: Root directory of the project. Defaults to current directory.
|
|
20
|
+
"""
|
|
21
|
+
self.project_root = project_root or Path.cwd()
|
|
22
|
+
# Use TemplateReader to resolve the correct templates directory
|
|
23
|
+
reader = TemplateReader(self.project_root)
|
|
24
|
+
self.templates_dir = reader.get_templates_directory()
|
|
25
|
+
self.prompts_dir = self.project_root / self.DEFAULT_PROMPTS_DIR
|
|
26
|
+
|
|
27
|
+
def get_prompt_path(self, template: CommandTemplate) -> Path:
|
|
28
|
+
"""Get the expected prompt file path for a template.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
template: The command template.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Expected prompt file path.
|
|
35
|
+
"""
|
|
36
|
+
return self.prompts_dir / template.prompt_filename
|
|
37
|
+
|
|
38
|
+
def check_sync_status(self, template: CommandTemplate) -> SyncStatus:
|
|
39
|
+
"""Check the sync status for a command template.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
template: The command template to check.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
SyncStatus indicating whether prompt is synchronized.
|
|
46
|
+
"""
|
|
47
|
+
prompt_path = self.get_prompt_path(template)
|
|
48
|
+
now = datetime.now()
|
|
49
|
+
|
|
50
|
+
if not prompt_path.exists():
|
|
51
|
+
return SyncStatus(
|
|
52
|
+
command_name=template.name,
|
|
53
|
+
status=SyncStatusEnum.MISSING,
|
|
54
|
+
checked_at=now,
|
|
55
|
+
reason="No corresponding prompt file exists",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Compare timestamps
|
|
59
|
+
prompt_mtime = prompt_path.stat().st_mtime
|
|
60
|
+
template_mtime = template.modified_at.timestamp()
|
|
61
|
+
|
|
62
|
+
if template_mtime > prompt_mtime:
|
|
63
|
+
return SyncStatus(
|
|
64
|
+
command_name=template.name,
|
|
65
|
+
status=SyncStatusEnum.OUT_OF_SYNC,
|
|
66
|
+
checked_at=now,
|
|
67
|
+
reason="Command template modified after prompt was generated",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return SyncStatus(
|
|
71
|
+
command_name=template.name,
|
|
72
|
+
status=SyncStatusEnum.SYNCHRONIZED,
|
|
73
|
+
checked_at=now,
|
|
74
|
+
reason="Prompt is up-to-date with command template",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def check_all(self, templates: list[CommandTemplate]) -> list[SyncStatus]:
|
|
78
|
+
"""Check sync status for all templates.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
templates: List of command templates to check.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of SyncStatus for each template.
|
|
85
|
+
"""
|
|
86
|
+
return [self.check_sync_status(t) for t in templates]
|
|
87
|
+
|
|
88
|
+
def get_out_of_sync(self, templates: list[CommandTemplate]) -> list[SyncStatus]:
|
|
89
|
+
"""Get only the templates that are out of sync.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
templates: List of command templates to check.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of SyncStatus for templates that need synchronization.
|
|
96
|
+
"""
|
|
97
|
+
statuses = self.check_all(templates)
|
|
98
|
+
return [s for s in statuses if s.status != SyncStatusEnum.SYNCHRONIZED]
|
|
99
|
+
|
|
100
|
+
def is_all_synced(self, templates: list[CommandTemplate]) -> bool:
|
|
101
|
+
"""Check if all templates are synchronized.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
templates: List of command templates to check.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
True if all templates are synchronized, False otherwise.
|
|
108
|
+
"""
|
|
109
|
+
return len(self.get_out_of_sync(templates)) == 0
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Parser for Key Entities section from spec.md files."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models.diagram_models import (
|
|
7
|
+
Cardinality,
|
|
8
|
+
EntityAttribute,
|
|
9
|
+
EntityRelationship,
|
|
10
|
+
ParsedEntity,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EntityParser:
|
|
15
|
+
"""Parses Key Entities section from spec.md content.
|
|
16
|
+
|
|
17
|
+
Extracts entity names, descriptions, attributes, and relationships
|
|
18
|
+
from the Key Entities markdown section.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
# Pattern for Key Entities section header
|
|
22
|
+
SECTION_HEADER_PATTERN = re.compile(
|
|
23
|
+
r"^##\s*Key\s+Entities\s*$|^###\s*Key\s+Entities\s*$", re.IGNORECASE | re.MULTILINE
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Pattern for entity bullet: - **EntityName**: Description
|
|
27
|
+
ENTITY_PATTERN = re.compile(
|
|
28
|
+
r"^[-*]\s+\*\*([A-Za-z][A-Za-z0-9_]*)\*\*:\s*(.+)$", re.MULTILINE
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Patterns for inferring relationships
|
|
32
|
+
RELATIONSHIP_PATTERNS = [
|
|
33
|
+
(re.compile(r"has\s+many\s+(\w+)", re.IGNORECASE), Cardinality.ONE_TO_MANY),
|
|
34
|
+
(re.compile(r"contains\s+(\w+)", re.IGNORECASE), Cardinality.ONE_TO_MANY),
|
|
35
|
+
(re.compile(r"owns\s+(\w+)", re.IGNORECASE), Cardinality.ONE_TO_MANY),
|
|
36
|
+
(re.compile(r"has\s+one\s+(\w+)", re.IGNORECASE), Cardinality.ONE_TO_ONE),
|
|
37
|
+
(re.compile(r"belongs\s+to\s+(\w+)", re.IGNORECASE), Cardinality.MANY_TO_ONE),
|
|
38
|
+
(re.compile(r"references?\s+(\w+)", re.IGNORECASE), Cardinality.MANY_TO_ONE),
|
|
39
|
+
(re.compile(r"many-to-many\s+(?:with\s+)?(\w+)", re.IGNORECASE), Cardinality.MANY_TO_MANY),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Pattern for common attribute types
|
|
43
|
+
ATTRIBUTE_TYPE_PATTERNS = [
|
|
44
|
+
(re.compile(r"\b(id|uuid|identifier)\b", re.IGNORECASE), "uuid", True, False),
|
|
45
|
+
(re.compile(r"\b(email)\b", re.IGNORECASE), "string", False, False),
|
|
46
|
+
(re.compile(r"\b(name|title|description|text|label)\b", re.IGNORECASE), "string", False, False),
|
|
47
|
+
(re.compile(r"\b(password|hash|token|secret)\b", re.IGNORECASE), "string", False, False),
|
|
48
|
+
(re.compile(r"\b(count|number|quantity|amount)\b", re.IGNORECASE), "int", False, False),
|
|
49
|
+
(re.compile(r"\b(price|cost|rate)\b", re.IGNORECASE), "decimal", False, False),
|
|
50
|
+
(re.compile(r"\b(date|time|timestamp|created|updated)\b", re.IGNORECASE), "datetime", False, False),
|
|
51
|
+
(re.compile(r"\b(status|state|type)\b", re.IGNORECASE), "string", False, False),
|
|
52
|
+
(re.compile(r"\b(active|enabled|is_|has_)\b", re.IGNORECASE), "boolean", False, False),
|
|
53
|
+
(re.compile(r"\b(\w+)_id\b", re.IGNORECASE), "uuid", False, True), # FK pattern
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
def parse(self, content: str) -> list[ParsedEntity]:
|
|
57
|
+
"""Parse all entities from spec content.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
content: Full content of spec.md file
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
List of ParsedEntity objects
|
|
64
|
+
"""
|
|
65
|
+
# Find Key Entities section
|
|
66
|
+
section_content = self._extract_key_entities_section(content)
|
|
67
|
+
if not section_content:
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
entities = []
|
|
71
|
+
entity_names: set[str] = set()
|
|
72
|
+
|
|
73
|
+
# Find all entity definitions
|
|
74
|
+
for match in self.ENTITY_PATTERN.finditer(section_content):
|
|
75
|
+
entity_name = match.group(1).strip()
|
|
76
|
+
description = match.group(2).strip()
|
|
77
|
+
|
|
78
|
+
# Avoid duplicates
|
|
79
|
+
if entity_name in entity_names:
|
|
80
|
+
continue
|
|
81
|
+
entity_names.add(entity_name)
|
|
82
|
+
|
|
83
|
+
# Parse attributes from description
|
|
84
|
+
attributes = self._extract_attributes(entity_name, description)
|
|
85
|
+
|
|
86
|
+
# Parse relationships from description
|
|
87
|
+
relationships = self._extract_relationships(entity_name, description, entity_names)
|
|
88
|
+
|
|
89
|
+
entity = ParsedEntity(
|
|
90
|
+
name=entity_name,
|
|
91
|
+
description=description,
|
|
92
|
+
raw_text=match.group(0),
|
|
93
|
+
attributes=attributes,
|
|
94
|
+
relationships=[], # Will be populated in second pass
|
|
95
|
+
)
|
|
96
|
+
entities.append(entity)
|
|
97
|
+
|
|
98
|
+
# Second pass: resolve relationships now that we know all entities
|
|
99
|
+
self._resolve_relationships(entities)
|
|
100
|
+
|
|
101
|
+
return entities
|
|
102
|
+
|
|
103
|
+
def _extract_key_entities_section(self, content: str) -> Optional[str]:
|
|
104
|
+
"""Extract the Key Entities section from content.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
content: Full file content
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Key Entities section content, or None if not found
|
|
111
|
+
"""
|
|
112
|
+
match = self.SECTION_HEADER_PATTERN.search(content)
|
|
113
|
+
if not match:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
start_pos = match.end()
|
|
117
|
+
|
|
118
|
+
# Find next section header
|
|
119
|
+
next_section = re.search(r"^##\s+[^#]", content[start_pos:], re.MULTILINE)
|
|
120
|
+
if next_section:
|
|
121
|
+
end_pos = start_pos + next_section.start()
|
|
122
|
+
else:
|
|
123
|
+
end_pos = len(content)
|
|
124
|
+
|
|
125
|
+
return content[start_pos:end_pos]
|
|
126
|
+
|
|
127
|
+
def _extract_attributes(self, entity_name: str, description: str) -> list[EntityAttribute]:
|
|
128
|
+
"""Extract attributes from entity description.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
entity_name: Name of the entity
|
|
132
|
+
description: Entity description text
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
List of EntityAttribute objects
|
|
136
|
+
"""
|
|
137
|
+
attributes = []
|
|
138
|
+
found_attrs: set[str] = set()
|
|
139
|
+
|
|
140
|
+
# Always add an id attribute as PK
|
|
141
|
+
attributes.append(
|
|
142
|
+
EntityAttribute(name="id", attr_type="uuid", is_pk=True, is_fk=False)
|
|
143
|
+
)
|
|
144
|
+
found_attrs.add("id")
|
|
145
|
+
|
|
146
|
+
# Look for attribute mentions in description
|
|
147
|
+
words = re.findall(r"\b([a-z][a-z0-9_]*)\b", description.lower())
|
|
148
|
+
|
|
149
|
+
for word in words:
|
|
150
|
+
if word in found_attrs or word in ("the", "and", "or", "with", "for", "has", "is", "a", "an"):
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
for pattern, attr_type, is_pk, is_fk in self.ATTRIBUTE_TYPE_PATTERNS:
|
|
154
|
+
if pattern.search(word):
|
|
155
|
+
# Use the actual word as attribute name
|
|
156
|
+
attr_name = word
|
|
157
|
+
if attr_name not in found_attrs:
|
|
158
|
+
attributes.append(
|
|
159
|
+
EntityAttribute(
|
|
160
|
+
name=attr_name,
|
|
161
|
+
attr_type=attr_type,
|
|
162
|
+
is_pk=is_pk and len(attributes) == 0,
|
|
163
|
+
is_fk=is_fk,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
found_attrs.add(attr_name)
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
return attributes
|
|
170
|
+
|
|
171
|
+
def _extract_relationships(
|
|
172
|
+
self, entity_name: str, description: str, known_entities: set[str]
|
|
173
|
+
) -> list[tuple[str, Cardinality, str]]:
|
|
174
|
+
"""Extract relationships from entity description.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
entity_name: Source entity name
|
|
178
|
+
description: Entity description text
|
|
179
|
+
known_entities: Set of known entity names
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of (target_entity, cardinality, label) tuples
|
|
183
|
+
"""
|
|
184
|
+
relationships = []
|
|
185
|
+
|
|
186
|
+
for pattern, cardinality in self.RELATIONSHIP_PATTERNS:
|
|
187
|
+
matches = pattern.finditer(description)
|
|
188
|
+
for match in matches:
|
|
189
|
+
target_name = match.group(1)
|
|
190
|
+
|
|
191
|
+
# Normalize target name
|
|
192
|
+
target_normalized = self._normalize_entity_name(target_name)
|
|
193
|
+
|
|
194
|
+
# Generate label from match
|
|
195
|
+
label = self._generate_relationship_label(match.group(0))
|
|
196
|
+
|
|
197
|
+
relationships.append((target_normalized, cardinality, label))
|
|
198
|
+
|
|
199
|
+
return relationships
|
|
200
|
+
|
|
201
|
+
def _resolve_relationships(self, entities: list[ParsedEntity]) -> None:
|
|
202
|
+
"""Resolve relationships between entities.
|
|
203
|
+
|
|
204
|
+
Updates each entity's relationships list based on descriptions.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
entities: List of entities to process
|
|
208
|
+
"""
|
|
209
|
+
entity_names = {e.name.lower(): e.name for e in entities}
|
|
210
|
+
|
|
211
|
+
for entity in entities:
|
|
212
|
+
relationships = self._extract_relationships(
|
|
213
|
+
entity.name, entity.description, set(entity_names.keys())
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
for target_name, cardinality, label in relationships:
|
|
217
|
+
# Try to match target to known entity
|
|
218
|
+
target_lower = target_name.lower()
|
|
219
|
+
|
|
220
|
+
# Check exact match
|
|
221
|
+
if target_lower in entity_names:
|
|
222
|
+
actual_target = entity_names[target_lower]
|
|
223
|
+
else:
|
|
224
|
+
# Try singular/plural matching
|
|
225
|
+
singular = target_lower.rstrip("s")
|
|
226
|
+
plural = target_lower + "s"
|
|
227
|
+
|
|
228
|
+
if singular in entity_names:
|
|
229
|
+
actual_target = entity_names[singular]
|
|
230
|
+
elif plural in entity_names:
|
|
231
|
+
actual_target = entity_names[plural]
|
|
232
|
+
else:
|
|
233
|
+
# Target not found in known entities, skip
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Avoid self-references unless explicit
|
|
237
|
+
if actual_target == entity.name:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
rel = EntityRelationship(
|
|
241
|
+
source_entity=entity.name,
|
|
242
|
+
target_entity=actual_target,
|
|
243
|
+
cardinality=cardinality,
|
|
244
|
+
label=label,
|
|
245
|
+
)
|
|
246
|
+
entity.relationships.append(rel)
|
|
247
|
+
|
|
248
|
+
def _normalize_entity_name(self, name: str) -> str:
|
|
249
|
+
"""Normalize entity name to PascalCase.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
name: Raw entity name
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
PascalCase entity name
|
|
256
|
+
"""
|
|
257
|
+
# Remove pluralization
|
|
258
|
+
if name.endswith("s") and not name.endswith("ss"):
|
|
259
|
+
name = name[:-1]
|
|
260
|
+
|
|
261
|
+
# Capitalize first letter
|
|
262
|
+
return name[0].upper() + name[1:] if name else name
|
|
263
|
+
|
|
264
|
+
def _generate_relationship_label(self, match_text: str) -> str:
|
|
265
|
+
"""Generate a relationship label from match text.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
match_text: The matched relationship text
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Short label for the relationship
|
|
272
|
+
"""
|
|
273
|
+
# Extract verb phrase
|
|
274
|
+
text = match_text.lower()
|
|
275
|
+
|
|
276
|
+
if "has many" in text or "contains" in text:
|
|
277
|
+
return "has"
|
|
278
|
+
if "has one" in text:
|
|
279
|
+
return "has"
|
|
280
|
+
if "belongs to" in text:
|
|
281
|
+
return "belongs_to"
|
|
282
|
+
if "owns" in text:
|
|
283
|
+
return "owns"
|
|
284
|
+
if "reference" in text:
|
|
285
|
+
return "references"
|
|
286
|
+
|
|
287
|
+
return ""
|
|
288
|
+
|
|
289
|
+
def count_entities(self, content: str) -> int:
|
|
290
|
+
"""Count entities in content without full parsing.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
content: Spec content to check
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Number of entities found
|
|
297
|
+
"""
|
|
298
|
+
section = self._extract_key_entities_section(content)
|
|
299
|
+
if not section:
|
|
300
|
+
return 0
|
|
301
|
+
return len(self.ENTITY_PATTERN.findall(section))
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Generator for ER diagrams from parsed entities."""
|
|
2
|
+
|
|
3
|
+
from ..models.diagram_models import (
|
|
4
|
+
DiagramType,
|
|
5
|
+
EntityRelationship,
|
|
6
|
+
GeneratedDiagram,
|
|
7
|
+
ParsedEntity,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ERDiagramGenerator:
|
|
12
|
+
"""Generates Mermaid ER diagrams from parsed entities.
|
|
13
|
+
|
|
14
|
+
Converts ParsedEntity objects into an erDiagram with entity
|
|
15
|
+
definitions and relationship notations.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def generate(self, entities: list[ParsedEntity]) -> str:
|
|
19
|
+
"""Generate Mermaid erDiagram from entities.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
entities: List of parsed entities
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Mermaid erDiagram syntax
|
|
26
|
+
"""
|
|
27
|
+
if not entities:
|
|
28
|
+
return ""
|
|
29
|
+
|
|
30
|
+
lines = ["erDiagram"]
|
|
31
|
+
|
|
32
|
+
# Collect all relationships first
|
|
33
|
+
relationships = self._collect_relationships(entities)
|
|
34
|
+
|
|
35
|
+
# Add relationship lines
|
|
36
|
+
for rel in relationships:
|
|
37
|
+
lines.append(rel.mermaid_line)
|
|
38
|
+
|
|
39
|
+
# Add blank line between relationships and entities
|
|
40
|
+
if relationships:
|
|
41
|
+
lines.append("")
|
|
42
|
+
|
|
43
|
+
# Add entity definitions
|
|
44
|
+
for entity in entities:
|
|
45
|
+
entity_lines = self._generate_entity_block(entity)
|
|
46
|
+
lines.extend(entity_lines)
|
|
47
|
+
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
def generate_diagram(self, entities: list[ParsedEntity]) -> GeneratedDiagram:
|
|
51
|
+
"""Generate a GeneratedDiagram object from entities.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
entities: List of parsed entities
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
GeneratedDiagram with content and metadata
|
|
58
|
+
"""
|
|
59
|
+
content = self.generate(entities)
|
|
60
|
+
|
|
61
|
+
return GeneratedDiagram(
|
|
62
|
+
id="er-diagram",
|
|
63
|
+
diagram_type=DiagramType.ER_DIAGRAM,
|
|
64
|
+
mermaid_content=content,
|
|
65
|
+
is_valid=True,
|
|
66
|
+
node_count=len(entities),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def _collect_relationships(
|
|
70
|
+
self, entities: list[ParsedEntity]
|
|
71
|
+
) -> list[EntityRelationship]:
|
|
72
|
+
"""Collect all unique relationships from entities.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
entities: List of parsed entities
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Deduplicated list of relationships
|
|
79
|
+
"""
|
|
80
|
+
relationships = []
|
|
81
|
+
seen: set[tuple[str, str]] = set()
|
|
82
|
+
|
|
83
|
+
entity_names = {e.name for e in entities}
|
|
84
|
+
|
|
85
|
+
for entity in entities:
|
|
86
|
+
for rel in entity.relationships:
|
|
87
|
+
# Only include relationships where target exists
|
|
88
|
+
if rel.target_entity not in entity_names:
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Deduplicate based on source-target pair
|
|
92
|
+
key = (rel.source_entity, rel.target_entity)
|
|
93
|
+
reverse_key = (rel.target_entity, rel.source_entity)
|
|
94
|
+
|
|
95
|
+
if key not in seen and reverse_key not in seen:
|
|
96
|
+
relationships.append(rel)
|
|
97
|
+
seen.add(key)
|
|
98
|
+
|
|
99
|
+
return relationships
|
|
100
|
+
|
|
101
|
+
def _generate_entity_block(self, entity: ParsedEntity) -> list[str]:
|
|
102
|
+
"""Generate entity block with attributes.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
entity: Parsed entity
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of Mermaid syntax lines
|
|
109
|
+
"""
|
|
110
|
+
if not entity.attributes:
|
|
111
|
+
# Entity without attributes - just the name
|
|
112
|
+
return [f" {entity.name}"]
|
|
113
|
+
|
|
114
|
+
lines = [f" {entity.name} {{"]
|
|
115
|
+
|
|
116
|
+
for attr in entity.attributes:
|
|
117
|
+
pk_marker = " PK" if attr.is_pk else ""
|
|
118
|
+
fk_marker = " FK" if attr.is_fk else ""
|
|
119
|
+
lines.append(f" {attr.attr_type} {attr.name}{pk_marker}{fk_marker}")
|
|
120
|
+
|
|
121
|
+
lines.append(" }")
|
|
122
|
+
return lines
|
|
123
|
+
|
|
124
|
+
def generate_simple(self, entities: list[ParsedEntity]) -> str:
|
|
125
|
+
"""Generate a simplified ER diagram with just entities and relationships.
|
|
126
|
+
|
|
127
|
+
No attribute details - useful for high-level overview.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
entities: List of parsed entities
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Simplified Mermaid erDiagram syntax
|
|
134
|
+
"""
|
|
135
|
+
if not entities:
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
lines = ["erDiagram"]
|
|
139
|
+
|
|
140
|
+
# Add relationships
|
|
141
|
+
relationships = self._collect_relationships(entities)
|
|
142
|
+
for rel in relationships:
|
|
143
|
+
lines.append(rel.mermaid_line)
|
|
144
|
+
|
|
145
|
+
# Add orphan entities (no relationships)
|
|
146
|
+
related_entities = set()
|
|
147
|
+
for rel in relationships:
|
|
148
|
+
related_entities.add(rel.source_entity)
|
|
149
|
+
related_entities.add(rel.target_entity)
|
|
150
|
+
|
|
151
|
+
for entity in entities:
|
|
152
|
+
if entity.name not in related_entities:
|
|
153
|
+
lines.append(f" {entity.name}")
|
|
154
|
+
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
def add_inferred_relationships(
|
|
158
|
+
self, entities: list[ParsedEntity]
|
|
159
|
+
) -> list[ParsedEntity]:
|
|
160
|
+
"""Add inferred relationships based on naming conventions.
|
|
161
|
+
|
|
162
|
+
Looks for FK patterns like user_id that reference other entities.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
entities: List of parsed entities
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Entities with additional inferred relationships
|
|
169
|
+
"""
|
|
170
|
+
from ..models.diagram_models import Cardinality
|
|
171
|
+
|
|
172
|
+
entity_names_lower = {e.name.lower(): e.name for e in entities}
|
|
173
|
+
|
|
174
|
+
for entity in entities:
|
|
175
|
+
for attr in entity.attributes:
|
|
176
|
+
if attr.is_fk and attr.name.endswith("_id"):
|
|
177
|
+
# Extract referenced entity name
|
|
178
|
+
ref_name = attr.name[:-3] # Remove '_id'
|
|
179
|
+
|
|
180
|
+
if ref_name.lower() in entity_names_lower:
|
|
181
|
+
target = entity_names_lower[ref_name.lower()]
|
|
182
|
+
|
|
183
|
+
# Check if relationship already exists
|
|
184
|
+
existing = any(
|
|
185
|
+
r.target_entity == target for r in entity.relationships
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if not existing and target != entity.name:
|
|
189
|
+
rel = EntityRelationship(
|
|
190
|
+
source_entity=entity.name,
|
|
191
|
+
target_entity=target,
|
|
192
|
+
cardinality=Cardinality.MANY_TO_ONE,
|
|
193
|
+
label="references",
|
|
194
|
+
)
|
|
195
|
+
entity.relationships.append(rel)
|
|
196
|
+
|
|
197
|
+
return entities
|