@guanlin4924/devflow-pi 0.1.39 → 0.1.47
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.
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +728 -39
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +334 -30
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +86 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +61 -24
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Strict structural validator and canonicalizer for Devflow business models.
|
|
3
|
+
|
|
4
|
+
P0-01 intentionally validates only schema shape, strict keys, local union
|
|
5
|
+
semantics, and deterministic serialization. Source resolution and business
|
|
6
|
+
closure checks are added by P0-02 through P0-04.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import unicodedata
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_SCHEMA = Path(__file__).resolve().parents[1] / "contracts" / "business-model.schema.yaml"
|
|
22
|
+
SOURCE_RESOLVER = Path(__file__).resolve().with_name("business_source_resolver.rb")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_yaml(path: Path) -> Any:
|
|
26
|
+
try:
|
|
27
|
+
import yaml # type: ignore
|
|
28
|
+
|
|
29
|
+
return yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
30
|
+
except ImportError:
|
|
31
|
+
result = subprocess.run(
|
|
32
|
+
[
|
|
33
|
+
"ruby",
|
|
34
|
+
"-ryaml",
|
|
35
|
+
"-rjson",
|
|
36
|
+
"-e",
|
|
37
|
+
"puts JSON.generate(YAML.safe_load(File.read(ARGV.fetch(0))))",
|
|
38
|
+
str(path),
|
|
39
|
+
],
|
|
40
|
+
text=True,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
check=False,
|
|
43
|
+
)
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
raise ValueError(result.stderr.strip() or "YAML parse failed")
|
|
46
|
+
return json.loads(result.stdout)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def add_error(errors: list[dict[str, str]], code: str, path: str, message: str) -> None:
|
|
50
|
+
errors.append({"code": code, "path": path, "message": message})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def object_spec(schema: dict[str, Any], name: str) -> dict[str, Any]:
|
|
54
|
+
value = schema.get("objects", {}).get(name, {})
|
|
55
|
+
return value if isinstance(value, dict) else {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def strict_object(
|
|
59
|
+
value: Any,
|
|
60
|
+
spec: dict[str, Any],
|
|
61
|
+
path: str,
|
|
62
|
+
errors: list[dict[str, str]],
|
|
63
|
+
*,
|
|
64
|
+
missing_code: str = "BUSINESS_REQUIRED_FIELD_MISSING",
|
|
65
|
+
) -> bool:
|
|
66
|
+
if not isinstance(value, dict):
|
|
67
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, "object required")
|
|
68
|
+
return False
|
|
69
|
+
properties = spec.get("properties", {})
|
|
70
|
+
allowed = set(properties) if isinstance(properties, dict) else set()
|
|
71
|
+
for key in value:
|
|
72
|
+
if key not in allowed:
|
|
73
|
+
add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"unknown field {key}")
|
|
74
|
+
for key in spec.get("required", []):
|
|
75
|
+
if key not in value:
|
|
76
|
+
add_error(errors, missing_code, f"{path}.{key}", f"required field {key} is missing")
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_id(value: Any, path: str, pattern: re.Pattern[str], errors: list[dict[str, str]]) -> bool:
|
|
81
|
+
if not isinstance(value, str) or not pattern.fullmatch(value):
|
|
82
|
+
add_error(errors, "BUSINESS_ID_INVALID", path, "value must match the business ID pattern")
|
|
83
|
+
return False
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_string(value: Any, path: str, errors: list[dict[str, str]], *, non_empty: bool = True) -> None:
|
|
88
|
+
if not isinstance(value, str) or (non_empty and not value.strip()):
|
|
89
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, "non-empty string required")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_id_list(
|
|
93
|
+
value: Any,
|
|
94
|
+
path: str,
|
|
95
|
+
pattern: re.Pattern[str],
|
|
96
|
+
errors: list[dict[str, str]],
|
|
97
|
+
*,
|
|
98
|
+
min_items: int = 0,
|
|
99
|
+
) -> None:
|
|
100
|
+
if not isinstance(value, list):
|
|
101
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, "array required")
|
|
102
|
+
return
|
|
103
|
+
if len(value) < min_items:
|
|
104
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", path, f"at least {min_items} item(s) required")
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
for index, item in enumerate(value):
|
|
107
|
+
if validate_id(item, f"{path}[{index}]", pattern, errors):
|
|
108
|
+
if item in seen:
|
|
109
|
+
add_error(errors, "BUSINESS_DUPLICATE_ID", f"{path}[{index}]", f"duplicate reference {item}")
|
|
110
|
+
seen.add(item)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def validate_source_refs(value: Any, path: str, pattern: re.Pattern[str], errors: list[dict[str, str]]) -> None:
|
|
114
|
+
validate_id_list(value, path, pattern, errors, min_items=1)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def register_id(value: Any, path: str, pattern: re.Pattern[str], errors: list[dict[str, str]], seen: set[str]) -> None:
|
|
118
|
+
if validate_id(value, path, pattern, errors):
|
|
119
|
+
if value in seen:
|
|
120
|
+
add_error(errors, "BUSINESS_DUPLICATE_ID", path, f"duplicate ID {value}")
|
|
121
|
+
else:
|
|
122
|
+
seen.add(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_simple_object(
|
|
126
|
+
value: Any,
|
|
127
|
+
kind: str,
|
|
128
|
+
path: str,
|
|
129
|
+
schema: dict[str, Any],
|
|
130
|
+
pattern: re.Pattern[str],
|
|
131
|
+
errors: list[dict[str, str]],
|
|
132
|
+
seen: set[str],
|
|
133
|
+
) -> None:
|
|
134
|
+
spec = object_spec(schema, kind)
|
|
135
|
+
if not strict_object(value, spec, path, errors):
|
|
136
|
+
return
|
|
137
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
138
|
+
if "name" in value:
|
|
139
|
+
validate_string(value.get("name"), f"{path}.name", errors)
|
|
140
|
+
validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def validate_entity(
|
|
144
|
+
value: Any,
|
|
145
|
+
path: str,
|
|
146
|
+
schema: dict[str, Any],
|
|
147
|
+
pattern: re.Pattern[str],
|
|
148
|
+
errors: list[dict[str, str]],
|
|
149
|
+
seen: set[str],
|
|
150
|
+
) -> None:
|
|
151
|
+
spec = object_spec(schema, "entity")
|
|
152
|
+
if not strict_object(value, spec, path, errors):
|
|
153
|
+
return
|
|
154
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
155
|
+
if "name" in value:
|
|
156
|
+
validate_string(value.get("name"), f"{path}.name", errors)
|
|
157
|
+
validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
|
|
158
|
+
states = value.get("states")
|
|
159
|
+
if not isinstance(states, list):
|
|
160
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.states", "array required")
|
|
161
|
+
return
|
|
162
|
+
state_spec = object_spec(schema, "state")
|
|
163
|
+
for index, state in enumerate(states):
|
|
164
|
+
state_path = f"{path}.states[{index}]"
|
|
165
|
+
if not strict_object(state, state_spec, state_path, errors):
|
|
166
|
+
continue
|
|
167
|
+
register_id(state.get("id"), f"{state_path}.id", pattern, errors, seen)
|
|
168
|
+
validate_string(state.get("name"), f"{state_path}.name", errors)
|
|
169
|
+
if "terminal" in state and not isinstance(state.get("terminal"), bool):
|
|
170
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{state_path}.terminal", "boolean required")
|
|
171
|
+
validate_source_refs(state.get("source_refs"), f"{state_path}.source_refs", pattern, errors)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_origin(
|
|
175
|
+
value: Any,
|
|
176
|
+
path: str,
|
|
177
|
+
schema: dict[str, Any],
|
|
178
|
+
pattern: re.Pattern[str],
|
|
179
|
+
errors: list[dict[str, str]],
|
|
180
|
+
) -> None:
|
|
181
|
+
variants = schema.get("unions", {}).get("resource_origin", {}).get("variants", {})
|
|
182
|
+
if not isinstance(value, dict):
|
|
183
|
+
add_error(errors, "BUSINESS_INVALID_ORIGIN", path, "resource origin must be an object")
|
|
184
|
+
return
|
|
185
|
+
kind = value.get("kind")
|
|
186
|
+
spec = variants.get(kind) if isinstance(variants, dict) else None
|
|
187
|
+
if not isinstance(spec, dict):
|
|
188
|
+
add_error(errors, "BUSINESS_INVALID_ORIGIN", f"{path}.kind", f"unknown resource origin {kind}")
|
|
189
|
+
return
|
|
190
|
+
properties = spec.get("properties", {})
|
|
191
|
+
allowed = set(properties) if isinstance(properties, dict) else set()
|
|
192
|
+
for key in value:
|
|
193
|
+
if key not in allowed:
|
|
194
|
+
add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"field {key} is not valid for origin {kind}")
|
|
195
|
+
for key in spec.get("required", []):
|
|
196
|
+
if key not in value:
|
|
197
|
+
add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", f"{path}.{key}", f"required field {key} is missing")
|
|
198
|
+
for key, item in value.items():
|
|
199
|
+
if key != "kind":
|
|
200
|
+
validate_id(item, f"{path}.{key}", pattern, errors)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def validate_resource(
|
|
204
|
+
value: Any,
|
|
205
|
+
path: str,
|
|
206
|
+
schema: dict[str, Any],
|
|
207
|
+
pattern: re.Pattern[str],
|
|
208
|
+
errors: list[dict[str, str]],
|
|
209
|
+
seen: set[str],
|
|
210
|
+
) -> None:
|
|
211
|
+
spec = object_spec(schema, "resource")
|
|
212
|
+
if not strict_object(value, spec, path, errors):
|
|
213
|
+
return
|
|
214
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
215
|
+
if "name" in value:
|
|
216
|
+
validate_string(value.get("name"), f"{path}.name", errors)
|
|
217
|
+
validate_origin(value.get("origin"), f"{path}.origin", schema, pattern, errors)
|
|
218
|
+
validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def validate_operand(
|
|
222
|
+
value: Any,
|
|
223
|
+
path: str,
|
|
224
|
+
schema: dict[str, Any],
|
|
225
|
+
pattern: re.Pattern[str],
|
|
226
|
+
errors: list[dict[str, str]],
|
|
227
|
+
) -> None:
|
|
228
|
+
variants = schema.get("conditions", {}).get("operand_variants", {})
|
|
229
|
+
if not isinstance(value, dict):
|
|
230
|
+
add_error(errors, "BUSINESS_RULE_INVALID", path, "operand must be an object")
|
|
231
|
+
return
|
|
232
|
+
kind = value.get("kind")
|
|
233
|
+
allowed_fields = variants.get(kind) if isinstance(variants, dict) else None
|
|
234
|
+
if not isinstance(allowed_fields, list):
|
|
235
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.kind", f"unknown operand kind {kind}")
|
|
236
|
+
return
|
|
237
|
+
allowed = set(allowed_fields)
|
|
238
|
+
for key in value:
|
|
239
|
+
if key not in allowed:
|
|
240
|
+
add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"field {key} is not valid for operand {kind}")
|
|
241
|
+
for key in allowed - {"value"}:
|
|
242
|
+
if key not in value:
|
|
243
|
+
add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", f"{path}.{key}", f"required field {key} is missing")
|
|
244
|
+
if kind == "literal":
|
|
245
|
+
if "value" not in value:
|
|
246
|
+
add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", f"{path}.value", "literal value is required")
|
|
247
|
+
else:
|
|
248
|
+
for key in allowed - {"kind", "attribute"}:
|
|
249
|
+
if key in value:
|
|
250
|
+
validate_id(value[key], f"{path}.{key}", pattern, errors)
|
|
251
|
+
if "attribute" in allowed and "attribute" in value:
|
|
252
|
+
validate_string(value["attribute"], f"{path}.attribute", errors)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def validate_condition(
|
|
256
|
+
value: Any,
|
|
257
|
+
path: str,
|
|
258
|
+
schema: dict[str, Any],
|
|
259
|
+
pattern: re.Pattern[str],
|
|
260
|
+
errors: list[dict[str, str]],
|
|
261
|
+
) -> None:
|
|
262
|
+
if not isinstance(value, dict):
|
|
263
|
+
add_error(errors, "BUSINESS_RULE_INVALID", path, "condition must be an object")
|
|
264
|
+
return
|
|
265
|
+
allowed = {"op", "args", "operand", "left", "right"}
|
|
266
|
+
for key in value:
|
|
267
|
+
if key not in allowed:
|
|
268
|
+
add_error(errors, "BUSINESS_UNKNOWN_FIELD", f"{path}.{key}", f"unknown condition field {key}")
|
|
269
|
+
op = value.get("op")
|
|
270
|
+
operators = set(schema.get("conditions", {}).get("operators", []))
|
|
271
|
+
if op not in operators:
|
|
272
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.op", f"unsupported operator {op}")
|
|
273
|
+
return
|
|
274
|
+
if op in {"and", "or", "not"}:
|
|
275
|
+
args = value.get("args")
|
|
276
|
+
expected = 1 if op == "not" else 2
|
|
277
|
+
if not isinstance(args, list) or len(args) < expected or (op == "not" and len(args) != 1):
|
|
278
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.args", f"{op} requires {'one' if op == 'not' else 'at least two'} argument(s)")
|
|
279
|
+
return
|
|
280
|
+
if set(value) - {"op", "args"}:
|
|
281
|
+
add_error(errors, "BUSINESS_RULE_INVALID", path, f"{op} only accepts args")
|
|
282
|
+
for index, item in enumerate(args):
|
|
283
|
+
validate_condition(item, f"{path}.args[{index}]", schema, pattern, errors)
|
|
284
|
+
elif op == "exists":
|
|
285
|
+
if set(value) - {"op", "operand"}:
|
|
286
|
+
add_error(errors, "BUSINESS_RULE_INVALID", path, "exists only accepts operand")
|
|
287
|
+
validate_operand(value.get("operand"), f"{path}.operand", schema, pattern, errors)
|
|
288
|
+
else:
|
|
289
|
+
if set(value) - {"op", "left", "right"}:
|
|
290
|
+
add_error(errors, "BUSINESS_RULE_INVALID", path, f"{op} only accepts left/right operands")
|
|
291
|
+
validate_operand(value.get("left"), f"{path}.left", schema, pattern, errors)
|
|
292
|
+
validate_operand(value.get("right"), f"{path}.right", schema, pattern, errors)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def validate_rule(
|
|
296
|
+
value: Any,
|
|
297
|
+
path: str,
|
|
298
|
+
schema: dict[str, Any],
|
|
299
|
+
pattern: re.Pattern[str],
|
|
300
|
+
errors: list[dict[str, str]],
|
|
301
|
+
seen: set[str],
|
|
302
|
+
) -> None:
|
|
303
|
+
spec = object_spec(schema, "rule")
|
|
304
|
+
if not strict_object(value, spec, path, errors):
|
|
305
|
+
return
|
|
306
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
307
|
+
if "name" in value:
|
|
308
|
+
validate_string(value.get("name"), f"{path}.name", errors)
|
|
309
|
+
kind = value.get("kind")
|
|
310
|
+
if kind not in {"constraint", "decision"}:
|
|
311
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.kind", "kind must be constraint or decision")
|
|
312
|
+
validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
|
|
313
|
+
if "input_refs" in value:
|
|
314
|
+
validate_id_list(value.get("input_refs"), f"{path}.input_refs", pattern, errors)
|
|
315
|
+
if kind == "decision":
|
|
316
|
+
if value.get("evaluation") != "first_match":
|
|
317
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.evaluation", "decision evaluation must be first_match")
|
|
318
|
+
if "expression" in value:
|
|
319
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.expression", "decision rule cannot define expression")
|
|
320
|
+
branches = value.get("branches")
|
|
321
|
+
if not isinstance(branches, list) or not branches:
|
|
322
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.branches", "decision branches must be a non-empty array")
|
|
323
|
+
return
|
|
324
|
+
branch_spec = object_spec(schema, "branch")
|
|
325
|
+
defaults: list[int] = []
|
|
326
|
+
branch_ids: set[str] = set()
|
|
327
|
+
for index, branch in enumerate(branches):
|
|
328
|
+
branch_path = f"{path}.branches[{index}]"
|
|
329
|
+
if not strict_object(branch, branch_spec, branch_path, errors):
|
|
330
|
+
continue
|
|
331
|
+
branch_id = branch.get("id")
|
|
332
|
+
if validate_id(branch_id, f"{branch_path}.id", pattern, errors):
|
|
333
|
+
if branch_id in branch_ids:
|
|
334
|
+
add_error(errors, "BUSINESS_DUPLICATE_ID", f"{branch_path}.id", f"duplicate branch ID {branch_id}")
|
|
335
|
+
branch_ids.add(branch_id)
|
|
336
|
+
validate_id_list(branch.get("next_action_refs"), f"{branch_path}.next_action_refs", pattern, errors, min_items=1)
|
|
337
|
+
validate_source_refs(branch.get("source_refs"), f"{branch_path}.source_refs", pattern, errors)
|
|
338
|
+
if branch.get("default") is True:
|
|
339
|
+
defaults.append(index)
|
|
340
|
+
if "when" in branch:
|
|
341
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{branch_path}.when", "default branch cannot define when")
|
|
342
|
+
else:
|
|
343
|
+
if branch.get("default") not in {None, False}:
|
|
344
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{branch_path}.default", "default must be boolean")
|
|
345
|
+
validate_condition(branch.get("when"), f"{branch_path}.when", schema, pattern, errors)
|
|
346
|
+
if defaults != [len(branches) - 1]:
|
|
347
|
+
add_error(errors, "BUSINESS_RULE_INVALID", f"{path}.branches", "exactly one default branch is required and it must be last")
|
|
348
|
+
elif kind == "constraint":
|
|
349
|
+
if "branches" in value or "evaluation" in value:
|
|
350
|
+
add_error(errors, "BUSINESS_RULE_INVALID", path, "constraint rule cannot define branches or evaluation")
|
|
351
|
+
validate_condition(value.get("expression"), f"{path}.expression", schema, pattern, errors)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def validate_routing(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]]) -> None:
|
|
355
|
+
spec = object_spec(schema, "routing")
|
|
356
|
+
if not strict_object(value, spec, path, errors):
|
|
357
|
+
return
|
|
358
|
+
choices = 0
|
|
359
|
+
if "next_action_refs" in value:
|
|
360
|
+
validate_id_list(value.get("next_action_refs"), f"{path}.next_action_refs", pattern, errors, min_items=1)
|
|
361
|
+
choices += 1
|
|
362
|
+
if "decision_rule_ref" in value:
|
|
363
|
+
validate_id(value.get("decision_rule_ref"), f"{path}.decision_rule_ref", pattern, errors)
|
|
364
|
+
choices += 1
|
|
365
|
+
if "terminal" in value:
|
|
366
|
+
if value.get("terminal") is not True:
|
|
367
|
+
add_error(errors, "BUSINESS_ROUTING_INVALID", f"{path}.terminal", "terminal must be true when present")
|
|
368
|
+
choices += 1
|
|
369
|
+
if choices != 1:
|
|
370
|
+
add_error(errors, "BUSINESS_ROUTING_INVALID", path, "routing must choose exactly one of next_action_refs, decision_rule_ref, or terminal")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def validate_failure_mode(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]]) -> None:
|
|
374
|
+
spec = object_spec(schema, "failure_mode")
|
|
375
|
+
if not strict_object(value, spec, path, errors):
|
|
376
|
+
return
|
|
377
|
+
validate_id(value.get("code"), f"{path}.code", pattern, errors)
|
|
378
|
+
validate_source_refs(value.get("source_refs"), f"{path}.source_refs", pattern, errors)
|
|
379
|
+
recovery = "recovery_action_ref" in value
|
|
380
|
+
terminal = value.get("terminal") is True and "terminal_state_ref" in value
|
|
381
|
+
if recovery == terminal:
|
|
382
|
+
add_error(errors, "BUSINESS_FAILURE_OUTCOME_MISSING", path, "failure must define either recovery_action_ref or terminal=true with terminal_state_ref")
|
|
383
|
+
if recovery:
|
|
384
|
+
validate_id(value.get("recovery_action_ref"), f"{path}.recovery_action_ref", pattern, errors)
|
|
385
|
+
if "terminal_state_ref" in value or "terminal" in value:
|
|
386
|
+
add_error(errors, "BUSINESS_FAILURE_OUTCOME_MISSING", path, "recoverable failure cannot also be terminal")
|
|
387
|
+
if terminal:
|
|
388
|
+
validate_id(value.get("terminal_state_ref"), f"{path}.terminal_state_ref", pattern, errors)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def validate_action(
|
|
392
|
+
value: Any,
|
|
393
|
+
path: str,
|
|
394
|
+
schema: dict[str, Any],
|
|
395
|
+
pattern: re.Pattern[str],
|
|
396
|
+
errors: list[dict[str, str]],
|
|
397
|
+
seen: set[str],
|
|
398
|
+
) -> None:
|
|
399
|
+
spec = object_spec(schema, "action")
|
|
400
|
+
if not strict_object(value, spec, path, errors):
|
|
401
|
+
return
|
|
402
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
403
|
+
if "name" in value:
|
|
404
|
+
validate_string(value.get("name"), f"{path}.name", errors)
|
|
405
|
+
for key in ("requirement_refs", "consumes", "produces", "constraint_rule_refs", "source_refs"):
|
|
406
|
+
validate_id_list(value.get(key), f"{path}.{key}", pattern, errors, min_items=1 if key in {"requirement_refs", "source_refs"} else 0)
|
|
407
|
+
for key in ("actor_ref", "owner_ref"):
|
|
408
|
+
validate_id(value.get(key), f"{path}.{key}", pattern, errors)
|
|
409
|
+
validate_string(value.get("trigger"), f"{path}.trigger", errors)
|
|
410
|
+
state_keys = {"entity_ref", "from_state_ref", "to_state_ref"}
|
|
411
|
+
present = state_keys.intersection(value)
|
|
412
|
+
if present and present != state_keys:
|
|
413
|
+
add_error(errors, "BUSINESS_STATE_REF_PARTIAL", path, "entity_ref, from_state_ref, and to_state_ref must be all present or all absent")
|
|
414
|
+
for key in present:
|
|
415
|
+
validate_id(value.get(key), f"{path}.{key}", pattern, errors)
|
|
416
|
+
validate_routing(value.get("routing"), f"{path}.routing", schema, pattern, errors)
|
|
417
|
+
failures = value.get("failure_modes")
|
|
418
|
+
if not isinstance(failures, list):
|
|
419
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.failure_modes", "array required")
|
|
420
|
+
else:
|
|
421
|
+
failure_codes: set[str] = set()
|
|
422
|
+
for index, failure in enumerate(failures):
|
|
423
|
+
failure_path = f"{path}.failure_modes[{index}]"
|
|
424
|
+
validate_failure_mode(failure, failure_path, schema, pattern, errors)
|
|
425
|
+
if isinstance(failure, dict) and isinstance(failure.get("code"), str):
|
|
426
|
+
if failure["code"] in failure_codes:
|
|
427
|
+
add_error(errors, "BUSINESS_DUPLICATE_ID", f"{failure_path}.code", f"duplicate failure code {failure['code']}")
|
|
428
|
+
failure_codes.add(failure["code"])
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def validate_assumption(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]], seen: set[str]) -> None:
|
|
432
|
+
spec = object_spec(schema, "assumption")
|
|
433
|
+
if not strict_object(value, spec, path, errors):
|
|
434
|
+
return
|
|
435
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
436
|
+
validate_string(value.get("statement"), f"{path}.statement", errors)
|
|
437
|
+
validate_string(value.get("proposed_by"), f"{path}.proposed_by", errors)
|
|
438
|
+
validate_id_list(value.get("affected_refs"), f"{path}.affected_refs", pattern, errors, min_items=1)
|
|
439
|
+
if value.get("status") != "unresolved":
|
|
440
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.status", "AI proposal assumptions must remain unresolved")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def validate_conflict(value: Any, path: str, schema: dict[str, Any], pattern: re.Pattern[str], errors: list[dict[str, str]], seen: set[str]) -> None:
|
|
444
|
+
spec = object_spec(schema, "conflict")
|
|
445
|
+
if not strict_object(value, spec, path, errors):
|
|
446
|
+
return
|
|
447
|
+
register_id(value.get("id"), f"{path}.id", pattern, errors, seen)
|
|
448
|
+
validate_id(value.get("subject_ref"), f"{path}.subject_ref", pattern, errors)
|
|
449
|
+
validate_string(value.get("field"), f"{path}.field", errors)
|
|
450
|
+
if value.get("status") not in {"unresolved", "resolved"}:
|
|
451
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.status", "invalid conflict status")
|
|
452
|
+
alternatives = value.get("alternatives")
|
|
453
|
+
if not isinstance(alternatives, list) or len(alternatives) < 2:
|
|
454
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", f"{path}.alternatives", "at least two alternatives are required")
|
|
455
|
+
return
|
|
456
|
+
alternative_spec = object_spec(schema, "alternative")
|
|
457
|
+
for index, alternative in enumerate(alternatives):
|
|
458
|
+
alt_path = f"{path}.alternatives[{index}]"
|
|
459
|
+
if strict_object(alternative, alternative_spec, alt_path, errors):
|
|
460
|
+
validate_string(alternative.get("proposal_ref"), f"{alt_path}.proposal_ref", errors)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def validate_model(data: Any, schema: dict[str, Any]) -> list[dict[str, str]]:
|
|
464
|
+
errors: list[dict[str, str]] = []
|
|
465
|
+
top = schema.get("top_level", {})
|
|
466
|
+
if not isinstance(data, dict):
|
|
467
|
+
add_error(errors, "BUSINESS_SCHEMA_INVALID", "$", "root must be an object")
|
|
468
|
+
return errors
|
|
469
|
+
allowed = set(top.get("properties", {}))
|
|
470
|
+
forbidden = set(top.get("forbidden_derived", []))
|
|
471
|
+
for key in data:
|
|
472
|
+
if key in forbidden:
|
|
473
|
+
add_error(errors, "BUSINESS_DERIVED_FIELD_FORBIDDEN", key, f"derived field {key} cannot be authored")
|
|
474
|
+
elif key not in allowed:
|
|
475
|
+
add_error(errors, "BUSINESS_UNKNOWN_FIELD", key, f"unknown field {key}")
|
|
476
|
+
for key in top.get("required", []):
|
|
477
|
+
if key not in data:
|
|
478
|
+
add_error(errors, "BUSINESS_REQUIRED_FIELD_MISSING", key, f"required field {key} is missing")
|
|
479
|
+
if data.get("schema_version") != schema.get("schema_version", 1):
|
|
480
|
+
add_error(errors, "BUSINESS_SCHEMA_VERSION_INVALID", "schema_version", "schema version must match the contract")
|
|
481
|
+
feature = data.get("feature")
|
|
482
|
+
validate_string(feature, "feature", errors)
|
|
483
|
+
if isinstance(feature, str) and (re.search(r"[\\/\x00-\x1F\x7F]", feature) or feature in {".", ".."}):
|
|
484
|
+
add_error(errors, "BUSINESS_FEATURE_INVALID", "feature", "feature must be a single safe path segment")
|
|
485
|
+
validate_string(data.get("source_registry_ref"), "source_registry_ref", errors)
|
|
486
|
+
pattern = re.compile(str(schema.get("id_pattern", r"^[A-Za-z][A-Za-z0-9_-]*$")))
|
|
487
|
+
seen: set[str] = set()
|
|
488
|
+
collection_handlers = {
|
|
489
|
+
"actors": lambda value, path: validate_simple_object(value, "actor", path, schema, pattern, errors, seen),
|
|
490
|
+
"entities": lambda value, path: validate_entity(value, path, schema, pattern, errors, seen),
|
|
491
|
+
"components": lambda value, path: validate_simple_object(value, "component", path, schema, pattern, errors, seen),
|
|
492
|
+
"boundaries": lambda value, path: validate_simple_object(value, "boundary", path, schema, pattern, errors, seen),
|
|
493
|
+
"resources": lambda value, path: validate_resource(value, path, schema, pattern, errors, seen),
|
|
494
|
+
"rules": lambda value, path: validate_rule(value, path, schema, pattern, errors, seen),
|
|
495
|
+
"actions": lambda value, path: validate_action(value, path, schema, pattern, errors, seen),
|
|
496
|
+
"assumptions": lambda value, path: validate_assumption(value, path, schema, pattern, errors, seen),
|
|
497
|
+
}
|
|
498
|
+
for collection, handler in collection_handlers.items():
|
|
499
|
+
values = data.get(collection, [])
|
|
500
|
+
if not isinstance(values, list):
|
|
501
|
+
add_error(errors, "BUSINESS_INVALID_FIELD_VALUE", collection, "array required")
|
|
502
|
+
continue
|
|
503
|
+
for index, value in enumerate(values):
|
|
504
|
+
handler(value, f"{collection}[{index}]")
|
|
505
|
+
for key in ("requirement_refs", "decision_refs", "resolved_assumption_refs"):
|
|
506
|
+
validate_id_list(data.get(key), key, pattern, errors)
|
|
507
|
+
return errors
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def normalize(value: Any) -> Any:
|
|
511
|
+
if isinstance(value, str):
|
|
512
|
+
return unicodedata.normalize("NFKC", value).rstrip()
|
|
513
|
+
if isinstance(value, list):
|
|
514
|
+
return [normalize(item) for item in value]
|
|
515
|
+
if isinstance(value, dict):
|
|
516
|
+
return {key: normalize(item) for key, item in value.items()}
|
|
517
|
+
return value
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def canonical_order(data: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
521
|
+
canonical = schema.get("canonical", {})
|
|
522
|
+
orders = canonical.get("object_orders", {})
|
|
523
|
+
|
|
524
|
+
def ordered(value: Any, kind: str) -> Any:
|
|
525
|
+
if not isinstance(value, dict):
|
|
526
|
+
return value
|
|
527
|
+
order = orders.get(kind, [])
|
|
528
|
+
keys = [key for key in order if key in value]
|
|
529
|
+
keys.extend(sorted(key for key in value if key not in keys))
|
|
530
|
+
result: dict[str, Any] = {}
|
|
531
|
+
for key in keys:
|
|
532
|
+
item = value[key]
|
|
533
|
+
if kind == "entity" and key == "states":
|
|
534
|
+
result[key] = [ordered(x, "state") for x in item]
|
|
535
|
+
elif kind == "resource" and key == "origin":
|
|
536
|
+
result[key] = ordered(item, "resource_origin")
|
|
537
|
+
elif kind == "rule" and key == "branches":
|
|
538
|
+
result[key] = [ordered(x, "branch") for x in item]
|
|
539
|
+
elif kind in {"rule", "branch"} and key in {"expression", "when"}:
|
|
540
|
+
result[key] = ordered_condition(item)
|
|
541
|
+
elif kind == "action" and key == "routing":
|
|
542
|
+
result[key] = ordered(item, "routing")
|
|
543
|
+
elif kind == "action" and key == "failure_modes":
|
|
544
|
+
result[key] = [ordered(x, "failure_mode") for x in item]
|
|
545
|
+
elif kind == "conflict" and key == "alternatives":
|
|
546
|
+
result[key] = [ordered(x, "alternative") for x in item]
|
|
547
|
+
else:
|
|
548
|
+
result[key] = item
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
def ordered_operand(value: Any) -> Any:
|
|
552
|
+
return ordered(value, "operand")
|
|
553
|
+
|
|
554
|
+
def ordered_condition(value: Any) -> Any:
|
|
555
|
+
if not isinstance(value, dict):
|
|
556
|
+
return value
|
|
557
|
+
order = orders.get("condition", [])
|
|
558
|
+
keys = [key for key in order if key in value]
|
|
559
|
+
keys.extend(sorted(key for key in value if key not in keys))
|
|
560
|
+
result: dict[str, Any] = {}
|
|
561
|
+
for key in keys:
|
|
562
|
+
item = value[key]
|
|
563
|
+
if key == "args":
|
|
564
|
+
result[key] = [ordered_condition(x) for x in item]
|
|
565
|
+
elif key in {"operand", "left", "right"}:
|
|
566
|
+
result[key] = ordered_operand(item)
|
|
567
|
+
else:
|
|
568
|
+
result[key] = item
|
|
569
|
+
return result
|
|
570
|
+
|
|
571
|
+
root_order = canonical.get("root_order", [])
|
|
572
|
+
keys = [key for key in root_order if key in data]
|
|
573
|
+
keys.extend(sorted(key for key in data if key not in keys))
|
|
574
|
+
collection_kinds = {
|
|
575
|
+
"actors": "actor",
|
|
576
|
+
"entities": "entity",
|
|
577
|
+
"components": "component",
|
|
578
|
+
"boundaries": "boundary",
|
|
579
|
+
"resources": "resource",
|
|
580
|
+
"rules": "rule",
|
|
581
|
+
"actions": "action",
|
|
582
|
+
"assumptions": "assumption",
|
|
583
|
+
}
|
|
584
|
+
result: dict[str, Any] = {}
|
|
585
|
+
for key in keys:
|
|
586
|
+
if key in collection_kinds:
|
|
587
|
+
result[key] = [ordered(item, collection_kinds[key]) for item in data[key]]
|
|
588
|
+
else:
|
|
589
|
+
result[key] = data[key]
|
|
590
|
+
return result
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def canonical_payload(data: dict[str, Any], schema: dict[str, Any]) -> tuple[dict[str, Any], bytes, str]:
|
|
594
|
+
normalized = normalize(data)
|
|
595
|
+
ordered = canonical_order(normalized, schema)
|
|
596
|
+
encoded = (json.dumps(ordered, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
597
|
+
return ordered, encoded, "sha256:" + hashlib.sha256(encoded).hexdigest()
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def add_violation(violations: list[dict[str, Any]], code: str, subject_ref: str, **facts: Any) -> None:
|
|
601
|
+
violations.append({"code": code, "subject_ref": subject_ref, "facts": facts})
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def load_json(path: Path) -> Any:
|
|
605
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def requirement_nodes(root: Path, feature: str, violations: list[dict[str, Any]]) -> set[str]:
|
|
609
|
+
requirements_root = (root / ".planning" / "requirements").resolve()
|
|
610
|
+
path = (requirements_root / feature / "governance.json").resolve()
|
|
611
|
+
if not path.is_relative_to(requirements_root):
|
|
612
|
+
add_violation(violations, "BUSINESS_REQUIREMENT_GRAPH_INVALID", feature, path=str(path), message="feature escapes requirements directory")
|
|
613
|
+
return set()
|
|
614
|
+
try:
|
|
615
|
+
graph = load_json(path)
|
|
616
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
617
|
+
add_violation(violations, "BUSINESS_REQUIREMENT_GRAPH_INVALID", feature, path=str(path), message=str(exc))
|
|
618
|
+
return set()
|
|
619
|
+
return {
|
|
620
|
+
node.get("id")
|
|
621
|
+
for node in graph.get("nodes", [])
|
|
622
|
+
if isinstance(node, dict)
|
|
623
|
+
and node.get("kind") == "requirement"
|
|
624
|
+
and node.get("status") in {"confirmed", "approved"}
|
|
625
|
+
and isinstance(node.get("id"), str)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def resolve_sources(root: Path, feature: str, registry_ref: str, violations: list[dict[str, Any]]) -> set[str]:
|
|
630
|
+
result = subprocess.run(
|
|
631
|
+
["ruby", str(SOURCE_RESOLVER), "--root", str(root), "--feature", feature, "--registry", registry_ref, "--json"],
|
|
632
|
+
text=True,
|
|
633
|
+
capture_output=True,
|
|
634
|
+
check=False,
|
|
635
|
+
)
|
|
636
|
+
try:
|
|
637
|
+
payload = json.loads(result.stdout)
|
|
638
|
+
except json.JSONDecodeError:
|
|
639
|
+
add_violation(
|
|
640
|
+
violations,
|
|
641
|
+
"BUSINESS_PROVENANCE_SOURCE_INVALID",
|
|
642
|
+
"SOURCE-REGISTRY",
|
|
643
|
+
resolver_error=result.stderr.strip() or "invalid resolver output",
|
|
644
|
+
)
|
|
645
|
+
return set()
|
|
646
|
+
if result.returncode != 0 or not payload.get("ok"):
|
|
647
|
+
for error in payload.get("errors", []):
|
|
648
|
+
add_violation(
|
|
649
|
+
violations,
|
|
650
|
+
"BUSINESS_PROVENANCE_SOURCE_INVALID",
|
|
651
|
+
error.get("source_id") or "SOURCE-REGISTRY",
|
|
652
|
+
resolver_code=error.get("code"),
|
|
653
|
+
message=error.get("message"),
|
|
654
|
+
path=error.get("path"),
|
|
655
|
+
)
|
|
656
|
+
return set()
|
|
657
|
+
registry_path = root / registry_ref
|
|
658
|
+
try:
|
|
659
|
+
registry = load_yaml(registry_path)
|
|
660
|
+
except (OSError, ValueError) as exc:
|
|
661
|
+
add_violation(violations, "BUSINESS_PROVENANCE_SOURCE_INVALID", "SOURCE-REGISTRY", message=str(exc))
|
|
662
|
+
return set()
|
|
663
|
+
return {item.get("id") for item in registry.get("sources", []) if isinstance(item, dict) and isinstance(item.get("id"), str)}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def all_sourced_facts(model: dict[str, Any]):
|
|
667
|
+
for collection in ("actors", "components", "boundaries", "resources", "rules", "actions"):
|
|
668
|
+
for item in model.get(collection, []):
|
|
669
|
+
if isinstance(item, dict):
|
|
670
|
+
yield item.get("id", collection), item.get("source_refs", []), f"{collection}.{item.get('id', '?')}"
|
|
671
|
+
for entity in model.get("entities", []):
|
|
672
|
+
if not isinstance(entity, dict):
|
|
673
|
+
continue
|
|
674
|
+
yield entity.get("id", "entities"), entity.get("source_refs", []), f"entities.{entity.get('id', '?')}"
|
|
675
|
+
for state in entity.get("states", []):
|
|
676
|
+
if isinstance(state, dict):
|
|
677
|
+
yield state.get("id", "state"), state.get("source_refs", []), f"states.{state.get('id', '?')}"
|
|
678
|
+
for rule in model.get("rules", []):
|
|
679
|
+
if isinstance(rule, dict):
|
|
680
|
+
for branch in rule.get("branches", []):
|
|
681
|
+
if isinstance(branch, dict):
|
|
682
|
+
yield branch.get("id", "branch"), branch.get("source_refs", []), f"branches.{branch.get('id', '?')}"
|
|
683
|
+
for action in model.get("actions", []):
|
|
684
|
+
if isinstance(action, dict):
|
|
685
|
+
for failure in action.get("failure_modes", []):
|
|
686
|
+
if isinstance(failure, dict):
|
|
687
|
+
subject = f"{action.get('id', '?')}#{failure.get('code', '?')}"
|
|
688
|
+
yield subject, failure.get("source_refs", []), f"failure_modes.{subject}"
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def condition_refs(value: Any):
|
|
692
|
+
if not isinstance(value, dict):
|
|
693
|
+
return
|
|
694
|
+
for key, expected in (
|
|
695
|
+
("resource_ref", "resource"),
|
|
696
|
+
("entity_ref", "entity"),
|
|
697
|
+
("state_ref", "state"),
|
|
698
|
+
("actor_ref", "actor"),
|
|
699
|
+
):
|
|
700
|
+
if isinstance(value.get(key), str):
|
|
701
|
+
yield value[key], expected, key
|
|
702
|
+
for key in ("args",):
|
|
703
|
+
for item in value.get(key, []) if isinstance(value.get(key), list) else []:
|
|
704
|
+
yield from condition_refs(item)
|
|
705
|
+
for key in ("operand", "left", "right"):
|
|
706
|
+
if key in value:
|
|
707
|
+
yield from condition_refs(value[key])
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def check_reference_closure(model: dict[str, Any], violations: list[dict[str, Any]]) -> dict[str, Any]:
|
|
711
|
+
actors = {item["id"] for item in model.get("actors", [])}
|
|
712
|
+
entities = {item["id"] for item in model.get("entities", [])}
|
|
713
|
+
components = {item["id"] for item in model.get("components", [])}
|
|
714
|
+
boundaries = {item["id"] for item in model.get("boundaries", [])}
|
|
715
|
+
resources = {item["id"] for item in model.get("resources", [])}
|
|
716
|
+
rules = {item["id"] for item in model.get("rules", [])}
|
|
717
|
+
actions = {item["id"] for item in model.get("actions", [])}
|
|
718
|
+
state_owner: dict[str, str] = {}
|
|
719
|
+
state_terminal: dict[str, bool] = {}
|
|
720
|
+
for entity in model.get("entities", []):
|
|
721
|
+
for state in entity.get("states", []):
|
|
722
|
+
state_owner[state["id"]] = entity["id"]
|
|
723
|
+
state_terminal[state["id"]] = state.get("terminal") is True
|
|
724
|
+
namespaces = {
|
|
725
|
+
"actor": actors,
|
|
726
|
+
"entity": entities,
|
|
727
|
+
"component": components,
|
|
728
|
+
"boundary": boundaries,
|
|
729
|
+
"resource": resources,
|
|
730
|
+
"rule": rules,
|
|
731
|
+
"action": actions,
|
|
732
|
+
"state": set(state_owner),
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
def require(subject: str, field: str, ref: Any, expected: str):
|
|
736
|
+
if ref not in namespaces[expected]:
|
|
737
|
+
add_violation(violations, "BUSINESS_REF_NOT_FOUND", subject, field=field, ref=ref, expected=expected)
|
|
738
|
+
|
|
739
|
+
for resource in model.get("resources", []):
|
|
740
|
+
origin = resource["origin"]
|
|
741
|
+
kind = origin["kind"]
|
|
742
|
+
subject = resource["id"]
|
|
743
|
+
if kind == "produced_in_flow":
|
|
744
|
+
require(subject, "origin.lifecycle_owner_ref", origin.get("lifecycle_owner_ref"), "component")
|
|
745
|
+
elif kind == "user_supplied":
|
|
746
|
+
require(subject, "origin.actor_ref", origin.get("actor_ref"), "actor")
|
|
747
|
+
require(subject, "origin.intake_action_ref", origin.get("intake_action_ref"), "action")
|
|
748
|
+
elif kind == "external_system":
|
|
749
|
+
require(subject, "origin.boundary_ref", origin.get("boundary_ref"), "boundary")
|
|
750
|
+
require(subject, "origin.receive_action_ref", origin.get("receive_action_ref"), "action")
|
|
751
|
+
elif kind == "preexisting_project":
|
|
752
|
+
require(subject, "origin.lifecycle_owner_ref", origin.get("lifecycle_owner_ref"), "component")
|
|
753
|
+
|
|
754
|
+
for rule in model.get("rules", []):
|
|
755
|
+
subject = rule["id"]
|
|
756
|
+
for ref in rule.get("input_refs", []):
|
|
757
|
+
require(subject, "input_refs", ref, "resource")
|
|
758
|
+
for branch in rule.get("branches", []):
|
|
759
|
+
for ref in branch.get("next_action_refs", []):
|
|
760
|
+
require(branch["id"], "next_action_refs", ref, "action")
|
|
761
|
+
for ref, expected, field in condition_refs(branch.get("when")):
|
|
762
|
+
require(branch["id"], field, ref, expected)
|
|
763
|
+
for ref, expected, field in condition_refs(rule.get("expression")):
|
|
764
|
+
require(subject, field, ref, expected)
|
|
765
|
+
|
|
766
|
+
for action in model.get("actions", []):
|
|
767
|
+
subject = action["id"]
|
|
768
|
+
require(subject, "actor_ref", action.get("actor_ref"), "actor")
|
|
769
|
+
require(subject, "owner_ref", action.get("owner_ref"), "component")
|
|
770
|
+
for field in ("consumes", "produces"):
|
|
771
|
+
for ref in action.get(field, []):
|
|
772
|
+
require(subject, field, ref, "resource")
|
|
773
|
+
for ref in action.get("constraint_rule_refs", []):
|
|
774
|
+
require(subject, "constraint_rule_refs", ref, "rule")
|
|
775
|
+
routing = action.get("routing", {})
|
|
776
|
+
for ref in routing.get("next_action_refs", []):
|
|
777
|
+
require(subject, "routing.next_action_refs", ref, "action")
|
|
778
|
+
if routing.get("decision_rule_ref"):
|
|
779
|
+
require(subject, "routing.decision_rule_ref", routing["decision_rule_ref"], "rule")
|
|
780
|
+
if action.get("entity_ref"):
|
|
781
|
+
require(subject, "entity_ref", action["entity_ref"], "entity")
|
|
782
|
+
require(subject, "from_state_ref", action["from_state_ref"], "state")
|
|
783
|
+
require(subject, "to_state_ref", action["to_state_ref"], "state")
|
|
784
|
+
for field in ("from_state_ref", "to_state_ref"):
|
|
785
|
+
ref = action[field]
|
|
786
|
+
if ref in state_owner and state_owner[ref] != action["entity_ref"]:
|
|
787
|
+
add_violation(violations, "BUSINESS_STATE_ENTITY_MISMATCH", subject, field=field, state_ref=ref, entity_ref=action["entity_ref"])
|
|
788
|
+
for failure in action.get("failure_modes", []):
|
|
789
|
+
if failure.get("recovery_action_ref"):
|
|
790
|
+
require(subject, "failure.recovery_action_ref", failure["recovery_action_ref"], "action")
|
|
791
|
+
if failure.get("terminal_state_ref"):
|
|
792
|
+
require(subject, "failure.terminal_state_ref", failure["terminal_state_ref"], "state")
|
|
793
|
+
terminal = failure["terminal_state_ref"]
|
|
794
|
+
if action.get("entity_ref") and terminal in state_owner and state_owner[terminal] != action["entity_ref"]:
|
|
795
|
+
add_violation(
|
|
796
|
+
violations,
|
|
797
|
+
"BUSINESS_STATE_ENTITY_MISMATCH",
|
|
798
|
+
subject,
|
|
799
|
+
field="failure.terminal_state_ref",
|
|
800
|
+
state_ref=terminal,
|
|
801
|
+
entity_ref=action["entity_ref"],
|
|
802
|
+
)
|
|
803
|
+
return {
|
|
804
|
+
"actors": actors,
|
|
805
|
+
"entities": entities,
|
|
806
|
+
"components": components,
|
|
807
|
+
"boundaries": boundaries,
|
|
808
|
+
"resources": resources,
|
|
809
|
+
"rules": rules,
|
|
810
|
+
"actions": actions,
|
|
811
|
+
"state_owner": state_owner,
|
|
812
|
+
"state_terminal": state_terminal,
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def check_requirement_coverage(model: dict[str, Any], known: set[str], violations: list[dict[str, Any]]) -> None:
|
|
817
|
+
declared = set(model.get("requirement_refs", []))
|
|
818
|
+
covered: set[str] = set()
|
|
819
|
+
for action in model.get("actions", []):
|
|
820
|
+
covered.update(action.get("requirement_refs", []))
|
|
821
|
+
for ref in action.get("requirement_refs", []):
|
|
822
|
+
if ref not in known:
|
|
823
|
+
add_violation(violations, "BUSINESS_REQUIREMENT_REF_INVALID", action["id"], requirement_ref=ref)
|
|
824
|
+
for ref in declared:
|
|
825
|
+
if ref not in known:
|
|
826
|
+
add_violation(violations, "BUSINESS_REQUIREMENT_REF_INVALID", ref, requirement_ref=ref)
|
|
827
|
+
elif ref not in covered:
|
|
828
|
+
add_violation(violations, "BUSINESS_REQUIREMENT_UNCOVERED", ref, requirement_ref=ref, covering_actions=[])
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def check_provenance(model: dict[str, Any], known_sources: set[str], violations: list[dict[str, Any]]) -> None:
|
|
832
|
+
for subject, refs, path in all_sourced_facts(model):
|
|
833
|
+
missing = [ref for ref in refs if ref not in known_sources]
|
|
834
|
+
if missing:
|
|
835
|
+
add_violation(violations, "BUSINESS_PROVENANCE_SOURCE_INVALID", subject, path=path, missing_source_refs=missing)
|
|
836
|
+
for resource in model.get("resources", []):
|
|
837
|
+
origin = resource.get("origin", {})
|
|
838
|
+
code_source_ref = origin.get("code_source_ref") if origin.get("kind") == "preexisting_project" else None
|
|
839
|
+
if code_source_ref and code_source_ref not in known_sources:
|
|
840
|
+
add_violation(
|
|
841
|
+
violations,
|
|
842
|
+
"BUSINESS_PROVENANCE_SOURCE_INVALID",
|
|
843
|
+
resource["id"],
|
|
844
|
+
path="origin.code_source_ref",
|
|
845
|
+
missing_source_refs=[code_source_ref],
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def check_resources(model: dict[str, Any], violations: list[dict[str, Any]]) -> None:
|
|
850
|
+
producers: dict[str, list[str]] = {}
|
|
851
|
+
consumers: dict[str, list[str]] = {}
|
|
852
|
+
actions = {action["id"]: action for action in model.get("actions", [])}
|
|
853
|
+
for action in model.get("actions", []):
|
|
854
|
+
for ref in action.get("produces", []):
|
|
855
|
+
producers.setdefault(ref, []).append(action["id"])
|
|
856
|
+
for ref in action.get("consumes", []):
|
|
857
|
+
consumers.setdefault(ref, []).append(action["id"])
|
|
858
|
+
for resource in model.get("resources", []):
|
|
859
|
+
ref = resource["id"]
|
|
860
|
+
origin = resource["origin"]
|
|
861
|
+
kind = origin["kind"]
|
|
862
|
+
produced = producers.get(ref, [])
|
|
863
|
+
consumed = consumers.get(ref, [])
|
|
864
|
+
if kind == "produced_in_flow" and not produced:
|
|
865
|
+
add_violation(violations, "BUSINESS_RESOURCE_PRODUCER_MISSING", ref, consumers=consumed, producers=[])
|
|
866
|
+
if not consumed:
|
|
867
|
+
add_violation(violations, "BUSINESS_RESOURCE_CONSUMER_MISSING", ref, consumers=[], producers=produced)
|
|
868
|
+
if kind == "user_supplied":
|
|
869
|
+
intake = actions.get(origin.get("intake_action_ref"))
|
|
870
|
+
if not intake or ref not in intake.get("produces", []):
|
|
871
|
+
add_violation(violations, "BUSINESS_RESOURCE_INTAKE_INVALID", ref, intake_action_ref=origin.get("intake_action_ref"))
|
|
872
|
+
elif kind == "external_system":
|
|
873
|
+
receiver = actions.get(origin.get("receive_action_ref"))
|
|
874
|
+
if not receiver or ref not in receiver.get("consumes", []):
|
|
875
|
+
add_violation(violations, "BUSINESS_EXTERNAL_BOUNDARY_INVALID", ref, receive_action_ref=origin.get("receive_action_ref"))
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def check_rules(model: dict[str, Any], violations: list[dict[str, Any]]) -> None:
|
|
879
|
+
for rule in model.get("rules", []):
|
|
880
|
+
if rule.get("kind") != "decision":
|
|
881
|
+
continue
|
|
882
|
+
seen: dict[str, str] = {}
|
|
883
|
+
for branch in rule.get("branches", []):
|
|
884
|
+
if branch.get("default") is True:
|
|
885
|
+
continue
|
|
886
|
+
key = json.dumps(normalize(branch.get("when")), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
887
|
+
if key in seen:
|
|
888
|
+
add_violation(
|
|
889
|
+
violations,
|
|
890
|
+
"BUSINESS_RULE_BRANCH_DUPLICATE_CONDITION",
|
|
891
|
+
rule["id"],
|
|
892
|
+
branch_ids=[seen[key], branch["id"]],
|
|
893
|
+
)
|
|
894
|
+
else:
|
|
895
|
+
seen[key] = branch["id"]
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def check_states(model: dict[str, Any], namespaces: dict[str, Any], violations: list[dict[str, Any]]) -> None:
|
|
899
|
+
outgoing: dict[str, list[str]] = {state: [] for state in namespaces["state_owner"]}
|
|
900
|
+
incoming: dict[str, list[str]] = {state: [] for state in namespaces["state_owner"]}
|
|
901
|
+
for action in model.get("actions", []):
|
|
902
|
+
source = action.get("from_state_ref")
|
|
903
|
+
target = action.get("to_state_ref")
|
|
904
|
+
if source in outgoing and target in incoming:
|
|
905
|
+
outgoing[source].append(target)
|
|
906
|
+
incoming[target].append(source)
|
|
907
|
+
if source in outgoing:
|
|
908
|
+
for failure in action.get("failure_modes", []):
|
|
909
|
+
terminal = failure.get("terminal_state_ref")
|
|
910
|
+
if terminal in incoming:
|
|
911
|
+
outgoing[source].append(terminal)
|
|
912
|
+
incoming[terminal].append(source)
|
|
913
|
+
for state, terminal in namespaces["state_terminal"].items():
|
|
914
|
+
if not terminal and not outgoing[state]:
|
|
915
|
+
add_violation(violations, "BUSINESS_STATE_OUTGOING_MISSING", state, outgoing=[])
|
|
916
|
+
if not outgoing:
|
|
917
|
+
return
|
|
918
|
+
starts = [state for state in outgoing if not incoming[state]]
|
|
919
|
+
if not starts:
|
|
920
|
+
add_violation(violations, "BUSINESS_STATE_START_MISSING", "STATE-MACHINE", states=sorted(outgoing))
|
|
921
|
+
return
|
|
922
|
+
reachable = set(starts)
|
|
923
|
+
queue = list(starts)
|
|
924
|
+
while queue:
|
|
925
|
+
state = queue.pop(0)
|
|
926
|
+
for target in outgoing[state]:
|
|
927
|
+
if target not in reachable:
|
|
928
|
+
reachable.add(target)
|
|
929
|
+
queue.append(target)
|
|
930
|
+
for state in outgoing:
|
|
931
|
+
if state not in reachable:
|
|
932
|
+
add_violation(violations, "BUSINESS_STATE_UNREACHABLE", state, start_states=starts)
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
def flow_edges(model: dict[str, Any]) -> tuple[dict[str, list[str]], set[str]]:
|
|
936
|
+
rules = {rule["id"]: rule for rule in model.get("rules", [])}
|
|
937
|
+
edges = {action["id"]: [] for action in model.get("actions", [])}
|
|
938
|
+
terminals: set[str] = set()
|
|
939
|
+
for action in model.get("actions", []):
|
|
940
|
+
routing = action.get("routing", {})
|
|
941
|
+
if routing.get("terminal") is True:
|
|
942
|
+
terminals.add(action["id"])
|
|
943
|
+
edges[action["id"]].extend(routing.get("next_action_refs", []))
|
|
944
|
+
rule = rules.get(routing.get("decision_rule_ref"))
|
|
945
|
+
if rule:
|
|
946
|
+
for branch in rule.get("branches", []):
|
|
947
|
+
edges[action["id"]].extend(branch.get("next_action_refs", []))
|
|
948
|
+
for failure in action.get("failure_modes", []):
|
|
949
|
+
if failure.get("recovery_action_ref"):
|
|
950
|
+
edges[action["id"]].append(failure["recovery_action_ref"])
|
|
951
|
+
return edges, terminals
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def check_flow(model: dict[str, Any], violations: list[dict[str, Any]]) -> None:
|
|
955
|
+
edges, terminals = flow_edges(model)
|
|
956
|
+
if not edges:
|
|
957
|
+
return
|
|
958
|
+
incoming = {action: [] for action in edges}
|
|
959
|
+
for source, targets in edges.items():
|
|
960
|
+
for target in targets:
|
|
961
|
+
if target in incoming:
|
|
962
|
+
incoming[target].append(source)
|
|
963
|
+
starts = [action for action in edges if not incoming[action]]
|
|
964
|
+
if not starts:
|
|
965
|
+
add_violation(violations, "BUSINESS_FLOW_START_MISSING", "BUSINESS-FLOW", actions=sorted(edges))
|
|
966
|
+
reachable = set(starts)
|
|
967
|
+
queue = list(starts)
|
|
968
|
+
while queue:
|
|
969
|
+
action = queue.pop(0)
|
|
970
|
+
for target in edges[action]:
|
|
971
|
+
if target in edges and target not in reachable:
|
|
972
|
+
reachable.add(target)
|
|
973
|
+
queue.append(target)
|
|
974
|
+
for action in edges:
|
|
975
|
+
if action not in reachable:
|
|
976
|
+
add_violation(violations, "BUSINESS_FLOW_ACTION_UNREACHABLE", action, start_actions=starts)
|
|
977
|
+
reverse = {action: [] for action in edges}
|
|
978
|
+
for source, targets in edges.items():
|
|
979
|
+
for target in targets:
|
|
980
|
+
if target in reverse:
|
|
981
|
+
reverse[target].append(source)
|
|
982
|
+
can_finish = set(terminals)
|
|
983
|
+
queue = list(terminals)
|
|
984
|
+
while queue:
|
|
985
|
+
action = queue.pop(0)
|
|
986
|
+
for source in reverse[action]:
|
|
987
|
+
if source not in can_finish:
|
|
988
|
+
can_finish.add(source)
|
|
989
|
+
queue.append(source)
|
|
990
|
+
for action in edges:
|
|
991
|
+
if action not in can_finish:
|
|
992
|
+
add_violation(violations, "BUSINESS_FLOW_NO_TERMINAL_PATH", action, terminal_actions=sorted(terminals))
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def check_business_closure(model: dict[str, Any], root: Path) -> list[dict[str, Any]]:
|
|
996
|
+
violations: list[dict[str, Any]] = []
|
|
997
|
+
feature = model["feature"]
|
|
998
|
+
known_sources = resolve_sources(root, feature, model["source_registry_ref"], violations)
|
|
999
|
+
known_requirements = requirement_nodes(root, feature, violations)
|
|
1000
|
+
namespaces = check_reference_closure(model, violations)
|
|
1001
|
+
check_provenance(model, known_sources, violations)
|
|
1002
|
+
check_requirement_coverage(model, known_requirements, violations)
|
|
1003
|
+
check_resources(model, violations)
|
|
1004
|
+
check_rules(model, violations)
|
|
1005
|
+
check_states(model, namespaces, violations)
|
|
1006
|
+
check_flow(model, violations)
|
|
1007
|
+
for assumption in model.get("assumptions", []):
|
|
1008
|
+
add_violation(violations, "BUSINESS_ASSUMPTION_UNRESOLVED", assumption["id"], affected_refs=assumption.get("affected_refs", []))
|
|
1009
|
+
return violations
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def main() -> int:
|
|
1013
|
+
parser = argparse.ArgumentParser()
|
|
1014
|
+
parser.add_argument("--proposal", type=Path, required=True)
|
|
1015
|
+
parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
|
|
1016
|
+
parser.add_argument("--canonical-out", type=Path)
|
|
1017
|
+
parser.add_argument("--check-closure", action="store_true")
|
|
1018
|
+
parser.add_argument("--root", type=Path)
|
|
1019
|
+
args = parser.parse_args()
|
|
1020
|
+
try:
|
|
1021
|
+
schema = load_yaml(args.schema)
|
|
1022
|
+
data = load_yaml(args.proposal)
|
|
1023
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1024
|
+
payload = {
|
|
1025
|
+
"valid": False,
|
|
1026
|
+
"verdict": "block_unsafe",
|
|
1027
|
+
"errors": [{"code": "BUSINESS_YAML_INVALID", "path": "$", "message": str(exc)}],
|
|
1028
|
+
}
|
|
1029
|
+
print(json.dumps(payload, ensure_ascii=False))
|
|
1030
|
+
return 1
|
|
1031
|
+
errors = validate_model(data, schema)
|
|
1032
|
+
if errors:
|
|
1033
|
+
print(json.dumps({"valid": False, "verdict": "block_unsafe", "errors": errors}, ensure_ascii=False))
|
|
1034
|
+
return 1
|
|
1035
|
+
canonical, encoded, fingerprint = canonical_payload(data, schema)
|
|
1036
|
+
violations: list[dict[str, Any]] = []
|
|
1037
|
+
if args.check_closure:
|
|
1038
|
+
if args.root is None:
|
|
1039
|
+
violations = [{"code": "BUSINESS_CHECKER_CONFIGURATION_INVALID", "subject_ref": "BUSINESS-MODEL", "facts": {"message": "--root is required with --check-closure"}}]
|
|
1040
|
+
else:
|
|
1041
|
+
violations = check_business_closure(canonical, args.root.resolve())
|
|
1042
|
+
if args.canonical_out:
|
|
1043
|
+
args.canonical_out.parent.mkdir(parents=True, exist_ok=True)
|
|
1044
|
+
args.canonical_out.write_bytes(encoded)
|
|
1045
|
+
print(
|
|
1046
|
+
json.dumps(
|
|
1047
|
+
{
|
|
1048
|
+
"valid": True,
|
|
1049
|
+
"verdict": "allow",
|
|
1050
|
+
"errors": [],
|
|
1051
|
+
"closure_ok": not violations,
|
|
1052
|
+
"violations": violations,
|
|
1053
|
+
"canonical": canonical,
|
|
1054
|
+
"canonical_fingerprint": fingerprint,
|
|
1055
|
+
},
|
|
1056
|
+
ensure_ascii=False,
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
return 0 if not violations else 1
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
if __name__ == "__main__":
|
|
1063
|
+
sys.exit(main())
|