doit-toolkit-cli 0.1.9__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 (134) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/status_command.py +117 -0
  11. doit_cli/cli/sync_prompts_command.py +248 -0
  12. doit_cli/cli/validate_command.py +196 -0
  13. doit_cli/cli/verify_command.py +204 -0
  14. doit_cli/cli/workflow_mixin.py +224 -0
  15. doit_cli/cli/xref_command.py +555 -0
  16. doit_cli/formatters/__init__.py +8 -0
  17. doit_cli/formatters/base.py +38 -0
  18. doit_cli/formatters/json_formatter.py +126 -0
  19. doit_cli/formatters/markdown_formatter.py +97 -0
  20. doit_cli/formatters/rich_formatter.py +257 -0
  21. doit_cli/main.py +49 -0
  22. doit_cli/models/__init__.py +139 -0
  23. doit_cli/models/agent.py +74 -0
  24. doit_cli/models/analytics_models.py +384 -0
  25. doit_cli/models/context_config.py +464 -0
  26. doit_cli/models/crossref_models.py +182 -0
  27. doit_cli/models/diagram_models.py +363 -0
  28. doit_cli/models/fixit_models.py +355 -0
  29. doit_cli/models/hook_config.py +125 -0
  30. doit_cli/models/project.py +91 -0
  31. doit_cli/models/results.py +121 -0
  32. doit_cli/models/search_models.py +228 -0
  33. doit_cli/models/status_models.py +195 -0
  34. doit_cli/models/sync_models.py +146 -0
  35. doit_cli/models/template.py +77 -0
  36. doit_cli/models/validation_models.py +175 -0
  37. doit_cli/models/workflow_models.py +319 -0
  38. doit_cli/prompts/__init__.py +5 -0
  39. doit_cli/prompts/fixit_prompts.py +344 -0
  40. doit_cli/prompts/interactive.py +390 -0
  41. doit_cli/rules/__init__.py +5 -0
  42. doit_cli/rules/builtin_rules.py +160 -0
  43. doit_cli/services/__init__.py +79 -0
  44. doit_cli/services/agent_detector.py +168 -0
  45. doit_cli/services/analytics_service.py +218 -0
  46. doit_cli/services/architecture_generator.py +290 -0
  47. doit_cli/services/backup_service.py +204 -0
  48. doit_cli/services/config_loader.py +113 -0
  49. doit_cli/services/context_loader.py +1121 -0
  50. doit_cli/services/coverage_calculator.py +142 -0
  51. doit_cli/services/crossref_service.py +237 -0
  52. doit_cli/services/cycle_time_calculator.py +134 -0
  53. doit_cli/services/date_inferrer.py +349 -0
  54. doit_cli/services/diagram_service.py +337 -0
  55. doit_cli/services/drift_detector.py +109 -0
  56. doit_cli/services/entity_parser.py +301 -0
  57. doit_cli/services/er_diagram_generator.py +197 -0
  58. doit_cli/services/fixit_service.py +699 -0
  59. doit_cli/services/github_service.py +192 -0
  60. doit_cli/services/hook_manager.py +258 -0
  61. doit_cli/services/hook_validator.py +528 -0
  62. doit_cli/services/input_validator.py +322 -0
  63. doit_cli/services/memory_search.py +527 -0
  64. doit_cli/services/mermaid_validator.py +334 -0
  65. doit_cli/services/prompt_transformer.py +91 -0
  66. doit_cli/services/prompt_writer.py +133 -0
  67. doit_cli/services/query_interpreter.py +428 -0
  68. doit_cli/services/report_exporter.py +219 -0
  69. doit_cli/services/report_generator.py +256 -0
  70. doit_cli/services/requirement_parser.py +112 -0
  71. doit_cli/services/roadmap_summarizer.py +209 -0
  72. doit_cli/services/rule_engine.py +443 -0
  73. doit_cli/services/scaffolder.py +215 -0
  74. doit_cli/services/score_calculator.py +172 -0
  75. doit_cli/services/section_parser.py +204 -0
  76. doit_cli/services/spec_scanner.py +327 -0
  77. doit_cli/services/state_manager.py +355 -0
  78. doit_cli/services/status_reporter.py +143 -0
  79. doit_cli/services/task_parser.py +347 -0
  80. doit_cli/services/template_manager.py +710 -0
  81. doit_cli/services/template_reader.py +158 -0
  82. doit_cli/services/user_journey_generator.py +214 -0
  83. doit_cli/services/user_story_parser.py +232 -0
  84. doit_cli/services/validation_service.py +188 -0
  85. doit_cli/services/validator.py +232 -0
  86. doit_cli/services/velocity_tracker.py +173 -0
  87. doit_cli/services/workflow_engine.py +405 -0
  88. doit_cli/templates/agent-file-template.md +28 -0
  89. doit_cli/templates/checklist-template.md +39 -0
  90. doit_cli/templates/commands/doit.checkin.md +363 -0
  91. doit_cli/templates/commands/doit.constitution.md +187 -0
  92. doit_cli/templates/commands/doit.documentit.md +485 -0
  93. doit_cli/templates/commands/doit.fixit.md +181 -0
  94. doit_cli/templates/commands/doit.implementit.md +265 -0
  95. doit_cli/templates/commands/doit.planit.md +262 -0
  96. doit_cli/templates/commands/doit.reviewit.md +355 -0
  97. doit_cli/templates/commands/doit.roadmapit.md +368 -0
  98. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  99. doit_cli/templates/commands/doit.specit.md +521 -0
  100. doit_cli/templates/commands/doit.taskit.md +304 -0
  101. doit_cli/templates/commands/doit.testit.md +277 -0
  102. doit_cli/templates/config/context.yaml +134 -0
  103. doit_cli/templates/config/hooks.yaml +93 -0
  104. doit_cli/templates/config/validation-rules.yaml +64 -0
  105. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  106. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  107. doit_cli/templates/github-issue-templates/task.yml +129 -0
  108. doit_cli/templates/hooks/.gitkeep +0 -0
  109. doit_cli/templates/hooks/post-commit.sh +25 -0
  110. doit_cli/templates/hooks/post-merge.sh +75 -0
  111. doit_cli/templates/hooks/pre-commit.sh +17 -0
  112. doit_cli/templates/hooks/pre-push.sh +18 -0
  113. doit_cli/templates/memory/completed_roadmap.md +50 -0
  114. doit_cli/templates/memory/constitution.md +125 -0
  115. doit_cli/templates/memory/roadmap.md +61 -0
  116. doit_cli/templates/plan-template.md +146 -0
  117. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  118. doit_cli/templates/scripts/bash/common.sh +156 -0
  119. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  120. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  121. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  122. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  123. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  124. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  125. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  126. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  127. doit_cli/templates/spec-template.md +159 -0
  128. doit_cli/templates/tasks-template.md +313 -0
  129. doit_cli/templates/vscode-settings.json +14 -0
  130. doit_toolkit_cli-0.1.9.dist-info/METADATA +324 -0
  131. doit_toolkit_cli-0.1.9.dist-info/RECORD +134 -0
  132. doit_toolkit_cli-0.1.9.dist-info/WHEEL +4 -0
  133. doit_toolkit_cli-0.1.9.dist-info/entry_points.txt +2 -0
  134. doit_toolkit_cli-0.1.9.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