Coverage for src/prosemark/app/audit_project.py: 100%
110 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1"""AuditProject use case for checking project integrity."""
3from dataclasses import dataclass
4from pathlib import Path
5from typing import TYPE_CHECKING
7from prosemark.domain.models import BinderItem, NodeId
8from prosemark.exceptions import FileSystemError, FrontmatterFormatError, NodeNotFoundError
10if TYPE_CHECKING: # pragma: no cover
11 from prosemark.ports.binder_repo import BinderRepo
12 from prosemark.ports.console_port import ConsolePort
13 from prosemark.ports.logger import Logger
14 from prosemark.ports.node_repo import NodeRepo
17@dataclass(frozen=True)
18class PlaceholderIssue:
19 """Represents a placeholder item found during audit."""
21 display_title: str
22 position: str # Human-readable position like "[0][1]"
25@dataclass(frozen=True)
26class MissingIssue:
27 """Represents a missing node file found during audit."""
29 node_id: NodeId
30 expected_path: str
33@dataclass(frozen=True)
34class OrphanIssue:
35 """Represents an orphaned node file found during audit."""
37 node_id: NodeId
38 file_path: str
41@dataclass(frozen=True)
42class MismatchIssue:
43 """Represents a mismatch between file name and content ID."""
45 node_id: NodeId
46 file_id: str
47 file_path: str
50@dataclass(frozen=True)
51class AuditReport:
52 """Complete audit report for a project."""
54 placeholders: list[PlaceholderIssue]
55 missing: list[MissingIssue]
56 orphans: list[OrphanIssue]
57 mismatches: list[MismatchIssue]
59 @property
60 def has_issues(self) -> bool:
61 """Check if the report contains any issues."""
62 return bool(self.placeholders or self.missing or self.orphans or self.mismatches)
65class AuditProject:
66 """Audit a prosemark project for consistency and integrity."""
68 def __init__(
69 self,
70 *,
71 binder_repo: 'BinderRepo',
72 node_repo: 'NodeRepo',
73 console: 'ConsolePort',
74 logger: 'Logger',
75 ) -> None:
76 """Initialize the AuditProject use case.
78 Args:
79 binder_repo: Repository for binder operations.
80 node_repo: Repository for node operations.
81 console: Console output port.
82 logger: Logger port.
84 """
85 self.binder_repo = binder_repo
86 self.node_repo = node_repo
87 self.console = console
88 self.logger = logger
90 def execute(self, *, project_path: Path | None = None) -> AuditReport:
91 """Audit the project for consistency issues.
93 Args:
94 project_path: Project directory path.
96 Returns:
97 Audit report with all found issues.
99 """
100 project_path = project_path or Path.cwd()
101 self.logger.info('Auditing project at %s', project_path)
103 # Load binder
104 binder = self.binder_repo.load()
106 # Collect all issues
107 placeholders: list[PlaceholderIssue] = []
108 missing: list[MissingIssue] = []
109 mismatches: list[MismatchIssue] = []
111 # Check binder items
112 self._check_items(binder.roots, placeholders, missing, mismatches, project_path)
114 # Check for orphaned files
115 orphans = self._find_orphans(binder.roots, project_path)
117 # Create report
118 report = AuditReport(
119 placeholders=placeholders,
120 missing=missing,
121 orphans=orphans,
122 mismatches=mismatches,
123 )
125 # Display results
126 self._display_report(report)
128 return report
130 def _check_items(
131 self,
132 items: list[BinderItem],
133 placeholders: list[PlaceholderIssue],
134 missing: list[MissingIssue],
135 mismatches: list[MismatchIssue],
136 project_path: Path,
137 position_prefix: str = '',
138 ) -> None:
139 """Recursively check binder items for issues.
141 Args:
142 items: List of binder items to check.
143 placeholders: List to collect placeholder issues.
144 missing: List to collect missing file issues.
145 mismatches: List to collect mismatch issues.
146 project_path: Project directory path.
147 position_prefix: Position string prefix for nested items.
149 """
150 for i, item in enumerate(items):
151 position = f'{position_prefix}[{i}]'
153 if not item.node_id:
154 # Found a placeholder
155 placeholders.append(
156 PlaceholderIssue(
157 display_title=item.display_title,
158 position=position,
159 ),
160 )
161 else:
162 # Check if node files exist
163 draft_path = project_path / f'{item.node_id.value}.md'
164 notes_path = project_path / f'{item.node_id.value}.notes.md'
166 if not draft_path.exists():
167 missing.append(
168 MissingIssue(
169 node_id=item.node_id,
170 expected_path=str(draft_path),
171 ),
172 )
173 else:
174 # Check for ID mismatch
175 try:
176 frontmatter = self.node_repo.read_frontmatter(item.node_id)
177 node_id_from_frontmatter = frontmatter.get('id')
178 if node_id_from_frontmatter != item.node_id.value:
179 mismatches.append(
180 MismatchIssue(
181 node_id=item.node_id,
182 file_id=node_id_from_frontmatter or '',
183 file_path=str(draft_path),
184 ),
185 )
186 except (NodeNotFoundError, FileSystemError, FrontmatterFormatError): # pragma: no cover
187 # Frontmatter read failed - will be caught as missing # pragma: no cover
188 pass # pragma: no cover
190 if not notes_path.exists():
191 missing.append(
192 MissingIssue(
193 node_id=item.node_id,
194 expected_path=str(notes_path),
195 ),
196 )
198 # Recursively check children
199 self._check_items(
200 item.children,
201 placeholders,
202 missing,
203 mismatches,
204 project_path,
205 position,
206 )
208 def _find_orphans(self, items: list[BinderItem], project_path: Path) -> list[OrphanIssue]:
209 """Find orphaned node files not referenced in the binder.
211 Args:
212 items: List of binder items.
213 project_path: Project directory path.
215 Returns:
216 List of orphan issues.
218 """
219 # Collect all referenced node IDs
220 referenced_ids: set[str] = set()
221 self._collect_ids(items, referenced_ids)
223 # Find all node files in the directory
224 orphans: list[OrphanIssue] = []
225 for path in project_path.glob('*.md'):
226 if path.name == '_binder.md':
227 continue
228 if path.name.endswith('.notes.md'):
229 continue
231 # Extract ID from filename
232 file_id = path.stem
233 if file_id not in referenced_ids:
234 orphans.append(
235 OrphanIssue(
236 node_id=NodeId(file_id),
237 file_path=str(path),
238 ),
239 )
241 return orphans
243 def _collect_ids(self, items: list[BinderItem], ids: set[str]) -> None:
244 """Recursively collect all node IDs from binder items.
246 Args:
247 items: List of binder items.
248 ids: Set to collect IDs into.
250 """
251 for item in items:
252 if item.node_id:
253 ids.add(item.node_id.value)
254 self._collect_ids(item.children, ids)
256 def _display_report(self, report: AuditReport) -> None:
257 """Display the audit report to the console.
259 Args:
260 report: The audit report to display.
262 """
263 self.console.print_info('Project integrity check completed')
265 if not report.has_issues:
266 self.console.print_success('✓ All nodes have valid files')
267 self.console.print_success('✓ All references are consistent')
268 self.console.print_success('✓ No orphaned files found')
269 return
271 # Display issues
272 if report.placeholders:
273 self.console.print_warning(f'Found {len(report.placeholders)} placeholder(s):')
274 for issue in report.placeholders:
275 self.console.print_info(f' {issue.position}: {issue.display_title}')
277 if report.missing:
278 self.console.print_error(f'Found {len(report.missing)} missing file(s):')
279 for missing_issue in report.missing:
280 self.console.print_info(f' {missing_issue.expected_path}')
282 if report.orphans:
283 self.console.print_warning(f'Found {len(report.orphans)} orphaned file(s):')
284 for orphan_issue in report.orphans:
285 self.console.print_info(f' {orphan_issue.file_path}')
287 if report.mismatches:
288 self.console.print_error(f'Found {len(report.mismatches)} ID mismatch(es):')
289 for mismatch_issue in report.mismatches:
290 expected = mismatch_issue.node_id.value
291 found = mismatch_issue.file_id
292 msg = f' {mismatch_issue.file_path}: expected {expected}, found {found}'
293 self.console.print_info(msg)