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

1"""AuditProject use case for checking project integrity.""" 

2 

3from dataclasses import dataclass 

4from pathlib import Path 

5from typing import TYPE_CHECKING 

6 

7from prosemark.domain.models import BinderItem, NodeId 

8from prosemark.exceptions import FileSystemError, FrontmatterFormatError, NodeNotFoundError 

9 

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 

15 

16 

17@dataclass(frozen=True) 

18class PlaceholderIssue: 

19 """Represents a placeholder item found during audit.""" 

20 

21 display_title: str 

22 position: str # Human-readable position like "[0][1]" 

23 

24 

25@dataclass(frozen=True) 

26class MissingIssue: 

27 """Represents a missing node file found during audit.""" 

28 

29 node_id: NodeId 

30 expected_path: str 

31 

32 

33@dataclass(frozen=True) 

34class OrphanIssue: 

35 """Represents an orphaned node file found during audit.""" 

36 

37 node_id: NodeId 

38 file_path: str 

39 

40 

41@dataclass(frozen=True) 

42class MismatchIssue: 

43 """Represents a mismatch between file name and content ID.""" 

44 

45 node_id: NodeId 

46 file_id: str 

47 file_path: str 

48 

49 

50@dataclass(frozen=True) 

51class AuditReport: 

52 """Complete audit report for a project.""" 

53 

54 placeholders: list[PlaceholderIssue] 

55 missing: list[MissingIssue] 

56 orphans: list[OrphanIssue] 

57 mismatches: list[MismatchIssue] 

58 

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) 

63 

64 

65class AuditProject: 

66 """Audit a prosemark project for consistency and integrity.""" 

67 

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. 

77 

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. 

83 

84 """ 

85 self.binder_repo = binder_repo 

86 self.node_repo = node_repo 

87 self.console = console 

88 self.logger = logger 

89 

90 def execute(self, *, project_path: Path | None = None) -> AuditReport: 

91 """Audit the project for consistency issues. 

92 

93 Args: 

94 project_path: Project directory path. 

95 

96 Returns: 

97 Audit report with all found issues. 

98 

99 """ 

100 project_path = project_path or Path.cwd() 

101 self.logger.info('Auditing project at %s', project_path) 

102 

103 # Load binder 

104 binder = self.binder_repo.load() 

105 

106 # Collect all issues 

107 placeholders: list[PlaceholderIssue] = [] 

108 missing: list[MissingIssue] = [] 

109 mismatches: list[MismatchIssue] = [] 

110 

111 # Check binder items 

112 self._check_items(binder.roots, placeholders, missing, mismatches, project_path) 

113 

114 # Check for orphaned files 

115 orphans = self._find_orphans(binder.roots, project_path) 

116 

117 # Create report 

118 report = AuditReport( 

119 placeholders=placeholders, 

120 missing=missing, 

121 orphans=orphans, 

122 mismatches=mismatches, 

123 ) 

124 

125 # Display results 

126 self._display_report(report) 

127 

128 return report 

129 

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. 

140 

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. 

148 

149 """ 

150 for i, item in enumerate(items): 

151 position = f'{position_prefix}[{i}]' 

152 

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' 

165 

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 

189 

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 ) 

197 

198 # Recursively check children 

199 self._check_items( 

200 item.children, 

201 placeholders, 

202 missing, 

203 mismatches, 

204 project_path, 

205 position, 

206 ) 

207 

208 def _find_orphans(self, items: list[BinderItem], project_path: Path) -> list[OrphanIssue]: 

209 """Find orphaned node files not referenced in the binder. 

210 

211 Args: 

212 items: List of binder items. 

213 project_path: Project directory path. 

214 

215 Returns: 

216 List of orphan issues. 

217 

218 """ 

219 # Collect all referenced node IDs 

220 referenced_ids: set[str] = set() 

221 self._collect_ids(items, referenced_ids) 

222 

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 

230 

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 ) 

240 

241 return orphans 

242 

243 def _collect_ids(self, items: list[BinderItem], ids: set[str]) -> None: 

244 """Recursively collect all node IDs from binder items. 

245 

246 Args: 

247 items: List of binder items. 

248 ids: Set to collect IDs into. 

249 

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) 

255 

256 def _display_report(self, report: AuditReport) -> None: 

257 """Display the audit report to the console. 

258 

259 Args: 

260 report: The audit report to display. 

261 

262 """ 

263 self.console.print_info('Project integrity check completed') 

264 

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 

270 

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}') 

276 

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}') 

281 

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}') 

286 

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)