claude-dev-env 2.36.0 → 4.0.0

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 (66) hide show
  1. package/.agents/skills/_shared/advisor/scripts/README.md +1 -1
  2. package/.agents/skills/team-advisor/SKILL.md +11 -11
  3. package/_shared/advisor/advisor-protocol.md +51 -51
  4. package/_shared/advisor/reference/advisor-block.md +9 -9
  5. package/_shared/advisor/reference/astra-rung.md +39 -0
  6. package/_shared/advisor/reference/identity.md +4 -4
  7. package/_shared/advisor/reference/lifecycle.md +6 -6
  8. package/_shared/advisor/reference/spawn-walk-log.md +9 -9
  9. package/_shared/advisor/reference/third-party-bind.md +11 -11
  10. package/_shared/advisor/reference/warm-up.md +13 -13
  11. package/_shared/advisor/scripts/codex_astra_advisor.py +308 -0
  12. package/_shared/advisor/scripts/codex_astra_preflight.py +115 -0
  13. package/_shared/advisor/scripts/codex_astra_reply.py +159 -0
  14. package/_shared/advisor/scripts/config/advisor_scripts_constants/advisor_route_constants.py +3 -3
  15. package/_shared/advisor/scripts/config/advisor_scripts_constants/astra_advisor_constants.py +38 -0
  16. package/_shared/advisor/scripts/model_tier_run_validator.py +17 -18
  17. package/_shared/advisor/scripts/tests/test_codex_astra_advisor.py +271 -0
  18. package/_shared/advisor/scripts/tests/test_codex_astra_preflight.py +24 -0
  19. package/_shared/advisor/scripts/tests/test_codex_astra_reply.py +42 -0
  20. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +55 -30
  21. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +14 -4
  22. package/_shared/pr-loop/scripts/pr_loop_shared_constants/terminology_sweep_constants.py +14 -0
  23. package/_shared/pr-loop/scripts/terminology_sweep.py +138 -50
  24. package/_shared/pr-loop/scripts/tests/test_terminology_sweep.py +13 -1
  25. package/docs/references/team-advisor-skill.md +3 -3
  26. package/hooks/validators/README.md +35 -1
  27. package/package.json +1 -1
  28. package/scripts/check.ps1 +18 -6
  29. package/scripts/policy_lint/adapter_detectors.py +42 -2
  30. package/scripts/policy_lint/adapters.py +24 -0
  31. package/scripts/policy_lint/config/constants.py +11 -0
  32. package/scripts/policy_lint/engine.py +1 -1
  33. package/scripts/policy_lint/model.py +1 -0
  34. package/scripts/policy_lint/registry.py +10 -1
  35. package/scripts/repository_checks/__init__.py +1 -0
  36. package/scripts/repository_checks/claude_md.py +108 -0
  37. package/scripts/repository_checks/config/__init__.py +1 -0
  38. package/scripts/repository_checks/config/constants.py +150 -0
  39. package/scripts/repository_checks/env_var_documentation.py +243 -0
  40. package/scripts/repository_checks/hook_modules.py +34 -0
  41. package/scripts/repository_checks/models.py +37 -0
  42. package/scripts/repository_checks/package_inventory.py +118 -0
  43. package/scripts/repository_checks/pytest_testpaths.py +84 -0
  44. package/scripts/repository_checks/reporting.py +39 -0
  45. package/scripts/repository_checks/runner.py +93 -0
  46. package/scripts/repository_checks/tracked_secrets.py +147 -0
  47. package/scripts/repository_checks/tracked_tree.py +28 -0
  48. package/scripts/repository_policy.py +120 -0
  49. package/scripts/tests/repository_policy_test_support.py +89 -0
  50. package/scripts/tests/test_cde_lint.py +25 -1
  51. package/scripts/tests/test_claude_md.py +68 -0
  52. package/scripts/tests/test_env_var_documentation.py +133 -0
  53. package/scripts/tests/test_hook_modules.py +17 -0
  54. package/scripts/tests/test_models.py +26 -0
  55. package/scripts/tests/test_package_inventory.py +96 -0
  56. package/scripts/tests/test_policy_lint_rules_registry.py +117 -0
  57. package/scripts/tests/test_pytest_testpaths.py +66 -0
  58. package/scripts/tests/test_reporting.py +54 -0
  59. package/scripts/tests/test_repository_policy.py +71 -0
  60. package/scripts/tests/test_runner.py +59 -0
  61. package/scripts/tests/test_tracked_secrets.py +175 -0
  62. package/scripts/tests/test_tracked_tree.py +28 -0
  63. package/_shared/advisor/reference/sol-rung.md +0 -39
  64. package/_shared/advisor/scripts/codex_sol_advisor.py +0 -581
  65. package/_shared/advisor/scripts/config/advisor_scripts_constants/sol_advisor_constants.py +0 -38
  66. package/_shared/advisor/scripts/tests/test_codex_sol_advisor.py +0 -740
@@ -0,0 +1,118 @@
1
+ """Package inventory checks for committed trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ from policy_lint.config.constants import PATH_SEPARATOR, UTF8_ENCODING
10
+
11
+ from repository_checks.config.constants import (
12
+ ALL_ARCHIVED_SKILL_DIRECTORY_SEGMENTS,
13
+ CHECK_ID_PACKAGE_INVENTORY,
14
+ PACKAGE_INVENTORY_CONSTANTS_MODULE_NAME,
15
+ PACKAGE_INVENTORY_DETECTION_MODULE_NAME,
16
+ PACKAGE_INVENTORY_MESSAGE_TEMPLATE,
17
+ WINDOWS_PATH_SEPARATOR,
18
+ )
19
+ from repository_checks.hook_modules import load_hooks_module
20
+ from repository_checks.models import RepositoryFinding
21
+
22
+
23
+ def collect_package_inventory_findings(
24
+ repository_root: Path, all_tracked_paths: Sequence[str]
25
+ ) -> list[RepositoryFinding]:
26
+ """Return tracked production files missing from package inventories.
27
+
28
+ Args:
29
+ repository_root: Git repository root.
30
+ all_tracked_paths: Repository-relative tracked paths.
31
+
32
+ Returns:
33
+ Findings for production files absent from their inventories.
34
+ """
35
+ detection = load_hooks_module(PACKAGE_INVENTORY_DETECTION_MODULE_NAME)
36
+ inventory_constants = load_hooks_module(PACKAGE_INVENTORY_CONSTANTS_MODULE_NAME)
37
+ all_findings: list[RepositoryFinding] = []
38
+ for each_relative_path in all_tracked_paths:
39
+ maybe_finding = find_inventory_finding_for_path(
40
+ repository_root,
41
+ each_relative_path,
42
+ detection,
43
+ inventory_constants,
44
+ )
45
+ if maybe_finding is not None:
46
+ all_findings.append(maybe_finding)
47
+ return all_findings
48
+
49
+
50
+ def find_inventory_finding_for_path(
51
+ repository_root: Path,
52
+ relative_path: str,
53
+ detection: ModuleType,
54
+ inventory_constants: ModuleType,
55
+ ) -> RepositoryFinding | None:
56
+ """Return an inventory finding for one tracked production file.
57
+
58
+ Args:
59
+ repository_root: Git repository root.
60
+ relative_path: Repository-relative production path.
61
+ detection: Existing inventory detector module.
62
+ inventory_constants: Existing detector constants module.
63
+
64
+ Returns:
65
+ The finding when the file is missing from its inventory.
66
+ """
67
+ if _is_archived_skill_path(relative_path):
68
+ return None
69
+ absolute_path = repository_root / relative_path
70
+ if not absolute_path.is_file():
71
+ return None
72
+ if not detection.is_inventoried_production_file(str(absolute_path)):
73
+ return None
74
+ _require_readable_inventory_documents(absolute_path.parent, inventory_constants)
75
+ if detection.find_stale_inventory(str(absolute_path)) is None:
76
+ return None
77
+ return _build_finding(relative_path)
78
+
79
+
80
+ def _build_finding(relative_path: str) -> RepositoryFinding:
81
+ return RepositoryFinding(
82
+ CHECK_ID_PACKAGE_INVENTORY,
83
+ relative_path.replace(WINDOWS_PATH_SEPARATOR, PATH_SEPARATOR),
84
+ PACKAGE_INVENTORY_MESSAGE_TEMPLATE,
85
+ )
86
+
87
+
88
+ def _is_archived_skill_path(relative_path: str) -> bool:
89
+ all_path_segments = relative_path.replace(
90
+ WINDOWS_PATH_SEPARATOR, PATH_SEPARATOR
91
+ ).split(PATH_SEPARATOR)
92
+ segment_count = len(ALL_ARCHIVED_SKILL_DIRECTORY_SEGMENTS)
93
+ last_start_index = len(all_path_segments) - segment_count
94
+ for each_start_index in range(max(last_start_index + 1, 0)):
95
+ compared_segments = tuple(
96
+ all_path_segments[each_start_index : each_start_index + segment_count]
97
+ )
98
+ if compared_segments == ALL_ARCHIVED_SKILL_DIRECTORY_SEGMENTS:
99
+ return True
100
+ return False
101
+
102
+
103
+ def _require_readable_inventory_documents(
104
+ package_directory: Path, inventory_constants: ModuleType
105
+ ) -> None:
106
+ for each_inventory_name in inventory_constants.ALL_INVENTORY_DOCUMENT_NAMES:
107
+ inventory_path = package_directory / each_inventory_name
108
+ if not inventory_path.is_file():
109
+ continue
110
+ inventory_path.read_text(encoding=UTF8_ENCODING)
111
+ if package_directory.name != inventory_constants.SCRIPTS_SUBDIRECTORY_NAME:
112
+ return
113
+ skill_inventory_path = (
114
+ package_directory.parent / inventory_constants.SKILL_INVENTORY_DOCUMENT_NAME
115
+ )
116
+ if not skill_inventory_path.is_file():
117
+ return
118
+ skill_inventory_path.read_text(encoding=UTF8_ENCODING)
@@ -0,0 +1,84 @@
1
+ """Pytest testpath checks for committed trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ import tomllib
10
+ from policy_lint.config import constants as policy_constants
11
+
12
+ from repository_checks.config import constants as repository_constants
13
+ from repository_checks.hook_modules import load_hooks_module
14
+ from repository_checks.models import RepositoryFinding
15
+
16
+
17
+ def collect_pytest_testpath_findings(
18
+ repository_root: Path, all_tracked_paths: Sequence[str]
19
+ ) -> list[RepositoryFinding]:
20
+ """Return tracked tests outside explicit package testpaths.
21
+
22
+ Args:
23
+ repository_root: Git repository root.
24
+ all_tracked_paths: Repository-relative tracked paths.
25
+
26
+ Returns:
27
+ Findings for tests excluded by their package testpaths.
28
+ """
29
+ pytest_module = load_hooks_module(repository_constants.PYTEST_TESTPATHS_MODULE_NAME)
30
+ all_findings: list[RepositoryFinding] = []
31
+ for each_relative_path in all_tracked_paths:
32
+ maybe_finding = _find_testpath_finding(
33
+ repository_root, each_relative_path, pytest_module
34
+ )
35
+ if maybe_finding is not None:
36
+ all_findings.append(maybe_finding)
37
+ return all_findings
38
+
39
+
40
+ def _find_testpath_finding(
41
+ repository_root: Path,
42
+ relative_path: str,
43
+ pytest_module: ModuleType,
44
+ ) -> RepositoryFinding | None:
45
+ if not pytest_module.is_test_file(relative_path):
46
+ return None
47
+ absolute_path = repository_root / relative_path
48
+ if not absolute_path.is_file():
49
+ return None
50
+ _require_readable_inputs(absolute_path, repository_root)
51
+ if pytest_module.find_unregistered_test_directory(str(absolute_path)) is None:
52
+ return None
53
+ return RepositoryFinding(
54
+ repository_constants.CHECK_ID_PYTEST_TESTPATHS,
55
+ _to_posix_path(relative_path),
56
+ repository_constants.PYTEST_TESTPATH_MESSAGE_TEMPLATE,
57
+ )
58
+
59
+
60
+ def _to_posix_path(relative_path: str) -> str:
61
+ return relative_path.replace(
62
+ repository_constants.WINDOWS_PATH_SEPARATOR,
63
+ policy_constants.PATH_SEPARATOR,
64
+ )
65
+
66
+
67
+ def _require_readable_inputs(test_file: Path, repository_root: Path) -> None:
68
+ test_file.read_bytes()
69
+ resolved_repository_root = repository_root.resolve()
70
+ for each_directory in test_file.parents:
71
+ if not _is_inside_repository(each_directory, resolved_repository_root):
72
+ break
73
+ pyproject_path = each_directory / repository_constants.PYPROJECT_FILENAME
74
+ if not pyproject_path.is_file():
75
+ continue
76
+ tomllib.loads(pyproject_path.read_text(encoding=policy_constants.UTF8_ENCODING))
77
+
78
+
79
+ def _is_inside_repository(directory: Path, resolved_repository_root: Path) -> bool:
80
+ try:
81
+ directory.resolve().relative_to(resolved_repository_root)
82
+ except ValueError:
83
+ return False
84
+ return True
@@ -0,0 +1,39 @@
1
+ """Stable text reports for committed-tree repository checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from repository_checks.config import constants as repository_constants
6
+ from repository_checks.models import RepositoryCheckReport
7
+
8
+
9
+ def render_report(report: RepositoryCheckReport) -> str:
10
+ """Render findings and failed checks as newline-terminated text.
11
+
12
+ Args:
13
+ report: Findings and failed check identifiers.
14
+
15
+ Returns:
16
+ The report body, or an empty string when the tree is clean.
17
+ """
18
+ all_lines = [
19
+ repository_constants.FINDING_LINE_TEMPLATE.format(
20
+ check_id=each_finding.check_id,
21
+ relative_path=each_finding.relative_path,
22
+ message=each_finding.message,
23
+ )
24
+ for each_finding in report.all_findings
25
+ ]
26
+ all_lines.extend(_failed_check_lines(report))
27
+ if not all_lines:
28
+ return repository_constants.EMPTY_REPORT_TEXT
29
+ return (
30
+ repository_constants.REPORT_LINE_SEPARATOR.join(all_lines)
31
+ + repository_constants.REPORT_LINE_SEPARATOR
32
+ )
33
+
34
+
35
+ def _failed_check_lines(report: RepositoryCheckReport) -> list[str]:
36
+ return [
37
+ repository_constants.RULE_FAILED_LINE_TEMPLATE.format(check_id=each_check_id)
38
+ for each_check_id in report.all_failed_check_ids
39
+ ]
@@ -0,0 +1,93 @@
1
+ """Fail-closed runner for committed-tree repository checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from pathlib import Path
7
+
8
+ from repository_checks import tracked_tree
9
+ from repository_checks.claude_md import collect_claude_md_orphan_findings
10
+ from repository_checks.config import constants as repository_constants
11
+ from repository_checks.env_var_documentation import (
12
+ collect_env_var_documentation_findings,
13
+ )
14
+ from repository_checks.models import RepositoryCheckReport, RepositoryFinding
15
+ from repository_checks.package_inventory import collect_package_inventory_findings
16
+ from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
17
+ from repository_checks.tracked_secrets import collect_tracked_secret_findings
18
+
19
+ RepositoryCollector = Callable[[Path, Sequence[str]], list[RepositoryFinding]]
20
+
21
+
22
+ def run_repository_checks(repository_root: Path) -> RepositoryCheckReport:
23
+ """Run every committed-tree check and fail closed on collector errors.
24
+
25
+ Args:
26
+ repository_root: Git repository root whose tracked files are scanned.
27
+
28
+ Returns:
29
+ Sorted findings and any check identifiers that failed closed.
30
+ """
31
+ try:
32
+ all_tracked_paths = tracked_tree.tracked_relative_paths(repository_root)
33
+ except repository_constants.ALL_FAIL_CLOSED_EXCEPTION_TYPES:
34
+ return RepositoryCheckReport((), repository_constants.ALL_CHECK_IDS)
35
+ all_findings, all_failed_check_ids = _run_collectors(
36
+ repository_root, all_tracked_paths
37
+ )
38
+ return RepositoryCheckReport(
39
+ _sort_findings(all_findings), tuple(all_failed_check_ids)
40
+ )
41
+
42
+
43
+ def _run_collectors(
44
+ repository_root: Path, all_tracked_paths: Sequence[str]
45
+ ) -> tuple[list[RepositoryFinding], list[str]]:
46
+ all_findings: list[RepositoryFinding] = []
47
+ all_failed_check_ids: list[str] = []
48
+ for each_check_id, each_collector in _all_collectors():
49
+ try:
50
+ all_findings.extend(each_collector(repository_root, all_tracked_paths))
51
+ except repository_constants.ALL_FAIL_CLOSED_EXCEPTION_TYPES:
52
+ all_failed_check_ids.append(each_check_id)
53
+ return all_findings, all_failed_check_ids
54
+
55
+
56
+ def _sort_findings(
57
+ all_findings: list[RepositoryFinding],
58
+ ) -> tuple[RepositoryFinding, ...]:
59
+ return tuple(
60
+ sorted(
61
+ all_findings,
62
+ key=lambda each_finding: (
63
+ each_finding.check_id,
64
+ each_finding.relative_path,
65
+ each_finding.message,
66
+ ),
67
+ )
68
+ )
69
+
70
+
71
+ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
72
+ return (
73
+ (
74
+ repository_constants.CHECK_ID_CLAUDE_MD_ORPHANS,
75
+ collect_claude_md_orphan_findings,
76
+ ),
77
+ (
78
+ repository_constants.CHECK_ID_ENV_VAR_DOCUMENTATION,
79
+ collect_env_var_documentation_findings,
80
+ ),
81
+ (
82
+ repository_constants.CHECK_ID_PACKAGE_INVENTORY,
83
+ collect_package_inventory_findings,
84
+ ),
85
+ (
86
+ repository_constants.CHECK_ID_PYTEST_TESTPATHS,
87
+ collect_pytest_testpath_findings,
88
+ ),
89
+ (
90
+ repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
91
+ collect_tracked_secret_findings,
92
+ ),
93
+ )
@@ -0,0 +1,147 @@
1
+ """Tracked-secret checks for committed trees."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+
10
+ from policy_lint.config import constants as policy_constants
11
+
12
+ from repository_checks.config import constants as repository_constants
13
+ from repository_checks.hook_modules import load_hooks_module
14
+ from repository_checks.models import RepositoryFinding
15
+
16
+
17
+ def collect_tracked_secret_findings(
18
+ repository_root: Path, all_tracked_paths: Sequence[str]
19
+ ) -> list[RepositoryFinding]:
20
+ """Return non-exempt secrets found in tracked UTF-8 files.
21
+
22
+ Args:
23
+ repository_root: Git repository root.
24
+ all_tracked_paths: Repository-relative tracked paths.
25
+
26
+ Returns:
27
+ Findings with redacted previews for non-exempt secrets.
28
+ """
29
+ scanner = load_hooks_module(repository_constants.PII_SCANNER_MODULE_NAME)
30
+ exemption = load_hooks_module(repository_constants.REPOSITORY_EXEMPTION_MODULE_NAME)
31
+ all_allowlisted_literals = exemption.repository_allowlisted_values(repository_root)
32
+ all_findings: list[RepositoryFinding] = []
33
+ for each_relative_path in all_tracked_paths:
34
+ all_findings.extend(
35
+ _find_secrets_for_path(
36
+ repository_root,
37
+ each_relative_path,
38
+ scanner,
39
+ all_allowlisted_literals,
40
+ )
41
+ )
42
+ return all_findings
43
+
44
+
45
+ def _find_secrets_for_path(
46
+ repository_root: Path,
47
+ relative_path: str,
48
+ scanner: ModuleType,
49
+ all_allowlisted_literals: frozenset[str],
50
+ ) -> list[RepositoryFinding]:
51
+ posix_relative_path = relative_path.replace(
52
+ repository_constants.WINDOWS_PATH_SEPARATOR,
53
+ policy_constants.PATH_SEPARATOR,
54
+ )
55
+ if scanner.is_path_exempt_from_pii_scan(posix_relative_path):
56
+ return []
57
+ maybe_content = _read_utf8_text(
58
+ repository_root,
59
+ repository_root / relative_path,
60
+ )
61
+ if maybe_content is None:
62
+ return []
63
+ return _find_secret_matches(
64
+ posix_relative_path,
65
+ maybe_content,
66
+ scanner,
67
+ all_allowlisted_literals,
68
+ )
69
+
70
+
71
+ def _find_secret_matches(
72
+ posix_relative_path: str,
73
+ content: str,
74
+ scanner: ModuleType,
75
+ all_allowlisted_literals: frozenset[str],
76
+ ) -> list[RepositoryFinding]:
77
+ return [
78
+ _build_finding(
79
+ posix_relative_path,
80
+ category=each_match.category,
81
+ preview=each_match.preview,
82
+ )
83
+ for each_match in scanner.scan_text_for_pii(content)
84
+ if _should_report_match(
85
+ posix_relative_path,
86
+ each_match.category,
87
+ each_match.matched_text,
88
+ all_allowlisted_literals,
89
+ )
90
+ ]
91
+
92
+
93
+ def _read_utf8_text(repository_root: Path, absolute_path: Path) -> str | None:
94
+ if not absolute_path.is_file():
95
+ return None
96
+ _require_path_inside_repository(repository_root, absolute_path)
97
+ try:
98
+ return absolute_path.read_text(encoding=policy_constants.UTF8_ENCODING)
99
+ except UnicodeDecodeError:
100
+ return None
101
+
102
+
103
+ def _require_path_inside_repository(repository_root: Path, absolute_path: Path) -> None:
104
+ resolved_repository_root = repository_root.resolve(strict=True)
105
+ resolved_absolute_path = absolute_path.resolve(strict=True)
106
+ try:
107
+ resolved_absolute_path.relative_to(resolved_repository_root)
108
+ except ValueError as e:
109
+ raise OSError(absolute_path) from e
110
+
111
+
112
+ def _build_finding(
113
+ posix_relative_path: str, category: str, preview: str
114
+ ) -> RepositoryFinding:
115
+ return RepositoryFinding(
116
+ repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
117
+ posix_relative_path,
118
+ repository_constants.TRACKED_MATCH_MESSAGE_TEMPLATE.format(
119
+ category=category,
120
+ preview=preview,
121
+ ),
122
+ )
123
+
124
+
125
+ def _should_report_match(
126
+ posix_relative_path: str,
127
+ category: str,
128
+ matched_text: str,
129
+ all_allowlisted_literals: frozenset[str],
130
+ ) -> bool:
131
+ exact_exemption_identity = (
132
+ posix_relative_path,
133
+ category,
134
+ _secret_digest(matched_text),
135
+ )
136
+ if (
137
+ exact_exemption_identity
138
+ in repository_constants.ALL_TRACKED_SECRET_EXACT_EXEMPTIONS
139
+ ):
140
+ return False
141
+ return matched_text not in all_allowlisted_literals
142
+
143
+
144
+ def _secret_digest(matched_text: str) -> str:
145
+ return hashlib.sha256(
146
+ matched_text.encode(policy_constants.UTF8_ENCODING)
147
+ ).hexdigest()
@@ -0,0 +1,28 @@
1
+ """List tracked worktree paths for the committed-tree checker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from policy_lint.selection_git import git_bytes_for, split_nul_tokens
8
+
9
+ from repository_checks.config.constants import ALL_GIT_LS_FILES_ARGUMENTS
10
+
11
+
12
+ def tracked_relative_paths(repository_root: Path) -> tuple[str, ...]:
13
+ """Return tracked paths as repository-relative posix strings.
14
+
15
+ ::
16
+
17
+ git ls-files -z -> ("notes/CLAUDE.md", "src/app.py")
18
+ ok: paths are relative
19
+ flag: an absolute Windows path
20
+
21
+ Args:
22
+ repository_root: Git repository root.
23
+
24
+ Returns:
25
+ Tracked relative paths in Git order.
26
+ """
27
+ raw_paths = git_bytes_for(repository_root, ALL_GIT_LS_FILES_ARGUMENTS)
28
+ return split_nul_tokens(raw_paths)
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Public command for the committed-tree repository checker."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+ from typing import Never, TextIO
11
+
12
+ from policy_lint.config.constants import ALL_GIT_ROOT_ARGUMENTS, UTF8_ENCODING
13
+ from policy_lint.selection_git import GitSelectionError, git_bytes_for
14
+ from repository_checks.config.constants import (
15
+ ALL_CHECK_IDS,
16
+ REPOSITORY_ROOT_FLAG,
17
+ USAGE_EXIT_CODE,
18
+ )
19
+ from repository_checks.reporting import render_report
20
+ from repository_checks.runner import run_repository_checks
21
+
22
+ __all__ = ["ALL_CHECK_IDS", "main"]
23
+
24
+
25
+ class _RepositoryPolicyUsageError(ValueError):
26
+ """Raised when command arguments cannot form a repository scan."""
27
+
28
+
29
+ class _RepositoryPolicyArgumentParser(argparse.ArgumentParser):
30
+ def error(self, message: str) -> Never:
31
+ """Raise a usage error for the caller.
32
+
33
+ Args:
34
+ message: Parser error text.
35
+
36
+ Raises:
37
+ _RepositoryPolicyUsageError: Always.
38
+ """
39
+ raise _RepositoryPolicyUsageError(message)
40
+
41
+
42
+ def _discover_repository_root(starting_directory: Path) -> Path:
43
+ """Find the Git root that contains the command's current directory.
44
+
45
+ Args:
46
+ starting_directory: Directory where the command started.
47
+
48
+ Returns:
49
+ The resolved Git repository root.
50
+
51
+ Raises:
52
+ _RepositoryPolicyUsageError: If the directory is not inside a Git repository.
53
+ """
54
+ try:
55
+ root_bytes = git_bytes_for(starting_directory.resolve(), ALL_GIT_ROOT_ARGUMENTS)
56
+ except (GitSelectionError, OSError) as error:
57
+ raise _RepositoryPolicyUsageError(
58
+ "Current directory is not inside a Git repository"
59
+ ) from error
60
+ root_text = root_bytes.decode(UTF8_ENCODING).strip()
61
+ if not root_text:
62
+ raise _RepositoryPolicyUsageError("Git returned an empty repository root")
63
+ return Path(root_text).resolve()
64
+
65
+
66
+ def _build_parser() -> argparse.ArgumentParser:
67
+ parser = _RepositoryPolicyArgumentParser(prog="repository_policy")
68
+ parser.add_argument(
69
+ REPOSITORY_ROOT_FLAG,
70
+ dest="repository_root",
71
+ default=None,
72
+ help="Git repository root to scan.",
73
+ )
74
+ return parser
75
+
76
+
77
+ def main(
78
+ all_arguments: Sequence[str],
79
+ *,
80
+ repository_root: Path,
81
+ stdout: TextIO = sys.stdout,
82
+ stderr: TextIO = sys.stderr,
83
+ ) -> int:
84
+ """Run committed-tree repository checks.
85
+
86
+ Args:
87
+ all_arguments: Command arguments.
88
+ repository_root: Repository root used when the flag is omitted.
89
+ stdout: Output stream.
90
+ stderr: Error stream.
91
+
92
+ Returns:
93
+ Zero when the tree is clean, one for findings, two for usage errors,
94
+ and three when a check fails closed.
95
+ """
96
+ try:
97
+ namespace = _build_parser().parse_args(list(all_arguments))
98
+ except _RepositoryPolicyUsageError as usage_error:
99
+ stderr.write(f"{usage_error}\n")
100
+ return USAGE_EXIT_CODE
101
+ selected_root = repository_root
102
+ if namespace.repository_root is not None:
103
+ selected_root = Path(namespace.repository_root).resolve()
104
+ report = run_repository_checks(selected_root)
105
+ stdout.write(render_report(report))
106
+ return report.exit_code
107
+
108
+
109
+ if __name__ == "__main__":
110
+ try:
111
+ shell_directory = Path.cwd()
112
+ parsed_namespace = _build_parser().parse_args(sys.argv[1:])
113
+ if parsed_namespace.repository_root is not None:
114
+ shell_repository_root = Path(parsed_namespace.repository_root).resolve()
115
+ else:
116
+ shell_repository_root = _discover_repository_root(shell_directory)
117
+ except _RepositoryPolicyUsageError as usage_error:
118
+ sys.stderr.write(f"{usage_error}\n")
119
+ raise SystemExit(USAGE_EXIT_CODE) from usage_error
120
+ raise SystemExit(main(sys.argv[1:], repository_root=shell_repository_root))