compos-cli 0.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.
@@ -0,0 +1,574 @@
1
+ """Write validation pipeline — ALL map mutations go through here.
2
+
3
+ Six-stage pipeline:
4
+ 1. Schema validation (enforced by Pydantic at construction)
5
+ 2. Provenance completeness (enforced by schema validators)
6
+ 3. ID uniqueness (register only)
7
+ 4. Referential integrity
8
+ 5. Structural safety (cycle detection for relationships)
9
+ 6. Merge engine (update only)
10
+
11
+ The pipeline is pure — it returns a WriteResult with the new map.
12
+ It does NOT persist. The caller is responsible for storage I/O.
13
+
14
+ Removal is provenance-gated: a caller can only remove objects created
15
+ by the same or lower priority source. Lower-priority callers get a
16
+ rejection with a warning flagging the object for human review.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from enum import StrEnum
24
+ from typing import Any
25
+
26
+ from compos.core.graph import evaluate_proposed_change
27
+ from compos.core.integrity import (
28
+ check_id_uniqueness,
29
+ check_referential_integrity,
30
+ check_remove_blocked,
31
+ )
32
+ from compos.core.merge import merge_object
33
+ from compos.core.merge_log import MergeLogEntry, create_entry
34
+ from compos.core.versioning import VersionBump, increment_version
35
+ from compos.schema.models import (
36
+ Component,
37
+ ComposMap,
38
+ Constraint,
39
+ ConstraintStatus,
40
+ Decision,
41
+ DecisionStatus,
42
+ ObjectStatus,
43
+ ProvenanceSource,
44
+ Relationship,
45
+ Risk,
46
+ RiskStatus,
47
+ )
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Types
51
+ # ---------------------------------------------------------------------------
52
+
53
+ type MapObject = Component | Relationship | Constraint | Risk | Decision
54
+
55
+
56
+ class OperationType(StrEnum):
57
+ REGISTER = "register"
58
+ UPDATE = "update"
59
+ REMOVE = "remove"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class WriteError:
64
+ """A single validation failure."""
65
+
66
+ stage: str
67
+ message: str
68
+ details: Any = None
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class WriteResult:
73
+ """Outcome of a write pipeline execution."""
74
+
75
+ success: bool
76
+ new_map: ComposMap | None
77
+ new_version: int | None
78
+ merge_summary: MergeLogEntry | None
79
+ errors: tuple[WriteError, ...]
80
+ warnings: tuple[str, ...]
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # Collection name mapping
85
+ # ---------------------------------------------------------------------------
86
+
87
+ _TYPE_TO_COLLECTION: dict[type, str] = {
88
+ Component: "components",
89
+ Relationship: "relationships",
90
+ Constraint: "constraints",
91
+ Risk: "risks",
92
+ Decision: "decisions",
93
+ }
94
+
95
+ _TYPE_TO_NAME: dict[type, str] = {
96
+ Component: "component",
97
+ Relationship: "relationship",
98
+ Constraint: "constraint",
99
+ Risk: "risk",
100
+ Decision: "decision",
101
+ }
102
+
103
+ # Type-aware removal: each type maps to its correct REMOVED status value
104
+ _TYPE_TO_REMOVED_STATUS: dict[
105
+ type, ObjectStatus | ConstraintStatus | RiskStatus | DecisionStatus
106
+ ] = {
107
+ Component: ObjectStatus.REMOVED,
108
+ Relationship: ObjectStatus.REMOVED,
109
+ Constraint: ConstraintStatus.REMOVED,
110
+ Risk: RiskStatus.REMOVED,
111
+ Decision: DecisionStatus.REMOVED,
112
+ }
113
+
114
+ # Provenance priority for gating removal
115
+ _SOURCE_PRIORITY: dict[ProvenanceSource, int] = {
116
+ ProvenanceSource.STATIC_ANALYSIS: 1,
117
+ ProvenanceSource.AI_ANNOTATED: 2,
118
+ ProvenanceSource.HUMAN_INPUT: 3,
119
+ }
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Internal helpers
124
+ # ---------------------------------------------------------------------------
125
+
126
+
127
+ def _fail(stage: str, message: str, details: Any = None) -> WriteResult:
128
+ """Shorthand for a failed WriteResult."""
129
+ return WriteResult(
130
+ success=False,
131
+ new_map=None,
132
+ new_version=None,
133
+ merge_summary=None,
134
+ errors=(WriteError(stage=stage, message=message, details=details),),
135
+ warnings=(),
136
+ )
137
+
138
+
139
+ def _find_existing(
140
+ compos_map: ComposMap, obj_id: str, obj_type: type,
141
+ ) -> MapObject | None:
142
+ """Find an existing object by ID in its collection."""
143
+ collection = _TYPE_TO_COLLECTION[obj_type]
144
+ for item in getattr(compos_map, collection):
145
+ if item.id == obj_id:
146
+ return item # type: ignore[no-any-return]
147
+ return None
148
+
149
+
150
+ def _build_map_with(
151
+ compos_map: ComposMap,
152
+ collection: str,
153
+ new_items: tuple[Any, ...],
154
+ ) -> ComposMap:
155
+ """Return a new ComposMap with one collection replaced."""
156
+ data = compos_map.model_dump(by_alias=False)
157
+ data[collection] = new_items
158
+ return ComposMap.model_validate(data)
159
+
160
+
161
+ def _replace_in_collection(
162
+ compos_map: ComposMap,
163
+ obj: MapObject,
164
+ collection: str,
165
+ ) -> ComposMap:
166
+ """Return a new ComposMap with obj replacing the same-ID object in collection."""
167
+ items = getattr(compos_map, collection)
168
+ new_items = tuple(obj if item.id == obj.id else item for item in items)
169
+ return _build_map_with(compos_map, collection, new_items)
170
+
171
+
172
+ def _add_to_collection(
173
+ compos_map: ComposMap,
174
+ obj: MapObject,
175
+ collection: str,
176
+ ) -> ComposMap:
177
+ """Return a new ComposMap with obj appended to collection."""
178
+ items = getattr(compos_map, collection)
179
+ new_items = items + (obj,)
180
+ return _build_map_with(compos_map, collection, new_items)
181
+
182
+
183
+ def _apply_version_bump(compos_map: ComposMap, bump: VersionBump) -> ComposMap:
184
+ """Return a new ComposMap with version bump applied."""
185
+ data = compos_map.model_dump(by_alias=False)
186
+ data["map_version"] = bump.new_version
187
+ data["generated_at"] = bump.generated_at
188
+ data["parent_version"] = bump.parent_version
189
+ if bump.git_commit is not None:
190
+ data["git_commit"] = bump.git_commit
191
+ return ComposMap.model_validate(data)
192
+
193
+
194
+ def _normalize_source(source: ProvenanceSource | Any) -> str:
195
+ """Normalize provenance source to string for priority lookup.
196
+
197
+ Handles both ProvenanceSource and DecisionProvenanceSource.
198
+ """
199
+ return str(source.value) if hasattr(source, "value") else str(source)
200
+
201
+
202
+ def _to_provenance_source(source_str: str) -> ProvenanceSource:
203
+ """Convert a source string to ProvenanceSource enum.
204
+
205
+ Works for both ProvenanceSource and DecisionProvenanceSource values
206
+ since they share the same string values (minus static-analysis).
207
+ """
208
+ return ProvenanceSource(source_str)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Generic pipeline functions
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def _execute_register(
217
+ current_map: ComposMap,
218
+ obj: MapObject,
219
+ git_commit: str | None = None,
220
+ ) -> WriteResult:
221
+ """Pipeline for REGISTER operations."""
222
+ collection = _TYPE_TO_COLLECTION[type(obj)]
223
+ type_name = _TYPE_TO_NAME[type(obj)]
224
+
225
+ # Stage 3: ID uniqueness
226
+ uniqueness_violation = check_id_uniqueness(current_map, obj.id, collection)
227
+ if uniqueness_violation is not None:
228
+ return _fail("uniqueness", uniqueness_violation.message, uniqueness_violation)
229
+
230
+ # Stage 4: Referential integrity
231
+ integrity_violations = check_referential_integrity(current_map, obj)
232
+ if integrity_violations:
233
+ messages = [v.message for v in integrity_violations]
234
+ return _fail(
235
+ "integrity",
236
+ f"Referential integrity violations: {'; '.join(messages)}",
237
+ integrity_violations,
238
+ )
239
+
240
+ # Stage 5: Structural safety (relationships only — cycle detection)
241
+ if isinstance(obj, Relationship):
242
+ eval_result = evaluate_proposed_change(
243
+ current_map, proposed_relationships=(obj,)
244
+ )
245
+ if eval_result.cycles:
246
+ cycle_desc = "; ".join(
247
+ f"cycle: {' -> '.join(c.component_ids)}" for c in eval_result.cycles
248
+ )
249
+ return _fail(
250
+ "structural",
251
+ f"Relationship would introduce cycle(s): {cycle_desc}",
252
+ eval_result.cycles,
253
+ )
254
+
255
+ # All checks passed — build new map
256
+ new_map = _add_to_collection(current_map, obj, collection)
257
+
258
+ # Version bump
259
+ bump = increment_version(current_map, git_commit=git_commit)
260
+ new_map = _apply_version_bump(new_map, bump)
261
+
262
+ # Merge log entry (no merge for register)
263
+ source_str = _normalize_source(obj.provenance.source)
264
+ log_entry = create_entry(
265
+ version_bump=bump,
266
+ operation=f"register_{type_name}",
267
+ target_id=obj.id,
268
+ target_type=type_name,
269
+ source=_to_provenance_source(source_str),
270
+ merge_result=None,
271
+ )
272
+
273
+ return WriteResult(
274
+ success=True,
275
+ new_map=new_map,
276
+ new_version=bump.new_version,
277
+ merge_summary=log_entry,
278
+ errors=(),
279
+ warnings=(),
280
+ )
281
+
282
+
283
+ def _execute_update(
284
+ current_map: ComposMap,
285
+ obj: MapObject,
286
+ git_commit: str | None = None,
287
+ ) -> WriteResult:
288
+ """Pipeline for UPDATE operations."""
289
+ type_name = _TYPE_TO_NAME[type(obj)]
290
+ collection = _TYPE_TO_COLLECTION[type(obj)]
291
+
292
+ # Find existing
293
+ existing = _find_existing(current_map, obj.id, type(obj))
294
+ if existing is None:
295
+ return _fail("not_found", f"{type_name} '{obj.id}' not found")
296
+
297
+ # Stage 4: Referential integrity (on the incoming object)
298
+ integrity_violations = check_referential_integrity(current_map, obj)
299
+ if integrity_violations:
300
+ messages = [v.message for v in integrity_violations]
301
+ return _fail(
302
+ "integrity",
303
+ f"Referential integrity violations: {'; '.join(messages)}",
304
+ integrity_violations,
305
+ )
306
+
307
+ # Stage 6: Merge engine
308
+ merge_result = merge_object(existing, obj)
309
+
310
+ # Build new map with merged object
311
+ new_map = _replace_in_collection(current_map, merge_result.merged_obj, collection)
312
+
313
+ # Version bump
314
+ bump = increment_version(current_map, git_commit=git_commit)
315
+ new_map = _apply_version_bump(new_map, bump)
316
+
317
+ # Merge log entry
318
+ source_str = _normalize_source(obj.provenance.source)
319
+ log_entry = create_entry(
320
+ version_bump=bump,
321
+ operation=f"update_{type_name}",
322
+ target_id=obj.id,
323
+ target_type=type_name,
324
+ source=_to_provenance_source(source_str),
325
+ merge_result=merge_result,
326
+ )
327
+
328
+ # Collect warnings
329
+ warnings: list[str] = []
330
+ for record in merge_result.field_records:
331
+ if record.resolution.value == "warning":
332
+ warnings.append(record.reason)
333
+ elif record.resolution.value == "rejected":
334
+ warnings.append(
335
+ f"Field '{record.field_name}' rejected: {record.reason}"
336
+ )
337
+
338
+ return WriteResult(
339
+ success=True,
340
+ new_map=new_map,
341
+ new_version=bump.new_version,
342
+ merge_summary=log_entry,
343
+ errors=(),
344
+ warnings=tuple(warnings),
345
+ )
346
+
347
+
348
+ def _execute_remove(
349
+ current_map: ComposMap,
350
+ obj_id: str,
351
+ obj_type: type,
352
+ reason: str,
353
+ caller_source: ProvenanceSource = ProvenanceSource.HUMAN_INPUT,
354
+ git_commit: str | None = None,
355
+ ) -> WriteResult:
356
+ """Pipeline for REMOVE operations.
357
+
358
+ Provenance-gated: caller_source must be >= the object's provenance source
359
+ in the trust hierarchy. If not, removal is rejected with a warning.
360
+ """
361
+ type_name = _TYPE_TO_NAME[obj_type]
362
+ collection = _TYPE_TO_COLLECTION[obj_type]
363
+
364
+ # Find existing
365
+ existing = _find_existing(current_map, obj_id, obj_type)
366
+ if existing is None:
367
+ return _fail("not_found", f"{type_name} '{obj_id}' not found")
368
+
369
+ # Provenance gate: check caller has sufficient priority
370
+ source_str = _normalize_source(existing.provenance.source)
371
+ existing_source = _to_provenance_source(source_str)
372
+ existing_priority = _SOURCE_PRIORITY[existing_source]
373
+ caller_priority = _SOURCE_PRIORITY[caller_source]
374
+
375
+ if caller_priority < existing_priority:
376
+ return _fail(
377
+ "provenance",
378
+ (
379
+ f"Cannot remove {type_name} '{obj_id}': created by "
380
+ f"{existing_source.value}, caller is {caller_source.value}. "
381
+ f"Flagged for human review."
382
+ ),
383
+ )
384
+
385
+ # Stage 4: Check remove blocked (impact report)
386
+ blocked = check_remove_blocked(current_map, obj_id, type_name)
387
+ if blocked:
388
+ blocking_desc = ", ".join(
389
+ f"{b.object_type}:{b.object_id}" for b in blocked
390
+ )
391
+ return _fail(
392
+ "integrity",
393
+ f"Cannot remove {type_name} '{obj_id}': referenced by {blocking_desc}",
394
+ blocked,
395
+ )
396
+
397
+ # Build soft-deleted object — type-aware status and fields
398
+ now = datetime.now(UTC)
399
+ existing_data = existing.model_dump(by_alias=False)
400
+ existing_data["status"] = _TYPE_TO_REMOVED_STATUS[obj_type]
401
+ existing_data["removed_at"] = now
402
+ existing_data["removed_reason"] = reason
403
+ removed_obj = type(existing).model_validate(existing_data)
404
+
405
+ # Build new map
406
+ new_map = _replace_in_collection(current_map, removed_obj, collection)
407
+
408
+ # Version bump
409
+ bump = increment_version(current_map, git_commit=git_commit)
410
+ new_map = _apply_version_bump(new_map, bump)
411
+
412
+ # Merge log entry
413
+ log_entry = create_entry(
414
+ version_bump=bump,
415
+ operation=f"remove_{type_name}",
416
+ target_id=obj_id,
417
+ target_type=type_name,
418
+ source=existing_source,
419
+ merge_result=None,
420
+ )
421
+
422
+ return WriteResult(
423
+ success=True,
424
+ new_map=new_map,
425
+ new_version=bump.new_version,
426
+ merge_summary=log_entry,
427
+ errors=(),
428
+ warnings=(),
429
+ )
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Type-specific public entry points
434
+ # ---------------------------------------------------------------------------
435
+
436
+
437
+ def register_component(
438
+ current_map: ComposMap,
439
+ component: Component,
440
+ git_commit: str | None = None,
441
+ ) -> WriteResult:
442
+ return _execute_register(current_map, component, git_commit)
443
+
444
+
445
+ def register_relationship(
446
+ current_map: ComposMap,
447
+ relationship: Relationship,
448
+ git_commit: str | None = None,
449
+ ) -> WriteResult:
450
+ return _execute_register(current_map, relationship, git_commit)
451
+
452
+
453
+ def register_constraint(
454
+ current_map: ComposMap,
455
+ constraint: Constraint,
456
+ git_commit: str | None = None,
457
+ ) -> WriteResult:
458
+ return _execute_register(current_map, constraint, git_commit)
459
+
460
+
461
+ def register_risk(
462
+ current_map: ComposMap,
463
+ risk: Risk,
464
+ git_commit: str | None = None,
465
+ ) -> WriteResult:
466
+ return _execute_register(current_map, risk, git_commit)
467
+
468
+
469
+ def register_decision(
470
+ current_map: ComposMap,
471
+ decision: Decision,
472
+ git_commit: str | None = None,
473
+ ) -> WriteResult:
474
+ return _execute_register(current_map, decision, git_commit)
475
+
476
+
477
+ def update_component(
478
+ current_map: ComposMap,
479
+ component: Component,
480
+ git_commit: str | None = None,
481
+ ) -> WriteResult:
482
+ return _execute_update(current_map, component, git_commit)
483
+
484
+
485
+ def update_relationship(
486
+ current_map: ComposMap,
487
+ relationship: Relationship,
488
+ git_commit: str | None = None,
489
+ ) -> WriteResult:
490
+ return _execute_update(current_map, relationship, git_commit)
491
+
492
+
493
+ def update_constraint(
494
+ current_map: ComposMap,
495
+ constraint: Constraint,
496
+ git_commit: str | None = None,
497
+ ) -> WriteResult:
498
+ return _execute_update(current_map, constraint, git_commit)
499
+
500
+
501
+ def update_risk(
502
+ current_map: ComposMap,
503
+ risk: Risk,
504
+ git_commit: str | None = None,
505
+ ) -> WriteResult:
506
+ return _execute_update(current_map, risk, git_commit)
507
+
508
+
509
+ def update_decision(
510
+ current_map: ComposMap,
511
+ decision: Decision,
512
+ git_commit: str | None = None,
513
+ ) -> WriteResult:
514
+ return _execute_update(current_map, decision, git_commit)
515
+
516
+
517
+ def remove_component(
518
+ current_map: ComposMap,
519
+ component_id: str,
520
+ reason: str,
521
+ caller_source: ProvenanceSource = ProvenanceSource.HUMAN_INPUT,
522
+ git_commit: str | None = None,
523
+ ) -> WriteResult:
524
+ return _execute_remove(
525
+ current_map, component_id, Component, reason, caller_source, git_commit,
526
+ )
527
+
528
+
529
+ def remove_relationship(
530
+ current_map: ComposMap,
531
+ relationship_id: str,
532
+ reason: str,
533
+ caller_source: ProvenanceSource = ProvenanceSource.HUMAN_INPUT,
534
+ git_commit: str | None = None,
535
+ ) -> WriteResult:
536
+ return _execute_remove(
537
+ current_map, relationship_id, Relationship, reason, caller_source, git_commit,
538
+ )
539
+
540
+
541
+ def remove_constraint(
542
+ current_map: ComposMap,
543
+ constraint_id: str,
544
+ reason: str,
545
+ caller_source: ProvenanceSource = ProvenanceSource.HUMAN_INPUT,
546
+ git_commit: str | None = None,
547
+ ) -> WriteResult:
548
+ return _execute_remove(
549
+ current_map, constraint_id, Constraint, reason, caller_source, git_commit,
550
+ )
551
+
552
+
553
+ def remove_risk(
554
+ current_map: ComposMap,
555
+ risk_id: str,
556
+ reason: str,
557
+ caller_source: ProvenanceSource = ProvenanceSource.HUMAN_INPUT,
558
+ git_commit: str | None = None,
559
+ ) -> WriteResult:
560
+ return _execute_remove(
561
+ current_map, risk_id, Risk, reason, caller_source, git_commit,
562
+ )
563
+
564
+
565
+ def remove_decision(
566
+ current_map: ComposMap,
567
+ decision_id: str,
568
+ reason: str,
569
+ caller_source: ProvenanceSource = ProvenanceSource.HUMAN_INPUT,
570
+ git_commit: str | None = None,
571
+ ) -> WriteResult:
572
+ return _execute_remove(
573
+ current_map, decision_id, Decision, reason, caller_source, git_commit,
574
+ )
@@ -0,0 +1,57 @@
1
+ """Schema package — Pydantic models for the Compos architectural map."""
2
+
3
+ from compos.schema.models import (
4
+ AlternativeConsidered,
5
+ Component,
6
+ ComponentType,
7
+ ComposMap,
8
+ Constraint,
9
+ ConstraintStatus,
10
+ ConstraintType,
11
+ Decision,
12
+ DecisionProvenance,
13
+ DecisionProvenanceSource,
14
+ DecisionStatus,
15
+ Hardness,
16
+ LastMerge,
17
+ Likelihood,
18
+ ObjectStatus,
19
+ ProjectInfo,
20
+ Provenance,
21
+ ProvenanceSource,
22
+ Relationship,
23
+ RelationshipPattern,
24
+ RelationshipType,
25
+ Risk,
26
+ RiskStatus,
27
+ Severity,
28
+ )
29
+
30
+ __all__ = [
31
+ # Enums
32
+ "ComponentType",
33
+ "ConstraintStatus",
34
+ "ConstraintType",
35
+ "DecisionProvenanceSource",
36
+ "DecisionStatus",
37
+ "Hardness",
38
+ "Likelihood",
39
+ "ObjectStatus",
40
+ "ProvenanceSource",
41
+ "RelationshipPattern",
42
+ "RelationshipType",
43
+ "RiskStatus",
44
+ "Severity",
45
+ # Models
46
+ "AlternativeConsidered",
47
+ "Component",
48
+ "ComposMap",
49
+ "Constraint",
50
+ "Decision",
51
+ "DecisionProvenance",
52
+ "LastMerge",
53
+ "ProjectInfo",
54
+ "Provenance",
55
+ "Relationship",
56
+ "Risk",
57
+ ]