backend-skeleton 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bskel.mjs +308 -0
- package/contracts/emit.mjs +22 -6
- package/handles/providers/java-spring/plan.mjs +24 -3
- package/handles/providers/java-spring/rules.mjs +143 -0
- package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
- package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
- package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
- package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
- package/handles/providers/python-fastapi/rules.mjs +133 -0
- package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
- package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
- package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/handles/providers/typescript-express/rules.mjs +129 -0
- package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
- package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
- package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
- package/lib/cli.mjs +37 -0
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +9 -0
- package/package.json +2 -1
- package/rules/compile.mjs +433 -0
- package/rules/derived.mjs +87 -0
- package/rules/diagnostics.mjs +147 -0
- package/rules/store.mjs +141 -0
- package/rules/vocabulary.mjs +172 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +49 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/feature-rules.schema.json +139 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
and regenerate.
|
|
3
|
+
|
|
4
|
+
D-business-rules (R8/R9): the opt-in AUTOMATIC half of business-rule checking -- exists only for a
|
|
5
|
+
function a human has chosen to decorate with `@enforce_rules`. Checks FIELD and CROSS rules only;
|
|
6
|
+
see this decorator's own parameter docs for why TRANSITION rules are excluded from this automatic
|
|
7
|
+
path. Combines Java's separate `@EnforceRules` annotation + `RuleEnforcementAspect` interceptor
|
|
8
|
+
into ONE decorator, deliberately -- same reasoning observe_contract.py's own docstring already
|
|
9
|
+
gives: Python decorators natively ARE this ecosystem's method-interception mechanism.
|
|
10
|
+
|
|
11
|
+
Only `operation_id` is required. `body_param` is the one deliberately EXPLICIT (never guessed, see
|
|
12
|
+
D-resolver-scope in DECISIONS.md) parameter naming which of the wrapped function's own arguments is
|
|
13
|
+
the request body -- default `None` means "skip checking, nothing to check without a body".
|
|
14
|
+
|
|
15
|
+
The observe/enforce split is read from the `BSKEL_RULES_MODE` environment variable (default
|
|
16
|
+
`"observe"`) on every call, not baked in at decoration time -- switching a deployed app from
|
|
17
|
+
observation to enforcement is a config change, never a re-run of `bskel rules emit`. Same posture
|
|
18
|
+
java-spring's own `bskel.rules.mode` Spring property takes.
|
|
19
|
+
|
|
20
|
+
- `observe` (default): every violation is logged (the `bskel.rules.violations` logger), the
|
|
21
|
+
wrapped call always proceeds. The same "measure before you enforce" posture
|
|
22
|
+
`observe_contract`/`ContractObservationAspect` already establish for contract shape checking.
|
|
23
|
+
- `enforce`: a violation raises `fastapi.HTTPException(400, ...)` BEFORE the wrapped function is
|
|
24
|
+
called. The message may name a rule id, a JSON Pointer, and a rule's own declared bound; it NEVER
|
|
25
|
+
contains an observed request value -- the same redaction invariant `rule_check.Violation` carries.
|
|
26
|
+
|
|
27
|
+
Fail-open on an INTERNAL error, fail-closed on a REAL violation: if this decorator itself cannot
|
|
28
|
+
compute violations (a malformed body, a loader problem), that is logged and the wrapped call
|
|
29
|
+
proceeds unaffected, in BOTH modes.
|
|
30
|
+
|
|
31
|
+
CRITICAL, python-specific correctness requirement (same as observe_contract.py's own note):
|
|
32
|
+
detects whether the wrapped function is a coroutine function at DECORATION time and dispatches to
|
|
33
|
+
a genuinely separate async/sync wrapper -- a single wrapper naively calling an async function
|
|
34
|
+
without awaiting it would return an unawaited coroutine object as the "result".
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
@enforce_rules(operation_id="updateWidget", body_param="request")
|
|
38
|
+
async def update_widget(widget_id: str, request: UpdateWidgetRequest):
|
|
39
|
+
...
|
|
40
|
+
"""
|
|
41
|
+
import functools
|
|
42
|
+
import inspect
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
|
|
46
|
+
from fastapi import HTTPException
|
|
47
|
+
|
|
48
|
+
from . import rule_check
|
|
49
|
+
from . import rule_set
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
_VIOLATIONS = logging.getLogger("bskel.rules.violations")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _mode() -> str:
|
|
56
|
+
return os.environ.get("BSKEL_RULES_MODE", "observe")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_jsonable(value):
|
|
60
|
+
if hasattr(value, "model_dump"):
|
|
61
|
+
return value.model_dump(mode="json")
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _safely_check(rules: dict, body_value, operation_id: str) -> list:
|
|
66
|
+
try:
|
|
67
|
+
if body_value is None:
|
|
68
|
+
return []
|
|
69
|
+
return rule_check.check(rules, _to_jsonable(body_value))
|
|
70
|
+
except Exception:
|
|
71
|
+
logger.warning('enforce_rules: could not evaluate rules for operation "%s" -- the wrapped call proceeds unaffected', operation_id, exc_info=True)
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _log_violations(operation_id: str, violations: list, mode: str) -> None:
|
|
76
|
+
try:
|
|
77
|
+
for v in violations:
|
|
78
|
+
_VIOLATIONS.warning("operation=%s rule=%s pointer=%s kind=%s mode=%s -- %s", operation_id, v.rule_id, v.pointer, v.kind, mode, v.message)
|
|
79
|
+
except Exception:
|
|
80
|
+
logger.warning('enforce_rules: could not log violations for operation "%s"', operation_id, exc_info=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _summarize(operation_id: str, violations: list) -> str:
|
|
84
|
+
"""Built from rule id / pointer / bound-derived text only -- never an observed value, the same
|
|
85
|
+
redaction invariant every Violation.message already holds."""
|
|
86
|
+
parts = [f"{v.pointer} ({v.rule_id}): {v.message}" for v in violations]
|
|
87
|
+
return f"business rule violation(s) for {operation_id}: " + "; ".join(parts)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def enforce_rules(*, operation_id: str, body_param: str | None = None):
|
|
91
|
+
def decorator(fn):
|
|
92
|
+
signature = inspect.signature(fn)
|
|
93
|
+
|
|
94
|
+
def _evaluate(bound: inspect.BoundArguments):
|
|
95
|
+
rules = rule_set.for_operation(operation_id)
|
|
96
|
+
if not rules["field"] and not rules["cross"]:
|
|
97
|
+
return [] # nothing compiled for this operation -- not worth logging on every call
|
|
98
|
+
body_value = bound.arguments.get(body_param) if body_param is not None else None
|
|
99
|
+
return _safely_check(rules, body_value, operation_id)
|
|
100
|
+
|
|
101
|
+
if inspect.iscoroutinefunction(fn):
|
|
102
|
+
@functools.wraps(fn)
|
|
103
|
+
async def wrapper(*args, **kwargs):
|
|
104
|
+
bound = signature.bind(*args, **kwargs)
|
|
105
|
+
bound.apply_defaults()
|
|
106
|
+
violations = _evaluate(bound)
|
|
107
|
+
if violations:
|
|
108
|
+
mode = _mode()
|
|
109
|
+
_log_violations(operation_id, violations, mode)
|
|
110
|
+
if mode == "enforce":
|
|
111
|
+
raise HTTPException(status_code=400, detail=_summarize(operation_id, violations))
|
|
112
|
+
return await fn(*args, **kwargs)
|
|
113
|
+
else:
|
|
114
|
+
@functools.wraps(fn)
|
|
115
|
+
def wrapper(*args, **kwargs):
|
|
116
|
+
bound = signature.bind(*args, **kwargs)
|
|
117
|
+
bound.apply_defaults()
|
|
118
|
+
violations = _evaluate(bound)
|
|
119
|
+
if violations:
|
|
120
|
+
mode = _mode()
|
|
121
|
+
_log_violations(operation_id, violations, mode)
|
|
122
|
+
if mode == "enforce":
|
|
123
|
+
raise HTTPException(status_code=400, detail=_summarize(operation_id, violations))
|
|
124
|
+
return fn(*args, **kwargs)
|
|
125
|
+
|
|
126
|
+
return wrapper
|
|
127
|
+
|
|
128
|
+
return decorator
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
and regenerate.
|
|
3
|
+
|
|
4
|
+
D-business-rules (R3/R9): pure, stdlib-only executor for one feature's compiled business rules.
|
|
5
|
+
A DUMB executor by design -- every semantic decision (which assertions exist, whether one applies
|
|
6
|
+
to a field's type, whether two fields are comparable, whether a transition's states are real) was
|
|
7
|
+
already made in JS by rules/compile.mjs at `bskel rules check` time. Mirrors
|
|
8
|
+
handles/providers/java-spring/templates/RuleCheck.java.tmpl exactly, field-for-field, so both
|
|
9
|
+
runtimes are guaranteed to agree -- see that file's own docstring for the full "one interpreter"
|
|
10
|
+
reasoning this project holds throughout.
|
|
11
|
+
|
|
12
|
+
Redaction invariant, inherited from contract_check.py and equally binding here: `Violation.message`
|
|
13
|
+
MUST NEVER interpolate an observed payload value -- only the JSON Pointer, the rule id, and the
|
|
14
|
+
rule's own declared bound (safe because it came from the contract or from rules.yaml, i.e. from the
|
|
15
|
+
developer, never from a request).
|
|
16
|
+
"""
|
|
17
|
+
from typing import NamedTuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Violation(NamedTuple):
|
|
21
|
+
rule_id: str
|
|
22
|
+
pointer: str
|
|
23
|
+
kind: str
|
|
24
|
+
message: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _at(body, pointer: str):
|
|
28
|
+
"""Compiled pointers are always a single top-level "/field" segment (rules/compile.mjs
|
|
29
|
+
refuses anything deeper) -- this is a dict lookup, not a JSON Pointer implementation."""
|
|
30
|
+
if not isinstance(body, dict):
|
|
31
|
+
return None
|
|
32
|
+
return body.get(pointer[1:])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_CROSS_SYMBOLS = {"lt": "<", "lte": "<=", "gt": ">", "gte": ">=", "eq": "equal to", "neq": "different from"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(rules: dict, body) -> list:
|
|
39
|
+
"""Runs every FIELD and CROSS rule in `rules` (one operation's own entry from a loaded
|
|
40
|
+
*.rules.json) against a request body. TRANSITION rules are deliberately NOT run here -- see
|
|
41
|
+
check_transitions() for why they need an argument this function does not have."""
|
|
42
|
+
violations: list = []
|
|
43
|
+
if not isinstance(body, dict):
|
|
44
|
+
return violations
|
|
45
|
+
for rule in rules.get("field", []):
|
|
46
|
+
violations.extend(_check_field(rule, body))
|
|
47
|
+
for rule in rules.get("cross", []):
|
|
48
|
+
violations.extend(_check_cross(rule, body))
|
|
49
|
+
return violations
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_field(rule: dict, body: dict) -> list:
|
|
53
|
+
value = _at(body, rule["pointer"])
|
|
54
|
+
if value is None:
|
|
55
|
+
# An absent optional field is not a violation -- requiredness is the contract's own
|
|
56
|
+
# statement, enforced by contract_check.py, deliberately not duplicated in this vocabulary.
|
|
57
|
+
return []
|
|
58
|
+
assertion = rule["assert"]
|
|
59
|
+
bound = rule.get("value")
|
|
60
|
+
|
|
61
|
+
if assertion == "minLength":
|
|
62
|
+
if isinstance(value, str) and len(value) < int(bound):
|
|
63
|
+
return [Violation(rule["id"], rule["pointer"], "minLength", f"shorter than the required minimum length of {int(bound)}")]
|
|
64
|
+
return []
|
|
65
|
+
if assertion == "maxLength":
|
|
66
|
+
if isinstance(value, str) and len(value) > int(bound):
|
|
67
|
+
return [Violation(rule["id"], rule["pointer"], "maxLength", f"longer than the permitted maximum length of {int(bound)}")]
|
|
68
|
+
return []
|
|
69
|
+
if assertion == "minimum":
|
|
70
|
+
return _compare_number(rule, value, bound, lambda a, b: a < b, "is below the permitted minimum of ")
|
|
71
|
+
if assertion == "maximum":
|
|
72
|
+
return _compare_number(rule, value, bound, lambda a, b: a > b, "is above the permitted maximum of ")
|
|
73
|
+
if assertion == "exclusiveMinimum":
|
|
74
|
+
return _compare_number(rule, value, bound, lambda a, b: a <= b, "must be strictly greater than ")
|
|
75
|
+
if assertion == "exclusiveMaximum":
|
|
76
|
+
return _compare_number(rule, value, bound, lambda a, b: a >= b, "must be strictly less than ")
|
|
77
|
+
if assertion == "multipleOf":
|
|
78
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool) and bound:
|
|
79
|
+
remainder = value % bound
|
|
80
|
+
if min(abs(remainder), abs(remainder - bound)) > 1e-9:
|
|
81
|
+
return [Violation(rule["id"], rule["pointer"], "multipleOf", f"is not an exact multiple of {bound}")]
|
|
82
|
+
return []
|
|
83
|
+
if assertion == "enum":
|
|
84
|
+
if value not in (bound or []):
|
|
85
|
+
return [Violation(rule["id"], rule["pointer"], "enum", f"is not one of the {len(bound or [])} permitted values")]
|
|
86
|
+
return []
|
|
87
|
+
# No fallthrough that passes silently: an assertion name this executor does not know can only
|
|
88
|
+
# mean the artifact was produced by a NEWER bskel than the code generated here. Reported, never
|
|
89
|
+
# ignored -- the same posture contract_check.py's own `unsupported` takes.
|
|
90
|
+
return [Violation(rule["id"], rule["pointer"], "unsupported", f'rule assertion "{assertion}" is not understood by this generated checker -- re-run `bskel rules emit` to regenerate it')]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _compare_number(rule: dict, value, bound, violates, message_prefix: str) -> list:
|
|
94
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
95
|
+
return []
|
|
96
|
+
if violates(value, bound):
|
|
97
|
+
return [Violation(rule["id"], rule["pointer"], rule["assert"], f"{message_prefix}{bound}")]
|
|
98
|
+
return []
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_cross(rule: dict, body: dict) -> list:
|
|
102
|
+
pointers = rule["pointers"]
|
|
103
|
+
assertion = rule["assert"]
|
|
104
|
+
first = pointers[0]
|
|
105
|
+
a = _at(body, first)
|
|
106
|
+
|
|
107
|
+
if assertion == "requiredIf":
|
|
108
|
+
if a is not None and _at(body, pointers[1]) is None:
|
|
109
|
+
return [Violation(rule["id"], pointers[1], "requiredIf", f"is required because {first} is present")]
|
|
110
|
+
return []
|
|
111
|
+
if assertion == "mutuallyExclusive":
|
|
112
|
+
present = [p for p in pointers if _at(body, p) is not None]
|
|
113
|
+
if len(present) > 1:
|
|
114
|
+
return [Violation(rule["id"], first, "mutuallyExclusive", f"at most one of {', '.join(pointers)} may be present, got {len(present)}")]
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
b = _at(body, pointers[1])
|
|
118
|
+
# Either side absent means there is nothing to compare -- see RuleCheck.java's own comment for
|
|
119
|
+
# why this is deliberately not a violation.
|
|
120
|
+
if a is None or b is None:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
124
|
+
# rules/compile.mjs proved these two pointers were mutually comparable against the
|
|
125
|
+
# contract's declared types, so reaching here means the real payload disagrees with the
|
|
126
|
+
# contract (a boolean where a number/string was declared).
|
|
127
|
+
return [Violation(rule["id"], first, "incomparable", f"could not be compared with {pointers[1]} -- the payload types differ from the contract's declared types")]
|
|
128
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
129
|
+
cmp = (a > b) - (a < b)
|
|
130
|
+
elif isinstance(a, str) and isinstance(b, str):
|
|
131
|
+
cmp = (a > b) - (a < b)
|
|
132
|
+
else:
|
|
133
|
+
return [Violation(rule["id"], first, "incomparable", f"could not be compared with {pointers[1]} -- the payload types differ from the contract's declared types")]
|
|
134
|
+
|
|
135
|
+
if assertion not in _CROSS_SYMBOLS:
|
|
136
|
+
return [Violation(rule["id"], first, "unsupported", f'rule assertion "{assertion}" is not understood by this generated checker -- re-run `bskel rules emit` to regenerate it')]
|
|
137
|
+
|
|
138
|
+
violated = {
|
|
139
|
+
"lt": cmp >= 0, "lte": cmp > 0, "gt": cmp <= 0, "gte": cmp < 0, "eq": cmp != 0, "neq": cmp == 0,
|
|
140
|
+
}[assertion]
|
|
141
|
+
if violated:
|
|
142
|
+
return [Violation(rule["id"], first, assertion, f"must be {_CROSS_SYMBOLS[assertion]} {pointers[1]}")]
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_transitions(rules: dict, body, current_states: dict | None) -> list:
|
|
147
|
+
"""Runs TRANSITION rules. Separate from check() because a transition guard is inherently a
|
|
148
|
+
statement about a change, and a request body only carries the DESTINATION state -- the current
|
|
149
|
+
state lives in the application's own datastore, which this function has no access to and must
|
|
150
|
+
never acquire.
|
|
151
|
+
|
|
152
|
+
`current_states` maps a rule's pointer (e.g. "/status") to that resource's CURRENT persisted
|
|
153
|
+
value, supplied by the caller. This is the one place a human must wire something -- the same
|
|
154
|
+
honest boundary resolver.py's own patch_field draws.
|
|
155
|
+
|
|
156
|
+
Fail-closed: a pointer with no entry in `current_states` produces an "unchecked" violation,
|
|
157
|
+
never a silent pass -- a transition guard that quietly does nothing because nobody supplied the
|
|
158
|
+
current state is exactly the "looks enforced, never fires" failure this whole item refuses
|
|
159
|
+
elsewhere (see RULE_TRANSITION_NOT_ENUM in rules/diagnostics.mjs).
|
|
160
|
+
"""
|
|
161
|
+
violations: list = []
|
|
162
|
+
if not isinstance(body, dict):
|
|
163
|
+
return violations
|
|
164
|
+
for rule in rules.get("transition", []):
|
|
165
|
+
target = _at(body, rule["pointer"])
|
|
166
|
+
if target is None or not isinstance(target, str):
|
|
167
|
+
continue # not changing this field
|
|
168
|
+
if current_states is None or rule["pointer"] not in current_states:
|
|
169
|
+
violations.append(Violation(rule["id"], rule["pointer"], "unchecked", "a transition guard is declared but the current state was not supplied, so it could not be evaluated -- pass it via check_transitions(..., current_states)"))
|
|
170
|
+
continue
|
|
171
|
+
from_state = current_states[rule["pointer"]]
|
|
172
|
+
to_state = target
|
|
173
|
+
if from_state is None or from_state == to_state:
|
|
174
|
+
continue # no transition is occurring
|
|
175
|
+
if from_state in rule["from"] and to_state in rule["to"]:
|
|
176
|
+
continue # explicitly allowed
|
|
177
|
+
violations.append(Violation(rule["id"], rule["pointer"], "transition", f"this state change is not permitted -- allowed transitions are from {{{', '.join(rule['from'])}}} to {{{', '.join(rule['to'])}}}"))
|
|
178
|
+
return violations
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
and regenerate.
|
|
3
|
+
|
|
4
|
+
D-business-rules (R9): loads every `<feature-id>.rules.json` file under this package's own
|
|
5
|
+
`rules_schemas/` directory (one per feature `bskel rules emit` has been run against) at MODULE
|
|
6
|
+
IMPORT time and merges them into one flat dict keyed by operationId. Mirrors observed_schema.py's
|
|
7
|
+
own shape exactly -- same "loaded once via Python's module cache" property, same plain-glob
|
|
8
|
+
discovery (this ecosystem only ever runs from source, never an installed wheel -- see that
|
|
9
|
+
module's own docstring for the full reasoning).
|
|
10
|
+
|
|
11
|
+
This module does NO interpretation. It deserializes an artifact whose every semantic decision was
|
|
12
|
+
already made and verified in JS by rules/compile.mjs -- see rule_check.py's own docstring for that
|
|
13
|
+
boundary.
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_SCHEMAS_DIR = Path(__file__).parent / "rules_schemas"
|
|
22
|
+
|
|
23
|
+
_EMPTY_OPERATION = {"field": [], "cross": [], "transition": []}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_one(path: Path) -> dict:
|
|
27
|
+
try:
|
|
28
|
+
raw = json.loads(path.read_text())
|
|
29
|
+
except (OSError, json.JSONDecodeError):
|
|
30
|
+
logger.warning("rule_set: could not read/parse %s -- its rules will not be enforced", path, exc_info=True)
|
|
31
|
+
return {}
|
|
32
|
+
operations = {}
|
|
33
|
+
for operation_id, entry in raw.get("operations", {}).items():
|
|
34
|
+
operations[operation_id] = {
|
|
35
|
+
"field": list(entry.get("field", [])),
|
|
36
|
+
"cross": list(entry.get("cross", [])),
|
|
37
|
+
"transition": list(entry.get("transition", [])),
|
|
38
|
+
}
|
|
39
|
+
return operations
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_all() -> dict:
|
|
43
|
+
merged: dict = {}
|
|
44
|
+
if not _SCHEMAS_DIR.is_dir():
|
|
45
|
+
return merged
|
|
46
|
+
for path in sorted(_SCHEMAS_DIR.glob("*.rules.json")):
|
|
47
|
+
for operation_id, ops in _load_one(path).items():
|
|
48
|
+
if operation_id in merged:
|
|
49
|
+
logger.warning('rule_set: operationId "%s" is declared by more than one *.rules.json on disk -- the last one loaded wins', operation_id)
|
|
50
|
+
merged[operation_id] = ops
|
|
51
|
+
return merged
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_OPERATIONS = _load_all()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def for_operation(operation_id: str) -> dict:
|
|
58
|
+
"""Never None -- an operation with no rules returns the shared empty operation dict."""
|
|
59
|
+
return _OPERATIONS.get(operation_id, _EMPTY_OPERATION)
|
|
@@ -57,7 +57,13 @@ function findFetchRoute(controllers, entityClassName) {
|
|
|
57
57
|
if (ep.verb !== 'GET') continue;
|
|
58
58
|
const suffix = ep.path.slice(controller.basePath.length);
|
|
59
59
|
if (/^\/:[^/(]+(\([^)]*\))?$/.test(suffix)) {
|
|
60
|
-
|
|
60
|
+
// X5 (D-route-expansion-provenance): threaded through so the caller can distinguish
|
|
61
|
+
// "inline arrow handler" (this provider's real, current no-method case) from "1:N
|
|
62
|
+
// framework-synthesized route" (a declaration is present) in its note text. null on
|
|
63
|
+
// every endpoint in today's adapter (it never populates declarationIndex) --
|
|
64
|
+
// forward-compatible only, not yet reachable.
|
|
65
|
+
const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
|
|
66
|
+
return { method: ep.method, path: ep.path, file: controller.file, line: ep.line, controllerClassName: controller.className, declaration };
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
}
|
|
@@ -183,7 +189,14 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
|
|
|
183
189
|
if (!fetchRoute) {
|
|
184
190
|
notes.push(`${entity.className}: no single-resource GET route found on a router whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
|
|
185
191
|
} else if (!fetchRoute.method) {
|
|
186
|
-
|
|
192
|
+
// X5 (D-route-expansion-provenance): a declaration means this route was expanded from a
|
|
193
|
+
// 1:N framework construct (no literal per-action method exists at all, not yet reachable
|
|
194
|
+
// in this adapter); no declaration keeps this provider's own real, current cause (an
|
|
195
|
+
// inline arrow-function handler) unchanged.
|
|
196
|
+
const note = fetchRoute.declaration
|
|
197
|
+
? `the matched endpoint (GET ${fetchRoute.path}) was expanded from ${fetchRoute.declaration.label ?? fetchRoute.declaration.rule} at ${path.relative(repoRoot, fetchRoute.file)}:${fetchRoute.declaration.line} (rule: ${fetchRoute.declaration.rule}) -- the framework generates this handler at runtime, so no literal per-action source method exists to correlate to. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`
|
|
198
|
+
: 'the single-resource GET route\'s handler is an inline function expression, not a named export -- nothing to correlate to a defining file, resolver NOT generated.';
|
|
199
|
+
notes.push(`${entity.className}: ${note}`);
|
|
187
200
|
} else if (!handlerFile) {
|
|
188
201
|
notes.push(`${entity.className}: could not resolve ${fetchRoute.method}'s own defining file (import, or one barrel hop, from ${path.relative(repoRoot, fetchRoute.file)}) -- resolver NOT generated.`);
|
|
189
202
|
} else if (!selectFields) {
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// D-business-rules (R9): the emit-side half of compiled business rules for typescript-express.
|
|
2
|
+
// Mirrors handles/providers/python-fastapi/rules.mjs's own shape -- rules, observe, and handles
|
|
3
|
+
// are orthogonal capabilities that happen to share the same repo-wide "generated infra" pattern.
|
|
4
|
+
//
|
|
5
|
+
// This emitter is deliberately THIN. It renders three fixed infra modules and copies an
|
|
6
|
+
// already-compiled artifact into `rulesSchemas/` -- it makes no decision about what a rule means.
|
|
7
|
+
// Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
|
|
8
|
+
// (R3), which is the invariant that lets three languages agree.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { emitUnits, unifiedDiff } from '../../_engine.mjs';
|
|
13
|
+
import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
|
|
14
|
+
import { pascalCase } from '../../../rules/vocabulary.mjs';
|
|
15
|
+
|
|
16
|
+
const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
18
|
+
|
|
19
|
+
// Repo-wide, shared across every feature that ever runs `bskel rules emit` -- ruleSet.ts
|
|
20
|
+
// discovers every `rulesSchemas/*.rules.json` file at module-load time rather than being
|
|
21
|
+
// regenerated per feature, so these files are true infra (create-once-per-repo, all-or-nothing
|
|
22
|
+
// conflict unit), the same treatment observe.mjs's own INFRA_FILES get. None need any {{VAR}}
|
|
23
|
+
// substitution -- same reasoning observe's own INFRA_FILES give.
|
|
24
|
+
const INFRA_FILES = [
|
|
25
|
+
{ template: 'ruleCheck.ts.tmpl', target: 'ruleCheck.ts' },
|
|
26
|
+
{ template: 'ruleSet.ts.tmpl', target: 'ruleSet.ts' },
|
|
27
|
+
{ template: 'enforceRules.ts.tmpl', target: 'enforceRules.ts' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function render(templatePath, vars) {
|
|
31
|
+
let content = fs.readFileSync(templatePath, 'utf8');
|
|
32
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
33
|
+
content = content.replaceAll(`{{${key}}}`, String(value));
|
|
34
|
+
}
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function writeUnit(target, content) {
|
|
39
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
40
|
+
fs.writeFileSync(target, content);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function camelCase(name) {
|
|
44
|
+
const pascal = pascalCase(name);
|
|
45
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// R5/Phase 3: one module per resource, one exported function per derived field -- same "complete,
|
|
49
|
+
// never a stub" posture java-spring's/python-fastapi's own renderers hold. All params are typed
|
|
50
|
+
// `number`: this vocabulary's operator set (add/sub/mul/div) is numeric-only by construction.
|
|
51
|
+
function renderDerivedModule(resource, rules, featureId) {
|
|
52
|
+
const functions = rules.map((rule) => {
|
|
53
|
+
const params = rule.params.map((p) => `${p}: number`).join(', ');
|
|
54
|
+
const formula = renderExprInfix(rule.expr);
|
|
55
|
+
return `/** \`${rule.field} = ${formula}\` -- rule "${rule.id}". */\nexport function compute${pascalCase(rule.field)}(${params}): number {\n\treturn ${formula};\n}`;
|
|
56
|
+
});
|
|
57
|
+
return `// Generated by backend-skeleton (bskel rules emit) for feature ${featureId}.
|
|
58
|
+
//
|
|
59
|
+
// D-business-rules (R5): a complete, compiling pure function per derived field -- never a stub.
|
|
60
|
+
// Nothing calls these; wire the call site yourself wherever a computed field on ${resource} should
|
|
61
|
+
// actually be set (e.g. before persisting).
|
|
62
|
+
|
|
63
|
+
${functions.join('\n\n')}
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `plan` is the already-computed typescript-express resource plan (bin/bskel.mjs calls
|
|
69
|
+
* planTypeScriptExpress() before this) -- only plan.srcRoot is used, matching observe.mjs's own
|
|
70
|
+
* tolerance-of-unused-fields pattern. `artifact` is the already-compiled, already-schema-validated
|
|
71
|
+
* rules artifact (rules/store.mjs's loadRulesArtifact) -- this function never reads specs/ itself.
|
|
72
|
+
*/
|
|
73
|
+
export function emitRulesTypeScriptExpress({ repoRoot, featureId, artifact, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
74
|
+
const rulesDir = path.join(plan.srcRoot, 'rules');
|
|
75
|
+
|
|
76
|
+
const infraUnits = INFRA_FILES.map((f) => ({
|
|
77
|
+
id: f.template,
|
|
78
|
+
templatePath: path.join(TEMPLATES_DIR, f.template),
|
|
79
|
+
targetAbs: path.join(rulesDir, f.target),
|
|
80
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), {}),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
// R5/Phase 3: one <resource>Rules.ts per resource with a derived field, emitted the SAME
|
|
84
|
+
// unconditional way the *.rules.json spec resource below is. See java-spring's own
|
|
85
|
+
// groupDerivedByResource() comment for the named cross-feature-collision limitation.
|
|
86
|
+
const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
|
|
87
|
+
const derivedUnits = derivedGroups.map(([resource, rules]) => ({
|
|
88
|
+
resource,
|
|
89
|
+
targetAbs: path.join(rulesDir, `${camelCase(resource)}Rules.ts`),
|
|
90
|
+
content: renderDerivedModule(resource, rules, featureId),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
94
|
+
|
|
95
|
+
for (const unit of derivedUnits) {
|
|
96
|
+
const relPath = path.relative(repoRoot, unit.targetAbs);
|
|
97
|
+
const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
|
|
98
|
+
const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
|
|
99
|
+
if (!dryRun) writeUnit(unit.targetAbs, unit.content);
|
|
100
|
+
result.written.push(relPath);
|
|
101
|
+
const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
|
|
102
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
|
|
103
|
+
result.actions.push(actionEntry);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The compiled artifact, copied verbatim -- byte-identical to the file `bskel rules check`
|
|
107
|
+
// already wrote and schema-validated, deliberately NOT re-serialized here, the same "exactly
|
|
108
|
+
// one representation of these rules" invariant every other provider's emitter holds.
|
|
109
|
+
const content = `${JSON.stringify(artifact, null, '\t')}\n`;
|
|
110
|
+
const target = path.join(rulesDir, 'rulesSchemas', `${featureId}.rules.json`);
|
|
111
|
+
const relPath = path.relative(repoRoot, target);
|
|
112
|
+
const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
|
|
113
|
+
const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
|
|
114
|
+
if (!dryRun) writeUnit(target, content);
|
|
115
|
+
result.written.push(relPath);
|
|
116
|
+
const actionEntry = { path: relPath, kind: 'spec', action };
|
|
117
|
+
if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
|
|
118
|
+
result.actions.push(actionEntry);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
...result,
|
|
122
|
+
postEmitNotes: [
|
|
123
|
+
`field/cross rules are LOADED but not yet ACTIVE: insert enforceRules("<operationId>") into the real route's own middleware array to have them checked automatically on every request.`,
|
|
124
|
+
`defaults to OBSERVE (logs violations to stderr, never rejects a request) -- set the BSKEL_RULES_MODE=enforce environment variable when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
|
|
125
|
+
`transition rules are NOT checked by enforceRules() -- they need the resource's CURRENT state, which no middleware can supply generically. Call ruleCheck.checkTransitions(rules, req.body, {"/status": current.status}) directly wherever your route handler has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
|
|
126
|
+
...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${camelCase(u.resource)}Rules.ts`).join(', ')}: complete, compiling pure functions, but NOTHING calls them -- wire the call site yourself. If another feature also declares a derived field on the same resource, whichever feature's \`rules emit\` runs LAST wins for that file -- not merged.`] : []),
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
|
|
2
|
+
// and regenerate.
|
|
3
|
+
//
|
|
4
|
+
// D-business-rules (R8/R9): the opt-in AUTOMATIC half of business-rule checking -- exists only for
|
|
5
|
+
// a route a human has chosen to insert enforceRules('...') into. Mirrors observeContract.ts's own
|
|
6
|
+
// choice: Express middleware IS this ecosystem's own interception mechanism, so there is no
|
|
7
|
+
// separate "declare a marker" vs. "implement the interceptor" split worth preserving here either.
|
|
8
|
+
//
|
|
9
|
+
// SIMPLER than observeContract.ts's response-capture dance: this middleware only ever checks the
|
|
10
|
+
// REQUEST (field/cross rules have no response-shape concept), so there is no res.json patching,
|
|
11
|
+
// no res.on('finish', ...) -- a violation in enforce mode is rejected synchronously, before
|
|
12
|
+
// `next()` is ever called, the same way any ordinary Express validation middleware works.
|
|
13
|
+
//
|
|
14
|
+
// Checks FIELD and CROSS rules only. Does NOT check TRANSITION rules -- a transition guard needs
|
|
15
|
+
// the resource's CURRENT persisted state, which this middleware's own request-scoped context has
|
|
16
|
+
// no generic way to obtain. Call checkTransitions() directly, with the current state your own
|
|
17
|
+
// route handler already has, wherever a transition needs checking.
|
|
18
|
+
//
|
|
19
|
+
// The observe/enforce split is read from the BSKEL_RULES_MODE environment variable (default
|
|
20
|
+
// "observe") on every request, not baked in at emit time -- switching a deployed app from
|
|
21
|
+
// observation to enforcement is a config change, never a re-run of `bskel rules emit`. Same
|
|
22
|
+
// posture java-spring's `bskel.rules.mode` / python-fastapi's own BSKEL_RULES_MODE take.
|
|
23
|
+
//
|
|
24
|
+
// - observe (default): every violation is logged to stderr, the request always proceeds.
|
|
25
|
+
// - enforce: a violation responds 400 BEFORE the route handler runs. The response body may name a
|
|
26
|
+
// rule id, a JSON Pointer, and a rule's own declared bound; it NEVER contains an observed
|
|
27
|
+
// request value -- the same redaction invariant Violation carries.
|
|
28
|
+
//
|
|
29
|
+
// Fail-open on an INTERNAL error, fail-closed on a REAL violation: if this middleware itself
|
|
30
|
+
// cannot compute violations (a malformed body, a loader problem), that is logged and the request
|
|
31
|
+
// proceeds unaffected, in BOTH modes.
|
|
32
|
+
//
|
|
33
|
+
// Example:
|
|
34
|
+
// router.patch('/widgets/:id', [enforceRules('updateWidget')], updateWidget);
|
|
35
|
+
|
|
36
|
+
import type { RequestHandler, Request, Response, NextFunction } from 'express';
|
|
37
|
+
import * as ruleCheck from './ruleCheck';
|
|
38
|
+
import * as ruleSet from './ruleSet';
|
|
39
|
+
import type { RuleOperation, Violation } from './ruleCheck';
|
|
40
|
+
|
|
41
|
+
function mode(): string {
|
|
42
|
+
return process.env.BSKEL_RULES_MODE ?? 'observe';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function safelyCheck(rules: RuleOperation, body: unknown, operationId: string): Violation[] {
|
|
46
|
+
try {
|
|
47
|
+
if (body === undefined) return [];
|
|
48
|
+
return ruleCheck.check(rules, body);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`enforceRules: could not evaluate rules for operation "${operationId}" -- the request proceeds unaffected`, err);
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function logViolations(operationId: string, violations: Violation[], activeMode: string): void {
|
|
56
|
+
try {
|
|
57
|
+
for (const v of violations) {
|
|
58
|
+
console.warn(`bskel.rules.violations operation=${operationId} rule=${v.ruleId} pointer=${v.pointer} kind=${v.kind} mode=${activeMode} -- ${v.message}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn(`enforceRules: could not log violations for operation "${operationId}"`, err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Built from rule id / pointer / bound-derived text only -- never an observed value, the same
|
|
66
|
+
* redaction invariant every Violation.message already holds. */
|
|
67
|
+
function summarize(operationId: string, violations: Violation[]): string {
|
|
68
|
+
const parts = violations.map((v) => `${v.pointer} (${v.ruleId}): ${v.message}`);
|
|
69
|
+
return `business rule violation(s) for ${operationId}: ${parts.join('; ')}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function enforceRules(operationId: string): RequestHandler {
|
|
73
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
74
|
+
const rules = ruleSet.forOperation(operationId);
|
|
75
|
+
if (rules.field.length === 0 && rules.cross.length === 0) {
|
|
76
|
+
// Nothing compiled for this operation -- most commonly a feature with no rules.yaml and
|
|
77
|
+
// a contract that projected nothing enforceable. Proceeds silently.
|
|
78
|
+
next();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const violations = safelyCheck(rules, req.body, operationId);
|
|
83
|
+
if (violations.length > 0) {
|
|
84
|
+
const activeMode = mode();
|
|
85
|
+
logViolations(operationId, violations, activeMode);
|
|
86
|
+
if (activeMode === 'enforce') {
|
|
87
|
+
res.status(400).json({ error: summarize(operationId, violations) });
|
|
88
|
+
return; // next() is never called -- the route handler must not run
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
next();
|
|
92
|
+
};
|
|
93
|
+
}
|