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.
- openworkproof/__init__.py +1 -0
- openworkproof/acceptance.py +3006 -0
- openworkproof/cli.py +164 -0
- openworkproof/composition.py +832 -0
- openworkproof/evidence.py +8281 -0
- openworkproof/execution_adapter.py +256 -0
- openworkproof/external_acceptor.py +211 -0
- openworkproof/mcp_server.py +2939 -0
- openworkproof/mcp_transport.py +72 -0
- openworkproof/models.py +3380 -0
- openworkproof/policy.py +2494 -0
- openworkproof/predicates.py +333 -0
- openworkproof/repo_pipeline/__init__.py +74 -0
- openworkproof/repo_pipeline/analysis.py +152 -0
- openworkproof/repo_pipeline/errors.py +64 -0
- openworkproof/repo_pipeline/models.py +80 -0
- openworkproof/repo_pipeline/output.py +68 -0
- openworkproof/repo_pipeline/reader.py +47 -0
- openworkproof/repo_pipeline/sources.py +120 -0
- openworkproof/repo_pipeline/traversal.py +253 -0
- openworkproof/repo_tools.py +8382 -0
- openworkproof/runtime_context.py +189 -0
- openworkproof/schema_registry.py +623 -0
- openworkproof/schemas/v0.1/acceptance-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/acceptance-rejection-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/action-receipt.schema.json +1 -0
- openworkproof/schemas/v0.1/capability-grant.schema.json +1 -0
- openworkproof/schemas/v0.1/schema-registry.json +1 -0
- openworkproof/schemas/v0.1/work-order.schema.json +1 -0
- openworkproof/signing.py +421 -0
- openworkproof/state.py +765 -0
- openworkproof/team_network_client.py +419 -0
- openworkproof/trusted_helper.py +269 -0
- openworkproof-1.0.0.dist-info/METADATA +578 -0
- openworkproof-1.0.0.dist-info/RECORD +39 -0
- openworkproof-1.0.0.dist-info/WHEEL +5 -0
- openworkproof-1.0.0.dist-info/entry_points.txt +2 -0
- openworkproof-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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)
|