se-admin 0.2.0__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 (55) hide show
  1. se_admin/__init__.py +1 -0
  2. se_admin/__main__.py +6 -0
  3. se_admin/_version.py +24 -0
  4. se_admin/actions/__init__.py +35 -0
  5. se_admin/actions/copy_file.py +57 -0
  6. se_admin/actions/dependabot.py +58 -0
  7. se_admin/actions/git_pull.py +18 -0
  8. se_admin/actions/patch_markdown.py +108 -0
  9. se_admin/actions/patch_toml.py +149 -0
  10. se_admin/actions/replace_file.py +93 -0
  11. se_admin/actions/run_command.py +38 -0
  12. se_admin/app.py +374 -0
  13. se_admin/checks/__init__.py +83 -0
  14. se_admin/checks/exact_files.py +72 -0
  15. se_admin/checks/python_version.py +94 -0
  16. se_admin/checks/reference_files.py +63 -0
  17. se_admin/checks/required_paths.py +29 -0
  18. se_admin/checks/tags.py +54 -0
  19. se_admin/checks/workflows.py +29 -0
  20. se_admin/cli.py +89 -0
  21. se_admin/domain/__init__.py +1 -0
  22. se_admin/domain/capabilities.py +20 -0
  23. se_admin/domain/findings.py +34 -0
  24. se_admin/domain/operations.py +194 -0
  25. se_admin/domain/profiles.py +92 -0
  26. se_admin/domain/repos.py +77 -0
  27. se_admin/domain/selectors.py +38 -0
  28. se_admin/domain/tasks.py +72 -0
  29. se_admin/migrations/__init__.py +17 -0
  30. se_admin/migrations/python_package_profile.py +62 -0
  31. se_admin/migrations/python_tooling_profile.py +54 -0
  32. se_admin/migrations/python_version.py +65 -0
  33. se_admin/migrations/replace_mkdocs_with_zensical.py +108 -0
  34. se_admin/migrations/workflow_names.py +82 -0
  35. se_admin/observe/__init__.py +91 -0
  36. se_admin/observe/filesystem.py +41 -0
  37. se_admin/observe/git.py +74 -0
  38. se_admin/observe/github.py +80 -0
  39. se_admin/observe/pyproject.py +52 -0
  40. se_admin/observe/toml_files.py +54 -0
  41. se_admin/observe/workflows.py +43 -0
  42. se_admin/py.typed +0 -0
  43. se_admin/reports/__init__.py +13 -0
  44. se_admin/reports/json_report.py +62 -0
  45. se_admin/reports/markdown.py +78 -0
  46. se_admin/reports/summary.py +56 -0
  47. se_admin/utils/__init__.py +1 -0
  48. se_admin/utils/paths.py +41 -0
  49. se_admin/utils/subprocesses.py +23 -0
  50. se_admin/utils/text.py +55 -0
  51. se_admin-0.2.0.dist-info/METADATA +258 -0
  52. se_admin-0.2.0.dist-info/RECORD +55 -0
  53. se_admin-0.2.0.dist-info/WHEEL +4 -0
  54. se_admin-0.2.0.dist-info/entry_points.txt +2 -0
  55. se_admin-0.2.0.dist-info/licenses/LICENSE +21 -0
se_admin/app.py ADDED
@@ -0,0 +1,374 @@
1
+ """Application orchestration for se_admin."""
2
+
3
+ from pathlib import Path
4
+
5
+ from se_admin.actions import ActionResult
6
+ from se_admin.actions.copy_file import (
7
+ run_copy_file,
8
+ run_delete_file,
9
+ )
10
+ from se_admin.actions.patch_toml import run_patch_toml
11
+ from se_admin.actions.replace_file import (
12
+ run_ensure_workflow,
13
+ run_remove_workflow,
14
+ run_replace_workflow,
15
+ )
16
+ from se_admin.checks import run_profile_checks
17
+ from se_admin.domain.capabilities import CAPABILITY_LABELS, Capability
18
+ from se_admin.domain.findings import Finding
19
+ from se_admin.domain.operations import (
20
+ CopyFile,
21
+ DeleteFile,
22
+ EnsureWorkflow,
23
+ PatchToml,
24
+ RemoveWorkflow,
25
+ ReplaceWorkflow,
26
+ )
27
+ from se_admin.domain.profiles import ProfileRegistry
28
+ from se_admin.domain.repos import RepoEntry, RepoRegistry
29
+ from se_admin.domain.selectors import (
30
+ PatternSelector,
31
+ RepoNameSelector,
32
+ RepoSetSelector,
33
+ )
34
+ from se_admin.domain.tasks import Task
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # show
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def run_show(*, area: str = "all") -> int:
42
+ """Show available admin capabilities."""
43
+ targets: list[Capability] = [*Capability] if area == "all" else [Capability(area)]
44
+ print("SE admin capabilities:")
45
+ for cap in targets:
46
+ print(f" {cap.value}: {CAPABILITY_LABELS[cap]}")
47
+ return 0
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # repos
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def run_repos(
56
+ *,
57
+ data: Path,
58
+ repo_set: str | None = None,
59
+ active_only: bool = False,
60
+ ) -> int:
61
+ """List repos from repos.toml."""
62
+ registry = RepoRegistry.from_toml(data / "repos.toml")
63
+
64
+ if repo_set:
65
+ repos = registry.repos_in_set(repo_set)
66
+ elif active_only:
67
+ repos = registry.active_repos()
68
+ else:
69
+ repos = list(registry.repos.values())
70
+
71
+ if not repos:
72
+ print("No repos matched.")
73
+ return 0
74
+
75
+ print(f"{'name':<40} {'set':<16} {'profiles'}")
76
+ print("-" * 72)
77
+ for repo in sorted(repos, key=lambda r: r.name):
78
+ profiles = ", ".join(repo.profiles)
79
+ status = f" [{repo.status}]" if repo.status != "active" else ""
80
+ print(f"{repo.name + status:<40} {repo.repo_set:<16} {profiles}")
81
+
82
+ return 0
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # tasks
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def run_tasks(*, data: Path) -> int:
91
+ """List available tasks from data/tasks/."""
92
+ tasks_dir = data / "tasks"
93
+ if not tasks_dir.exists():
94
+ print("No tasks directory found.")
95
+ return 1
96
+
97
+ tasks = Task.load_all(tasks_dir)
98
+ if not tasks:
99
+ print("No tasks found.")
100
+ return 0
101
+
102
+ print(f"{'id':<48} label")
103
+ print("-" * 72)
104
+ for task_id, task in sorted(tasks.items()):
105
+ print(f"{task_id:<48} {task.label}")
106
+
107
+ return 0
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # check
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def run_check(
116
+ *,
117
+ data: Path,
118
+ repo: str | None = None,
119
+ repo_set: str | None = None,
120
+ profile: str | None = None,
121
+ ) -> int:
122
+ """Run profile checks against a repo or set."""
123
+ repo_registry = RepoRegistry.from_toml(data / "repos.toml")
124
+ profile_registry = ProfileRegistry.from_toml(data / "profiles.toml")
125
+ workspace = repo_registry.workspace_path()
126
+
127
+ if repo:
128
+ entry = repo_registry.repos.get(repo)
129
+ if entry is None:
130
+ print(f"Unknown repo: {repo!r}")
131
+ return 1
132
+ entries = [entry]
133
+ elif repo_set:
134
+ entries = repo_registry.repos_in_set(repo_set)
135
+ if not entries:
136
+ print(f"No repos in set: {repo_set!r}")
137
+ return 1
138
+ else:
139
+ print("Specify --repo or --set.")
140
+ return 2
141
+
142
+ all_findings: list[Finding] = []
143
+
144
+ for entry in entries:
145
+ profile_names = [profile] if profile else entry.profiles
146
+ profiles = profile_registry.resolve(profile_names)
147
+ for prof in profiles:
148
+ findings = run_profile_checks(
149
+ entry,
150
+ prof,
151
+ workspace_path=workspace,
152
+ profile_registry=profile_registry,
153
+ )
154
+ all_findings.extend(findings)
155
+
156
+ _print_findings(all_findings)
157
+ failures = [f for f in all_findings if f.failed]
158
+ return 1 if failures else 0
159
+
160
+
161
+ def _print_findings(findings: list[Finding]) -> None:
162
+ if not findings:
163
+ print("No findings.")
164
+ return
165
+
166
+ by_repo: dict[str, list[Finding]] = {}
167
+ for f in findings:
168
+ by_repo.setdefault(f.repo, []).append(f)
169
+
170
+ for repo_name, repo_findings in sorted(by_repo.items()):
171
+ passed = sum(1 for f in repo_findings if f.passed)
172
+ failed = sum(1 for f in repo_findings if f.failed)
173
+ print(f"\n{repo_name} ({passed} pass, {failed} fail)")
174
+ for f in repo_findings:
175
+ icon = "✓" if f.passed else ("✗" if f.failed else "–")
176
+ path = f" {f.path}" if f.path else ""
177
+ msg = f" {f.message}" if f.message else ""
178
+ print(f" {icon} [{f.check}]{path}{msg}")
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # run (task executor)
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def run_task(
187
+ *,
188
+ data: Path,
189
+ task_id: str,
190
+ dry_run: bool = False,
191
+ ) -> int:
192
+ """Execute a task by id."""
193
+ tasks = Task.load_all(data / "tasks")
194
+ task = tasks.get(task_id)
195
+ if task is None:
196
+ available = ", ".join(sorted(tasks.keys()))
197
+ print(
198
+ f"Unknown task: {task_id!r}\n"
199
+ f" Add a task file: data/tasks/{task_id}.toml\n"
200
+ f" Available tasks: {available}"
201
+ )
202
+ return 1
203
+
204
+ repo_registry = RepoRegistry.from_toml(data / "repos.toml")
205
+ workspace = repo_registry.workspace_path()
206
+
207
+ source_path: Path | None = None
208
+ if task.source:
209
+ source_path = workspace / task.source.repo
210
+
211
+ entries = _resolve_selector(task, repo_registry)
212
+ if not entries:
213
+ print(f"Task {task_id!r}: no repos matched selector.")
214
+ return 0
215
+
216
+ label = "[dry-run] " if dry_run else ""
217
+ print(f"{label}Task: {task.label}")
218
+
219
+ total_changed = 0
220
+ total_errors = 0
221
+
222
+ for entry in entries:
223
+ target_path = workspace / entry.name
224
+ print(f"\n to {entry.name}")
225
+ for op_dict in task.operations:
226
+ result = _dispatch(
227
+ op_dict,
228
+ target_path=target_path,
229
+ source_path=source_path,
230
+ dry_run=dry_run,
231
+ )
232
+ icon = "✓" if result.ok else "✗"
233
+ changed = " (changed)" if result.changed else ""
234
+ msg = f": {result.message}" if result.message else ""
235
+ print(f" {icon} {op_dict.get('type', '?')}{changed}{msg}")
236
+ if result.changed:
237
+ total_changed += 1
238
+ if not result.ok:
239
+ total_errors += 1
240
+
241
+ print(f"\n{total_changed} change(s), {total_errors} error(s).")
242
+ return 1 if total_errors else 0
243
+
244
+
245
+ def _resolve_selector(task: Task, registry: RepoRegistry) -> list[RepoEntry]:
246
+ if task.selector is None:
247
+ return []
248
+ sel = task.selector
249
+ if isinstance(sel, RepoSetSelector):
250
+ entries: list[RepoEntry] = []
251
+ for set_name in sel.repo_sets:
252
+ entries.extend(registry.repos_in_set(set_name))
253
+ return entries
254
+ if isinstance(sel, RepoNameSelector):
255
+ return [registry.repos[n] for n in sel.repos if n in registry.repos]
256
+ if isinstance(sel, PatternSelector):
257
+ import re
258
+
259
+ pattern = re.compile(sel.pattern)
260
+ return [r for r in registry.repos.values() if pattern.search(r.name)]
261
+ return []
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Operation interpreter
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ def _dispatch(
270
+ op: dict,
271
+ *,
272
+ target_path: Path,
273
+ source_path: Path | None,
274
+ dry_run: bool,
275
+ ) -> ActionResult:
276
+ """Map a raw TOML operation dict to an action and execute it."""
277
+ op_type = op.get("type", "")
278
+
279
+ if dry_run:
280
+ return ActionResult.noop(f"would run {op_type}")
281
+
282
+ if op_type == "ensure_exact_files":
283
+ results = [
284
+ run_copy_file(
285
+ CopyFile(src=p, dest=p),
286
+ target_path=target_path,
287
+ source_path=source_path or target_path,
288
+ )
289
+ for p in op.get("paths", [])
290
+ ]
291
+ changed = any(r.changed for r in results)
292
+ errors = [r.message for r in results if not r.ok]
293
+ if errors:
294
+ return ActionResult.error("; ".join(e for e in errors if e))
295
+ return ActionResult(ok=True, changed=changed)
296
+
297
+ if op_type == "delete_files":
298
+ results = [
299
+ run_delete_file(DeleteFile(path=p), target_path=target_path)
300
+ for p in op.get("paths", [])
301
+ ]
302
+ changed = any(r.changed for r in results)
303
+ return ActionResult(ok=True, changed=changed)
304
+
305
+ if op_type == "add_dependency":
306
+ return run_patch_toml(
307
+ PatchToml(
308
+ file="pyproject.toml",
309
+ operation="add_dependency",
310
+ group=op.get("group"),
311
+ name=op.get("name"),
312
+ ),
313
+ target_path=target_path,
314
+ )
315
+
316
+ if op_type == "remove_dependency":
317
+ return run_patch_toml(
318
+ PatchToml(
319
+ file="pyproject.toml",
320
+ operation="remove_dependency",
321
+ group=op.get("group"),
322
+ name=op.get("name"),
323
+ ),
324
+ target_path=target_path,
325
+ )
326
+
327
+ if op_type == "ensure_workflow":
328
+ return run_ensure_workflow(
329
+ EnsureWorkflow(name=op["name"], src=op.get("src", op["name"])),
330
+ target_path=target_path,
331
+ source_path=source_path or target_path,
332
+ )
333
+
334
+ if op_type == "replace_workflow":
335
+ return run_replace_workflow(
336
+ ReplaceWorkflow(name=op["name"], src=op.get("src", op["name"])),
337
+ target_path=target_path,
338
+ source_path=source_path or target_path,
339
+ )
340
+
341
+ if op_type == "remove_workflow":
342
+ return run_remove_workflow(
343
+ RemoveWorkflow(name=op["name"]),
344
+ target_path=target_path,
345
+ )
346
+
347
+ if op_type == "git_pull":
348
+ from se_admin.actions.git_pull import git_pull
349
+
350
+ ok, message = git_pull(target_path)
351
+ return ActionResult(ok=ok, changed=ok, message=message or None)
352
+
353
+ if op_type == "merge_dependabot_prs":
354
+ from se_admin.actions.dependabot import (
355
+ list_dependabot_prs,
356
+ merge_dependabot_pr,
357
+ )
358
+
359
+ prs = list_dependabot_prs(target_path)
360
+ if not prs:
361
+ return ActionResult.noop("No open Dependabot PRs")
362
+ merged = 0
363
+ merge_errors: list[str] = []
364
+ for pr in prs:
365
+ ok, msg = merge_dependabot_pr(target_path, pr["number"])
366
+ if ok:
367
+ merged += 1
368
+ else:
369
+ merge_errors.append(f"#{pr['number']}: {msg}")
370
+ if merge_errors:
371
+ return ActionResult.error("; ".join(merge_errors))
372
+ return ActionResult.done(f"Merged {merged} Dependabot PR(s)")
373
+
374
+ return ActionResult.error(f"Unknown operation type: {op_type!r}")
@@ -0,0 +1,83 @@
1
+ """se_admin checks layer - pure comparisons, no side effects.
2
+
3
+ Each check reads actual state via observe/ and returns Finding objects.
4
+ Nothing in this layer writes to disk or calls external services
5
+ (except checks/tags.py which reads from the GitHub API).
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from se_admin.checks.exact_files import check_exact_files
11
+ from se_admin.checks.python_version import check_python_version
12
+ from se_admin.checks.reference_files import check_reference_files
13
+ from se_admin.checks.required_paths import check_required_paths
14
+ from se_admin.checks.tags import check_tags
15
+ from se_admin.checks.workflows import check_required_workflows
16
+ from se_admin.domain.findings import Finding
17
+ from se_admin.domain.profiles import Profile, ProfileRegistry
18
+ from se_admin.domain.repos import RepoEntry
19
+
20
+
21
+ def run_profile_checks(
22
+ repo: RepoEntry,
23
+ profile: Profile,
24
+ *,
25
+ workspace_path: Path,
26
+ source_path: Path | None = None,
27
+ profile_registry: ProfileRegistry | None = None,
28
+ ) -> list[Finding]:
29
+ """Run all checks implied by a profile against a repo.
30
+
31
+ Returns the combined list of Finding objects.
32
+ source_path is required only for exact_files checks.
33
+
34
+ Args:
35
+ repo: the repository to check
36
+ profile: the profile whose checks to run
37
+ *: keyword-only arguments after this point
38
+ workspace_path: the base path where repositories are located on disk
39
+ source_path: optional path to use as source for exact_files checks
40
+ profile_registry: optional profile registry for resolving extended profiles
41
+
42
+ Returns:
43
+ list of Finding objects for all checks that failed
44
+ """
45
+ repo_path = workspace_path / repo.name
46
+ findings: list[Finding] = []
47
+
48
+ # Expand extended profiles first
49
+ if profile.extends and profile_registry is not None:
50
+ for extended_id in profile.extends:
51
+ extended = profile_registry.get(extended_id)
52
+ if extended is None:
53
+ raise KeyError(
54
+ f"Profile {profile.id!r} extends unknown profile: {extended_id!r}"
55
+ )
56
+ findings += run_profile_checks(
57
+ repo,
58
+ extended,
59
+ workspace_path=workspace_path,
60
+ source_path=source_path,
61
+ profile_registry=profile_registry,
62
+ )
63
+
64
+ if profile.required_paths:
65
+ findings += check_required_paths(repo.name, repo_path, profile.required_paths)
66
+
67
+ if profile.required_workflows:
68
+ findings += check_required_workflows(
69
+ repo.name, repo_path, profile.required_workflows
70
+ )
71
+
72
+ return findings
73
+
74
+
75
+ __all__ = [
76
+ "check_exact_files",
77
+ "check_python_version",
78
+ "check_reference_files",
79
+ "check_required_paths",
80
+ "check_required_workflows",
81
+ "check_tags",
82
+ "run_profile_checks",
83
+ ]
@@ -0,0 +1,72 @@
1
+ """Check that files in a repo match the canonical source exactly."""
2
+
3
+ from pathlib import Path
4
+
5
+ from se_admin.domain.findings import Finding, FindingStatus
6
+
7
+
8
+ def check_exact_files(
9
+ repo: str,
10
+ repo_path: Path,
11
+ paths: list[str],
12
+ source_path: Path,
13
+ ) -> list[Finding]:
14
+ """Compare each path byte-for-byte against the canonical source.
15
+
16
+ Findings:
17
+ FAIL - file missing in target
18
+ FAIL - file present but differs from source
19
+ PASS - file matches source exactly
20
+ SKIP - source file missing (cannot compare)
21
+ """
22
+ findings: list[Finding] = []
23
+
24
+ for rel in paths:
25
+ src = source_path / rel
26
+ dest = repo_path / rel
27
+
28
+ if not src.exists():
29
+ findings.append(
30
+ Finding(
31
+ repo=repo,
32
+ check="exact_files",
33
+ status=FindingStatus.SKIP,
34
+ path=rel,
35
+ message=f"Source file not found, cannot compare: {rel}",
36
+ )
37
+ )
38
+ continue
39
+
40
+ if not dest.exists():
41
+ findings.append(
42
+ Finding(
43
+ repo=repo,
44
+ check="exact_files",
45
+ status=FindingStatus.FAIL,
46
+ path=rel,
47
+ message=f"File missing in target: {rel}",
48
+ )
49
+ )
50
+ continue
51
+
52
+ if dest.read_bytes() == src.read_bytes():
53
+ findings.append(
54
+ Finding(
55
+ repo=repo,
56
+ check="exact_files",
57
+ status=FindingStatus.PASS,
58
+ path=rel,
59
+ )
60
+ )
61
+ else:
62
+ findings.append(
63
+ Finding(
64
+ repo=repo,
65
+ check="exact_files",
66
+ status=FindingStatus.FAIL,
67
+ path=rel,
68
+ message=f"File differs from canonical source: {rel}",
69
+ )
70
+ )
71
+
72
+ return findings
@@ -0,0 +1,94 @@
1
+ """Check Python version alignment in a repo.
2
+
3
+ Two distinct checks:
4
+ python_version_file - .python-version pin matches expected
5
+ requires_python - pyproject.toml requires-python specifier present
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ from se_admin.domain.findings import Finding, FindingStatus
11
+ from se_admin.observe.pyproject import get_python_version_file, get_requires_python
12
+
13
+
14
+ def check_python_version(
15
+ repo: str,
16
+ repo_path: Path,
17
+ expected_version: str,
18
+ ) -> list[Finding]:
19
+ """Check .python-version pin equals expected_version (e.g. '3.12')."""
20
+ actual = get_python_version_file(repo_path)
21
+
22
+ if actual is None:
23
+ return [
24
+ Finding(
25
+ repo=repo,
26
+ check="python_version_file",
27
+ status=FindingStatus.FAIL,
28
+ path=".python-version",
29
+ message=".python-version file missing",
30
+ )
31
+ ]
32
+
33
+ if actual == expected_version:
34
+ return [
35
+ Finding(
36
+ repo=repo,
37
+ check="python_version_file",
38
+ status=FindingStatus.PASS,
39
+ path=".python-version",
40
+ )
41
+ ]
42
+
43
+ return [
44
+ Finding(
45
+ repo=repo,
46
+ check="python_version_file",
47
+ status=FindingStatus.FAIL,
48
+ path=".python-version",
49
+ message=f"Expected {expected_version!r}, found {actual!r}",
50
+ )
51
+ ]
52
+
53
+
54
+ def check_requires_python(
55
+ repo: str,
56
+ repo_path: Path,
57
+ expected_specifier: str | None = None,
58
+ ) -> list[Finding]:
59
+ """Check that requires-python is set in pyproject.toml.
60
+
61
+ If expected_specifier is given, also assert it matches exactly.
62
+ """
63
+ actual = get_requires_python(repo_path)
64
+
65
+ if actual is None:
66
+ return [
67
+ Finding(
68
+ repo=repo,
69
+ check="requires_python",
70
+ status=FindingStatus.FAIL,
71
+ path="pyproject.toml",
72
+ message="requires-python not set in [project]",
73
+ )
74
+ ]
75
+
76
+ if expected_specifier is not None and actual != expected_specifier:
77
+ return [
78
+ Finding(
79
+ repo=repo,
80
+ check="requires_python",
81
+ status=FindingStatus.FAIL,
82
+ path="pyproject.toml",
83
+ message=f"Expected requires-python={expected_specifier!r}, found {actual!r}",
84
+ )
85
+ ]
86
+
87
+ return [
88
+ Finding(
89
+ repo=repo,
90
+ check="requires_python",
91
+ status=FindingStatus.PASS,
92
+ path="pyproject.toml",
93
+ )
94
+ ]
@@ -0,0 +1,63 @@
1
+ """Check that a repo's reference/ directory exists and contains expected files."""
2
+
3
+ from pathlib import Path
4
+
5
+ from se_admin.domain.findings import Finding, FindingStatus
6
+ from se_admin.observe.filesystem import is_directory, path_exists
7
+
8
+ _REFERENCE_DIR = "reference"
9
+
10
+
11
+ def check_reference_files(
12
+ repo: str,
13
+ repo_path: Path,
14
+ expected_paths: list[str] | None = None,
15
+ ) -> list[Finding]:
16
+ """Check the reference/ directory and optionally specific files within it.
17
+
18
+ Without expected_paths: PASS if reference/ exists, FAIL if not.
19
+ With expected_paths: one Finding per path under reference/.
20
+ """
21
+ findings: list[Finding] = []
22
+
23
+ dir_present = is_directory(repo_path, _REFERENCE_DIR)
24
+
25
+ if not dir_present:
26
+ findings.append(
27
+ Finding(
28
+ repo=repo,
29
+ check="reference_files",
30
+ status=FindingStatus.FAIL,
31
+ path=_REFERENCE_DIR,
32
+ message="reference/ directory missing",
33
+ )
34
+ )
35
+ # No point checking contents if directory is absent
36
+ return findings
37
+
38
+ findings.append(
39
+ Finding(
40
+ repo=repo,
41
+ check="reference_files",
42
+ status=FindingStatus.PASS,
43
+ path=_REFERENCE_DIR,
44
+ )
45
+ )
46
+
47
+ if expected_paths:
48
+ for rel in expected_paths:
49
+ full_rel = f"{_REFERENCE_DIR}/{rel}"
50
+ exists = path_exists(repo_path, full_rel)
51
+ findings.append(
52
+ Finding(
53
+ repo=repo,
54
+ check="reference_files",
55
+ status=FindingStatus.PASS if exists else FindingStatus.FAIL,
56
+ path=full_rel,
57
+ message=None
58
+ if exists
59
+ else f"Expected reference file missing: {full_rel}",
60
+ )
61
+ )
62
+
63
+ return findings