openworkproof 1.0.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 (39) hide show
  1. openworkproof/__init__.py +1 -0
  2. openworkproof/acceptance.py +3006 -0
  3. openworkproof/cli.py +164 -0
  4. openworkproof/composition.py +832 -0
  5. openworkproof/evidence.py +8281 -0
  6. openworkproof/execution_adapter.py +256 -0
  7. openworkproof/external_acceptor.py +211 -0
  8. openworkproof/mcp_server.py +2939 -0
  9. openworkproof/mcp_transport.py +72 -0
  10. openworkproof/models.py +3380 -0
  11. openworkproof/policy.py +2494 -0
  12. openworkproof/predicates.py +333 -0
  13. openworkproof/repo_pipeline/__init__.py +74 -0
  14. openworkproof/repo_pipeline/analysis.py +152 -0
  15. openworkproof/repo_pipeline/errors.py +64 -0
  16. openworkproof/repo_pipeline/models.py +80 -0
  17. openworkproof/repo_pipeline/output.py +68 -0
  18. openworkproof/repo_pipeline/reader.py +47 -0
  19. openworkproof/repo_pipeline/sources.py +120 -0
  20. openworkproof/repo_pipeline/traversal.py +253 -0
  21. openworkproof/repo_tools.py +8382 -0
  22. openworkproof/runtime_context.py +189 -0
  23. openworkproof/schema_registry.py +623 -0
  24. openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
  25. openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
  26. openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
  27. openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
  28. openworkproof/schemas/v0.1/schema-registry.json +1 -0
  29. openworkproof/schemas/v0.1/work-order.schema.json +1 -0
  30. openworkproof/signing.py +421 -0
  31. openworkproof/state.py +765 -0
  32. openworkproof/team_network_client.py +419 -0
  33. openworkproof/trusted_helper.py +269 -0
  34. openworkproof-1.0.0.dist-info/METADATA +578 -0
  35. openworkproof-1.0.0.dist-info/RECORD +39 -0
  36. openworkproof-1.0.0.dist-info/WHEEL +5 -0
  37. openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
  38. openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
  39. openworkproof-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,333 @@
1
+ """Finite, fail-closed predicate evaluation for protocol v0.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass, field
8
+ from types import MappingProxyType
9
+ from typing import Any, Literal
10
+
11
+ from openworkproof.models import (
12
+ ArtifactDigestPredicateInput,
13
+ HumanSignaturePredicateInput,
14
+ PathAllowedPredicateInput,
15
+ PredicateInput,
16
+ PredicateResult,
17
+ PredicateSpec,
18
+ QuotaRemainingPredicateInput,
19
+ TestsPassedPredicateInput,
20
+ ToolAllowedPredicateInput,
21
+ WorkOrder,
22
+ _jcs_digest,
23
+ )
24
+
25
+
26
+ EvaluationOutcome = Literal["pass", "false", "fail_closed"]
27
+
28
+
29
+ def _immutable_snapshot(value: Any) -> Any:
30
+ if isinstance(value, Mapping):
31
+ return MappingProxyType(
32
+ {key: _immutable_snapshot(item) for key, item in value.items()}
33
+ )
34
+ if isinstance(value, (list, tuple)):
35
+ return tuple(_immutable_snapshot(item) for item in value)
36
+ if isinstance(value, (set, frozenset)):
37
+ return frozenset(_immutable_snapshot(item) for item in value)
38
+ return deepcopy(value)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class EvaluationContext:
43
+ """Trusted runtime facts and closed actual inputs for one evaluation step."""
44
+
45
+ inputs: Mapping[str, Any] = field(default_factory=dict)
46
+ authoritative_inputs: Mapping[str, Any] = field(default_factory=dict)
47
+ authoritative_ledger_prefix_digests: Mapping[str, str] = field(
48
+ default_factory=dict
49
+ )
50
+ verified_human_claim_digests: frozenset[str] = frozenset()
51
+
52
+ def __post_init__(self) -> None:
53
+ object.__setattr__(self, "inputs", _immutable_snapshot(self.inputs))
54
+ object.__setattr__(
55
+ self,
56
+ "authoritative_inputs",
57
+ _immutable_snapshot(self.authoritative_inputs),
58
+ )
59
+ object.__setattr__(
60
+ self,
61
+ "authoritative_ledger_prefix_digests",
62
+ _immutable_snapshot(self.authoritative_ledger_prefix_digests),
63
+ )
64
+ object.__setattr__(
65
+ self,
66
+ "verified_human_claim_digests",
67
+ frozenset(self.verified_human_claim_digests),
68
+ )
69
+
70
+ @classmethod
71
+ def empty(cls) -> EvaluationContext:
72
+ return cls()
73
+
74
+
75
+ def _is_within(path: str, roots: Sequence[str]) -> bool:
76
+ return any(path == root or path.startswith(f"{root}/") for root in roots)
77
+
78
+
79
+ def evaluate_path_allowed(
80
+ spec: PredicateSpec,
81
+ value: PredicateInput,
82
+ context: EvaluationContext,
83
+ ) -> EvaluationOutcome:
84
+ del context
85
+ assert isinstance(value, PathAllowedPredicateInput)
86
+ roots = tuple(spec.arguments["allowed_roots"])
87
+ if value.resolution_manifest_digest is None:
88
+ return "false"
89
+ if any(entry.resolved_relative_path is None for entry in value.resolved_entries):
90
+ return "fail_closed"
91
+ if not all(_is_within(path, roots) for path in value.requested_paths):
92
+ return "false"
93
+ if not all(
94
+ _is_within(entry.resolved_relative_path, roots)
95
+ for entry in value.resolved_entries
96
+ if entry.resolved_relative_path is not None
97
+ ):
98
+ return "false"
99
+ return "pass"
100
+
101
+
102
+ def evaluate_tool_allowed(
103
+ spec: PredicateSpec,
104
+ value: PredicateInput,
105
+ context: EvaluationContext,
106
+ ) -> EvaluationOutcome:
107
+ del context
108
+ assert isinstance(value, ToolAllowedPredicateInput)
109
+ actual = value.actual_tool_name
110
+ return (
111
+ "pass"
112
+ if actual == spec.arguments["tool_name"]
113
+ and actual in spec.arguments["allowed_tools"]
114
+ else "false"
115
+ )
116
+
117
+
118
+ def evaluate_quota_remaining(
119
+ spec: PredicateSpec,
120
+ value: PredicateInput,
121
+ context: EvaluationContext,
122
+ ) -> EvaluationOutcome:
123
+ assert isinstance(value, QuotaRemainingPredicateInput)
124
+ authoritative = context.authoritative_ledger_prefix_digests.get(value.grant_id)
125
+ if authoritative is None or authoritative != value.ledger_prefix_digest:
126
+ return "fail_closed"
127
+ if (
128
+ value.metric != spec.arguments["metric"]
129
+ or value.amount != spec.arguments["amount"]
130
+ ):
131
+ return "false"
132
+ return (
133
+ "pass" if value.grant_remaining_before >= value.amount else "false"
134
+ )
135
+
136
+
137
+ def evaluate_tests_passed(
138
+ spec: PredicateSpec,
139
+ value: PredicateInput,
140
+ context: EvaluationContext,
141
+ ) -> EvaluationOutcome:
142
+ del context
143
+ assert isinstance(value, TestsPassedPredicateInput)
144
+ if value.actual_exit_code is None or value.test_evidence_digest is None:
145
+ return "fail_closed"
146
+ expected = spec.arguments
147
+ if (
148
+ value.test_mode != expected["test_mode"]
149
+ or value.command_digest != expected["command_digest"]
150
+ or value.expected_exit_code != expected["expected_exit_code"]
151
+ or value.fixed_test_source_digest
152
+ != expected["fixed_test_source_digest"]
153
+ ):
154
+ return "false"
155
+ return (
156
+ "pass"
157
+ if value.actual_exit_code == value.expected_exit_code
158
+ else "false"
159
+ )
160
+
161
+
162
+ def evaluate_artifact_digest_matches(
163
+ spec: PredicateSpec,
164
+ value: PredicateInput,
165
+ context: EvaluationContext,
166
+ ) -> EvaluationOutcome:
167
+ del context
168
+ assert isinstance(value, ArtifactDigestPredicateInput)
169
+ if (
170
+ value.actual_digest is None
171
+ or value.size_bytes is None
172
+ or value.workspace_manifest_digest is None
173
+ ):
174
+ return "fail_closed"
175
+ if (
176
+ value.artifact_path != spec.arguments["artifact_path"]
177
+ or value.expected_digest != spec.arguments["expected_digest"]
178
+ ):
179
+ return "false"
180
+ return (
181
+ "pass" if value.actual_digest == value.expected_digest else "false"
182
+ )
183
+
184
+
185
+ def evaluate_human_signature_present(
186
+ spec: PredicateSpec,
187
+ value: PredicateInput,
188
+ context: EvaluationContext,
189
+ ) -> EvaluationOutcome:
190
+ assert isinstance(value, HumanSignaturePredicateInput)
191
+ if value.claim_digest not in context.verified_human_claim_digests:
192
+ return "fail_closed"
193
+ if value.required_role != spec.arguments["required_role"]:
194
+ return "false"
195
+ if value.expires_at is not None and value.validated_at > value.expires_at:
196
+ return "false"
197
+ return "pass"
198
+
199
+
200
+ PREDICATE_EVALUATORS = {
201
+ "path_allowed": evaluate_path_allowed,
202
+ "tool_allowed": evaluate_tool_allowed,
203
+ "quota_remaining": evaluate_quota_remaining,
204
+ "tests_passed": evaluate_tests_passed,
205
+ "artifact_digest_matches": evaluate_artifact_digest_matches,
206
+ "human_signature_present": evaluate_human_signature_present,
207
+ }
208
+
209
+ _INPUT_MODELS = {
210
+ "path_allowed": PathAllowedPredicateInput,
211
+ "tool_allowed": ToolAllowedPredicateInput,
212
+ "quota_remaining": QuotaRemainingPredicateInput,
213
+ "tests_passed": TestsPassedPredicateInput,
214
+ "artifact_digest_matches": ArtifactDigestPredicateInput,
215
+ "human_signature_present": HumanSignaturePredicateInput,
216
+ }
217
+
218
+
219
+ def predicate_input_digest(predicate_id: str, value: PredicateInput) -> str:
220
+ return _jcs_digest(
221
+ {
222
+ "domain": "openworkproof/predicate-input/v0.1",
223
+ "predicate_id": predicate_id,
224
+ "input": value,
225
+ }
226
+ )
227
+
228
+
229
+ def evaluate_predicate(
230
+ spec: PredicateSpec,
231
+ context: EvaluationContext,
232
+ ) -> PredicateResult:
233
+ spec = PredicateSpec.model_validate(spec.model_dump(mode="json"))
234
+ evaluator = PREDICATE_EVALUATORS.get(spec.name)
235
+ input_model = _INPUT_MODELS.get(spec.name)
236
+ if evaluator is None or input_model is None:
237
+ raise ValueError("unknown predicate")
238
+ if spec.predicate_id not in context.inputs:
239
+ raise ValueError("closed predicate input is required")
240
+
241
+ value = input_model.model_validate(context.inputs[spec.predicate_id])
242
+ authoritative_raw = context.authoritative_inputs.get(spec.predicate_id)
243
+ if authoritative_raw is None:
244
+ outcome: EvaluationOutcome = "fail_closed"
245
+ else:
246
+ try:
247
+ authoritative = input_model.model_validate(authoritative_raw)
248
+ except Exception:
249
+ outcome = "fail_closed"
250
+ else:
251
+ outcome = (
252
+ "fail_closed"
253
+ if value != authoritative
254
+ else "pass"
255
+ )
256
+ try:
257
+ if outcome == "pass":
258
+ outcome = evaluator(spec, value, context)
259
+ except Exception:
260
+ outcome = "fail_closed"
261
+
262
+ return PredicateResult(
263
+ predicate_id=spec.predicate_id,
264
+ name=spec.name,
265
+ version=spec.version,
266
+ arguments_digest=_jcs_digest(spec.arguments),
267
+ input=value.model_dump(mode="json"),
268
+ input_digest=predicate_input_digest(spec.predicate_id, value),
269
+ passed=outcome == "pass",
270
+ error_code=(
271
+ None
272
+ if outcome == "pass"
273
+ else "PREDICATE_FALSE"
274
+ if outcome == "false"
275
+ else "FAIL_CLOSED"
276
+ ),
277
+ )
278
+
279
+
280
+ def evaluate_required_predicates(
281
+ specs: Sequence[PredicateSpec],
282
+ context: EvaluationContext,
283
+ ) -> tuple[PredicateResult, ...]:
284
+ ids = tuple(spec.predicate_id for spec in specs)
285
+ if ids != tuple(sorted(set(ids))):
286
+ raise ValueError("predicate specs must be sorted and unique")
287
+ return tuple(evaluate_predicate(spec, context) for spec in specs)
288
+
289
+
290
+ def select_required_predicates(
291
+ *,
292
+ work_order: WorkOrder,
293
+ tool_name: str,
294
+ policy_decision: str,
295
+ execution_status: str,
296
+ test_mode: str,
297
+ ) -> tuple[PredicateSpec, ...]:
298
+ valid_outcomes = {
299
+ ("deny", "denied"),
300
+ ("allow", "succeeded"),
301
+ ("allow", "failed"),
302
+ }
303
+ if (policy_decision, execution_status) not in valid_outcomes:
304
+ raise ValueError("invalid policy/execution outcome")
305
+ if test_mode not in {"developer", "verifier"}:
306
+ raise ValueError("invalid test mode")
307
+
308
+ selected = [
309
+ spec
310
+ for spec in work_order.preconditions + work_order.invariants
311
+ if tool_name in spec.applies_to_tools
312
+ ]
313
+ if (
314
+ policy_decision == "allow"
315
+ and test_mode == "verifier"
316
+ and execution_status in {"succeeded", "failed"}
317
+ ):
318
+ selected.extend(
319
+ spec
320
+ for spec in work_order.postconditions
321
+ if tool_name in spec.applies_to_tools
322
+ )
323
+ return tuple(sorted(selected, key=lambda spec: spec.predicate_id))
324
+
325
+
326
+ __all__ = [
327
+ "EvaluationContext",
328
+ "PREDICATE_EVALUATORS",
329
+ "evaluate_predicate",
330
+ "evaluate_required_predicates",
331
+ "predicate_input_digest",
332
+ "select_required_predicates",
333
+ ]
@@ -0,0 +1,74 @@
1
+ """Repository read-and-analysis pipeline.
2
+
3
+ Public entry point::
4
+
5
+ from openworkproof.repo_pipeline import analyze_repository
6
+
7
+ analysis = analyze_repository("/path/to/repo") # local path
8
+ analysis = analyze_repository(
9
+ "https://github.com/owner/repo", clone_enabled=True
10
+ )
11
+
12
+ Pipeline stages, each decoupled and independently testable:
13
+
14
+ 1. ``sources`` — resolve a local path or remote URL to a repository root
15
+ 2. ``traversal`` — recursively walk, filter directories, recognise languages
16
+ 3. ``reader`` — read and decode each supported file with guards
17
+ 4. ``analysis`` — run the injected analyzers over the immutable scan
18
+ 5. ``output`` — render the full result to canonical JSON
19
+
20
+ Errors are typed (see ``errors``) and logging is graded through
21
+ ``openworkproof.repo_pipeline``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ from .analysis import (
29
+ DEFAULT_ANALYZERS,
30
+ RepositoryAnalyzer,
31
+ analyze_scan,
32
+ )
33
+ from .errors import get_logger
34
+ from .models import RepositoryAnalysis
35
+ from .output import render_json, to_dict
36
+ from .sources import resolve_repository_source
37
+ from .traversal import scan_repository
38
+
39
+ _logger = get_logger()
40
+
41
+ __all__ = [
42
+ "RepositoryAnalysis",
43
+ "RepositoryAnalyzer",
44
+ "analyze_repository",
45
+ "render_json",
46
+ "resolve_repository_source",
47
+ "scan_repository",
48
+ "to_dict",
49
+ ]
50
+
51
+
52
+ def analyze_repository(
53
+ source: str | Path,
54
+ *,
55
+ analyzers: tuple[RepositoryAnalyzer, ...] = DEFAULT_ANALYZERS,
56
+ clone_enabled: bool = True,
57
+ fail_fast: bool = False,
58
+ ) -> RepositoryAnalysis:
59
+ """Run the full pipeline over a local path or remote repository URL.
60
+
61
+ Remote sources are cloned into a temporary directory that the caller
62
+ must remove after consuming the result.
63
+ """
64
+ root = resolve_repository_source(source, clone_enabled=clone_enabled)
65
+ _logger.info("scanning repository root: %s", root)
66
+ scan = scan_repository(root)
67
+ results = analyze_scan(scan, analyzers, fail_fast=fail_fast)
68
+ _logger.info(
69
+ "scan complete: %d files, %d bytes, %d skipped dirs",
70
+ scan.total_files,
71
+ scan.total_bytes,
72
+ len(scan.skipped),
73
+ )
74
+ return RepositoryAnalysis(scan=scan, results=results)
@@ -0,0 +1,152 @@
1
+ """Extensible analysis layer.
2
+
3
+ Analyzers implement ``RepositoryAnalyzer`` and receive the immutable
4
+ ``RepositoryScan``; each returns one ``AnalysisResult``. The pipeline runs
5
+ analyzers in order and never lets one analyzer's failure abort the others
6
+ unless ``fail_fast`` is set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol
12
+
13
+ from .errors import AnalysisError, get_logger
14
+ from .models import AnalysisResult, RepositoryScan
15
+
16
+ _logger = get_logger()
17
+
18
+
19
+ class RepositoryAnalyzer(Protocol):
20
+ name: str
21
+
22
+ def analyze(self, scan: RepositoryScan) -> AnalysisResult: ...
23
+
24
+
25
+ class LanguageStatsAnalyzer:
26
+ """Summarise files and bytes per programming language."""
27
+
28
+ name = "language_stats"
29
+
30
+ def analyze(self, scan: RepositoryScan) -> AnalysisResult:
31
+ per_language: dict[str, dict[str, int]] = {}
32
+ for entry in scan.entries:
33
+ if entry.language is None:
34
+ continue
35
+ bucket = per_language.setdefault(
36
+ entry.language, {"files": 0, "bytes": 0}
37
+ )
38
+ bucket["files"] += 1
39
+ bucket["bytes"] += entry.size_bytes
40
+ return AnalysisResult(
41
+ analyzer=self.name,
42
+ data={
43
+ "languages": {
44
+ language: {"files": counts["files"], "bytes": counts["bytes"]}
45
+ for language, counts in sorted(per_language.items())
46
+ }
47
+ },
48
+ )
49
+
50
+
51
+ class LineCountAnalyzer:
52
+ """Count total source lines and per-language lines."""
53
+
54
+ name = "line_counts"
55
+
56
+ def analyze(self, scan: RepositoryScan) -> AnalysisResult:
57
+ total_lines = 0
58
+ per_language: dict[str, int] = {}
59
+ for entry in scan.entries:
60
+ lines = entry.content.count("\n")
61
+ total_lines += lines
62
+ if entry.language is not None:
63
+ per_language[entry.language] = (
64
+ per_language.get(entry.language, 0) + lines
65
+ )
66
+ return AnalysisResult(
67
+ analyzer=self.name,
68
+ data={
69
+ "total_lines": total_lines,
70
+ "lines_by_language": dict(
71
+ sorted(per_language.items(), key=lambda item: item[0])
72
+ ),
73
+ },
74
+ )
75
+
76
+
77
+ class DependencySnifferAnalyzer:
78
+ """Lightweight dependency-import sniffing for common ecosystems.
79
+
80
+ This is intentionally shallow and heuristic: it detects import/require
81
+ statements and package manifests without resolving the dependency graph.
82
+ """
83
+
84
+ name = "dependency_sniff"
85
+
86
+ def analyze(self, scan: RepositoryScan) -> AnalysisResult:
87
+ imports: dict[str, list[str]] = {}
88
+ for entry in scan.entries:
89
+ if entry.language == "Python":
90
+ kind = "python"
91
+ elif entry.language == "JavaScript":
92
+ kind = "javascript"
93
+ else:
94
+ continue
95
+ names = _sniff_imports(entry.content, kind)
96
+ if names:
97
+ imports.setdefault(kind, []).extend(names)
98
+ return AnalysisResult(
99
+ analyzer=self.name,
100
+ data={
101
+ "imports_by_kind": {
102
+ kind: sorted(set(names))
103
+ for kind, names in sorted(imports.items())
104
+ }
105
+ },
106
+ )
107
+
108
+
109
+ def _sniff_imports(content: str, kind: str) -> list[str]:
110
+ names: list[str] = []
111
+ for line in content.splitlines():
112
+ stripped = line.strip()
113
+ if not stripped or stripped.startswith(("#", "//", "/*", "*")):
114
+ continue
115
+ if kind == "python":
116
+ if stripped.startswith(("import ", "from ")):
117
+ token = stripped.split()[1]
118
+ names.append(token.split(".")[0])
119
+ elif kind == "javascript":
120
+ if stripped.startswith(("import ", "from ", "require(")):
121
+ token = stripped.split()[1].strip("'\"")
122
+ names.append(token.split("/")[0])
123
+ return names
124
+
125
+
126
+ def analyze_scan(
127
+ scan: RepositoryScan,
128
+ analyzers: tuple[RepositoryAnalyzer, ...],
129
+ *,
130
+ fail_fast: bool = False,
131
+ ) -> tuple[AnalysisResult, ...]:
132
+ """Run every analyzer over the scan, isolating per-analyzer failures."""
133
+ results: list[AnalysisResult] = []
134
+ for analyzer in analyzers:
135
+ try:
136
+ results.append(analyzer.analyze(scan))
137
+ except AnalysisError:
138
+ raise
139
+ except Exception as error: # noqa: BLE001
140
+ _logger.error("analyzer %s failed: %s", analyzer.name, error)
141
+ if fail_fast:
142
+ raise AnalysisError(
143
+ f"analyzer {analyzer.name} failed"
144
+ ) from error
145
+ return tuple(results)
146
+
147
+
148
+ DEFAULT_ANALYZERS: tuple[RepositoryAnalyzer, ...] = (
149
+ LanguageStatsAnalyzer(),
150
+ LineCountAnalyzer(),
151
+ DependencySnifferAnalyzer(),
152
+ )
@@ -0,0 +1,64 @@
1
+ """Typed errors and graded logging for the repository pipeline.
2
+
3
+ Every failure path raises a ``RepoPipelineError`` subclass so callers can
4
+ handle categories (missing path, permission, unsupported source, parse
5
+ failure, output failure) without inspecting message strings. Logging uses the
6
+ ``openworkproof.repo_pipeline`` namespace with severity levels suitable for
7
+ operator triage.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+
14
+
15
+ class RepoPipelineError(Exception):
16
+ """Base class for all repository pipeline failures."""
17
+
18
+
19
+ class SourceError(RepoPipelineError):
20
+ """The input source could not be resolved or reached."""
21
+
22
+
23
+ class PathNotFoundError(SourceError):
24
+ """A local repository path does not exist or is not a directory."""
25
+
26
+
27
+ class PermissionDeniedError(SourceError):
28
+ """The pipeline cannot read the repository path or a file inside it."""
29
+
30
+
31
+ class UnsupportedSourceError(SourceError):
32
+ """The input is neither a local path nor a supported remote URL."""
33
+
34
+
35
+ class RemoteCloneError(SourceError):
36
+ """A remote repository could not be cloned or fetched."""
37
+
38
+
39
+ class TraversalError(RepoPipelineError):
40
+ """The traversal stage failed while walking the repository."""
41
+
42
+
43
+ class ReadError(RepoPipelineError):
44
+ """A file could not be read or decoded."""
45
+
46
+
47
+ class DecodeError(ReadError):
48
+ """A file could not be decoded as UTF-8 text."""
49
+
50
+
51
+ class AnalysisError(RepoPipelineError):
52
+ """An analyzer failed while processing a scan."""
53
+
54
+
55
+ class OutputError(RepoPipelineError):
56
+ """The structured output could not be rendered."""
57
+
58
+
59
+ _logger = logging.getLogger("openworkproof.repo_pipeline")
60
+
61
+
62
+ def get_logger() -> logging.Logger:
63
+ """Return the shared graded logger for the pipeline stages."""
64
+ return _logger
@@ -0,0 +1,80 @@
1
+ """Unified data models for the repository read-and-analysis pipeline.
2
+
3
+ Every stage of the pipeline exchanges immutable dataclasses from this module,
4
+ so stages stay decoupled: traversal produces ``RepositoryScan``, analyzers
5
+ consume it and emit ``AnalysisResult``, and the output layer renders results
6
+ to JSON.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ import stat
14
+ from typing import Mapping
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class RepoFileEntry:
19
+ """One file discovered by the traversal stage."""
20
+
21
+ relative_path: str
22
+ language: str | None
23
+ size_bytes: int
24
+ content: str
25
+ sha256: str
26
+
27
+ @property
28
+ def is_text(self) -> bool:
29
+ return self.language is not None
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class SkippedDirectory:
34
+ """One directory skipped by a traversal filter rule."""
35
+
36
+ relative_path: str
37
+ rule: str
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class RepositoryScan:
42
+ """The complete immutable result of one repository traversal."""
43
+
44
+ root: str
45
+ entries: tuple[RepoFileEntry, ...] = ()
46
+ skipped: tuple[SkippedDirectory, ...] = ()
47
+ total_files: int = 0
48
+ total_bytes: int = 0
49
+ languages: Mapping[str, int] = field(default_factory=dict)
50
+
51
+ def language_counts(self) -> Mapping[str, int]:
52
+ counts: dict[str, int] = {}
53
+ for entry in self.entries:
54
+ if entry.language is not None:
55
+ counts[entry.language] = counts.get(entry.language, 0) + 1
56
+ return counts
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class AnalysisResult:
61
+ """One analyzer's structured output for a scan."""
62
+
63
+ analyzer: str
64
+ data: Mapping[str, object] = field(default_factory=dict)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class RepositoryAnalysis:
69
+ """The full pipeline output: scan plus every analyzer result."""
70
+
71
+ scan: RepositoryScan
72
+ results: tuple[AnalysisResult, ...] = ()
73
+
74
+
75
+ def entry_mode_is_regular(mode: int) -> bool:
76
+ return stat.S_ISREG(mode)
77
+
78
+
79
+ def is_supported_source_path(path: object) -> bool:
80
+ return isinstance(path, (str, Path)) and bool(path)