backend-skeleton 1.2.0 → 1.4.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.
Files changed (39) hide show
  1. package/README.md +100 -1
  2. package/bin/bskel.mjs +687 -6
  3. package/contracts/csv.mjs +100 -0
  4. package/handles/providers/java-spring/rules.mjs +143 -0
  5. package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
  6. package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
  7. package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
  8. package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
  9. package/handles/providers/python-fastapi/rules.mjs +133 -0
  10. package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
  11. package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
  12. package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
  13. package/handles/providers/typescript-express/rules.mjs +129 -0
  14. package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
  15. package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
  16. package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
  17. package/lib/cli.mjs +99 -1
  18. package/lib/doctor.mjs +11 -10
  19. package/lib/gate-definitions.mjs +27 -1
  20. package/lib/workflow.mjs +15 -0
  21. package/new/index.mjs +20 -0
  22. package/package.json +6 -2
  23. package/patterns/schema.sql +18 -0
  24. package/patterns/store.mjs +122 -0
  25. package/rules/compile.mjs +433 -0
  26. package/rules/derived.mjs +87 -0
  27. package/rules/diagnostics.mjs +147 -0
  28. package/rules/store.mjs +141 -0
  29. package/rules/vocabulary.mjs +172 -0
  30. package/scanners/adapters/_express-shared.mjs +7 -9
  31. package/scanners/adapters/java-spring.mjs +7 -9
  32. package/scanners/adapters/python-fastapi.mjs +7 -9
  33. package/scanners/db/erd.mjs +0 -0
  34. package/scanners/index.mjs +70 -17
  35. package/scanners/render.mjs +12 -3
  36. package/scanners/text-util.mjs +22 -0
  37. package/schemas/feature-rules.schema.json +139 -0
  38. package/schemas/pattern-record.schema.json +19 -0
  39. package/schemas/scan-report.schema.json +6 -2
@@ -0,0 +1,138 @@
1
+ package {{BASE_PACKAGE}}.global.rules;
2
+
3
+ import {{JACKSON_PACKAGE}}.JsonNode;
4
+ import {{JACKSON_PACKAGE}}.ObjectMapper;
5
+ import lombok.extern.slf4j.Slf4j;
6
+ import org.springframework.core.io.Resource;
7
+ import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
8
+ import org.springframework.stereotype.Component;
9
+
10
+ import java.io.IOException;
11
+ import java.io.InputStream;
12
+ import java.util.ArrayList;
13
+ import java.util.HashMap;
14
+ import java.util.List;
15
+ import java.util.Map;
16
+
17
+ /**
18
+ * D-business-rules (R9): loads every {@code bskel/*.rules.json} classpath resource (one per feature
19
+ * {@code bskel rules emit} has been run against) at startup and merges them into one flat map keyed
20
+ * by operationId. Same reasoning as {@code ObserveSchemaLoader}: a deployed app has no access to
21
+ * {@code specs/} at runtime, so the compiled artifact travels as a classpath resource.
22
+ *
23
+ * <p>This class does NO interpretation. It deserializes an artifact whose every semantic decision
24
+ * was already made and verified in JS by {@code rules/compile.mjs} -- see {@code RuleCheck}'s own
25
+ * javadoc for that boundary.
26
+ *
27
+ * <p>Generated by backend-skeleton ({@code bskel rules emit}). Do not hand-edit -- change the
28
+ * source rule and regenerate.
29
+ */
30
+ @Component
31
+ @Slf4j
32
+ public class RuleSetLoader {
33
+
34
+ /** {@code value} carries the declared bound for numeric/length assertions; {@code enumValues} is used only by the `enum` assertion. */
35
+ public record FieldRule(String id, String pointer, String assertion, JsonNode value, List<JsonNode> enumValues, String origin) {}
36
+
37
+ public record CrossRule(String id, List<String> pointers, String assertion, List<String> types, String origin) {}
38
+
39
+ public record TransitionRule(String id, String pointer, List<String> from, List<String> to, String origin) {}
40
+
41
+ public record RuleOperation(List<FieldRule> field, List<CrossRule> cross, List<TransitionRule> transition) {
42
+ public static final RuleOperation EMPTY = new RuleOperation(List.of(), List.of(), List.of());
43
+ public boolean isEmpty() { return field.isEmpty() && cross.isEmpty() && transition.isEmpty(); }
44
+ }
45
+
46
+ private final Map<String, RuleOperation> operationsById = new HashMap<>();
47
+ private final Map<String, String> contractRefByOperation = new HashMap<>();
48
+
49
+ public RuleSetLoader(ObjectMapper objectMapper) {
50
+ try {
51
+ Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:bskel/*.rules.json");
52
+ for (Resource resource : resources) {
53
+ try (InputStream in = resource.getInputStream()) {
54
+ load(objectMapper.readTree(in));
55
+ } catch (IOException e) {
56
+ // One unreadable resource must not take down application startup -- the same
57
+ // posture ObserveSchemaLoader takes for its own resources.
58
+ log.warn("bskel: could not read rules resource {} -- its rules will not be enforced", resource.getFilename(), e);
59
+ }
60
+ }
61
+ } catch (IOException e) {
62
+ log.warn("bskel: could not scan for bskel/*.rules.json rule resources -- no business rules will be enforced", e);
63
+ }
64
+ }
65
+
66
+ private void load(JsonNode doc) {
67
+ String contractRef = doc.path("contract_ref").asText("");
68
+ JsonNode operations = doc.path("operations");
69
+ operations.fieldNames().forEachRemaining((operationId) -> {
70
+ JsonNode entry = operations.path(operationId);
71
+ operationsById.put(operationId, new RuleOperation(
72
+ fieldRules(entry.path("field")),
73
+ crossRules(entry.path("cross")),
74
+ transitionRules(entry.path("transition"))
75
+ ));
76
+ contractRefByOperation.put(operationId, contractRef);
77
+ });
78
+ }
79
+
80
+ private static List<FieldRule> fieldRules(JsonNode array) {
81
+ List<FieldRule> rules = new ArrayList<>();
82
+ if (!array.isArray()) return rules;
83
+ for (JsonNode node : array) {
84
+ JsonNode value = node.path("value");
85
+ List<JsonNode> enumValues = new ArrayList<>();
86
+ if (value.isArray()) for (JsonNode item : value) enumValues.add(item);
87
+ rules.add(new FieldRule(
88
+ node.path("id").asText(), node.path("pointer").asText(), node.path("assert").asText(),
89
+ value, List.copyOf(enumValues), node.path("origin").asText()
90
+ ));
91
+ }
92
+ return List.copyOf(rules);
93
+ }
94
+
95
+ private static List<CrossRule> crossRules(JsonNode array) {
96
+ List<CrossRule> rules = new ArrayList<>();
97
+ if (!array.isArray()) return rules;
98
+ for (JsonNode node : array) {
99
+ rules.add(new CrossRule(
100
+ node.path("id").asText(), texts(node.path("pointers")), node.path("assert").asText(),
101
+ texts(node.path("types")), node.path("origin").asText()
102
+ ));
103
+ }
104
+ return List.copyOf(rules);
105
+ }
106
+
107
+ private static List<TransitionRule> transitionRules(JsonNode array) {
108
+ List<TransitionRule> rules = new ArrayList<>();
109
+ if (!array.isArray()) return rules;
110
+ for (JsonNode node : array) {
111
+ rules.add(new TransitionRule(
112
+ node.path("id").asText(), node.path("pointer").asText(),
113
+ texts(node.path("from")), texts(node.path("to")), node.path("origin").asText()
114
+ ));
115
+ }
116
+ return List.copyOf(rules);
117
+ }
118
+
119
+ private static List<String> texts(JsonNode array) {
120
+ List<String> out = new ArrayList<>();
121
+ if (array.isArray()) for (JsonNode node : array) out.add(node.asText());
122
+ return List.copyOf(out);
123
+ }
124
+
125
+ /** Never null -- an operation with no rules returns {@link RuleOperation#EMPTY}. */
126
+ public RuleOperation forOperation(String operationId) {
127
+ return operationsById.getOrDefault(operationId, RuleOperation.EMPTY);
128
+ }
129
+
130
+ /** The sha256 of the contract these rules were compiled and verified against. */
131
+ public String contractRefFor(String operationId) {
132
+ return contractRefByOperation.getOrDefault(operationId, "");
133
+ }
134
+
135
+ public int operationCount() {
136
+ return operationsById.size();
137
+ }
138
+ }
@@ -0,0 +1,133 @@
1
+ // D-business-rules (R9): the emit-side half of compiled business rules for python-fastapi. Mirrors
2
+ // handles/providers/java-spring/rules.mjs's own shape exactly -- rules, observe, and handles are
3
+ // 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 `rules_schemas/` -- 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 { snakeCase } 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` -- rule_set.py
20
+ // discovers every `rules_schemas/*.rules.json` file at module-import 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 of the three
23
+ // templates need any {{VAR}} substitution -- same reasoning observe's own INFRA_FILES give (no
24
+ // {{PKG}}, this stays decoupled from handles/, cross-imports are relative `from . import ...`).
25
+ const INFRA_FILES = [
26
+ { template: '__init__.py.tmpl', target: '__init__.py' },
27
+ { template: 'rule_check.py.tmpl', target: 'rule_check.py' },
28
+ { template: 'rule_set.py.tmpl', target: 'rule_set.py' },
29
+ { template: 'enforce_rules.py.tmpl', target: 'enforce_rules.py' },
30
+ ];
31
+
32
+ function render(templatePath, vars) {
33
+ let content = fs.readFileSync(templatePath, 'utf8');
34
+ for (const [key, value] of Object.entries(vars)) {
35
+ content = content.replaceAll(`{{${key}}}`, String(value));
36
+ }
37
+ return content;
38
+ }
39
+
40
+ function writeUnit(target, content) {
41
+ fs.mkdirSync(path.dirname(target), { recursive: true });
42
+ fs.writeFileSync(target, content);
43
+ }
44
+
45
+ // R5/Phase 3: one module per resource, one function per derived field -- same "complete, never a
46
+ // stub" posture java-spring's own renderDerivedClass() holds. All params are plain numbers: this
47
+ // vocabulary's operator set (add/sub/mul/div) is numeric-only by construction.
48
+ function renderDerivedModule(resource, rules, featureId) {
49
+ const functions = rules.map((rule) => {
50
+ const params = rule.params.map(snakeCase).join(', ');
51
+ const refName = (name) => snakeCase(name);
52
+ const formula = renderExprInfix(rule.expr, refName);
53
+ return `def compute_${snakeCase(rule.field)}(${params}):\n """${rule.field} = ${formula} -- rule "${rule.id}"."""\n return ${formula}`;
54
+ });
55
+ return `"""Generated by backend-skeleton (bskel rules emit) for feature ${featureId}.
56
+
57
+ D-business-rules (R5): a complete, callable pure function per derived field -- never a stub.
58
+ Nothing calls these; wire the call site yourself wherever a computed field on ${resource} should
59
+ actually be set (e.g. before persisting).
60
+ """
61
+
62
+
63
+ ${functions.join('\n\n\n')}
64
+ `;
65
+ }
66
+
67
+ /**
68
+ * `plan` is the already-computed python-fastapi resource plan (bin/bskel.mjs calls
69
+ * planPythonFastApi() before this) -- only plan.importRoot/plan.topPackage are used, matching
70
+ * observe.mjs's own tolerance-of-unused-fields pattern. `artifact` is the already-compiled,
71
+ * already-schema-validated rules artifact (rules/store.mjs's loadRulesArtifact) -- this function
72
+ * never reads specs/ itself, the same split observe.mjs holds for the contract.
73
+ */
74
+ export function emitRulesPythonFastApi({ repoRoot, featureId, artifact, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
75
+ const rulesDir = path.join(plan.importRoot, plan.topPackage, 'rules');
76
+ // __init__.py.tmpl is shared verbatim with the handles/observe infra sets (an empty marker
77
+ // file) -- reuse the same template rather than duplicating a one-line file a third time.
78
+ const sharedInitTemplate = path.join(PROVIDER_ROOT, 'templates', '__init__.py.tmpl');
79
+
80
+ const infraUnits = INFRA_FILES.map((f) => ({
81
+ id: f.template,
82
+ templatePath: f.template === '__init__.py.tmpl' ? sharedInitTemplate : path.join(TEMPLATES_DIR, f.template),
83
+ targetAbs: path.join(rulesDir, f.target),
84
+ rendered: render(f.template === '__init__.py.tmpl' ? sharedInitTemplate : path.join(TEMPLATES_DIR, f.template), {}),
85
+ }));
86
+
87
+ // R5/Phase 3: one <resource>_rules.py per resource with a derived field, emitted the SAME
88
+ // unconditional way the *.rules.json spec resource below is. See java-spring's own
89
+ // groupDerivedByResource() comment for the named cross-feature-collision limitation.
90
+ const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
91
+ const derivedUnits = derivedGroups.map(([resource, rules]) => ({
92
+ resource,
93
+ targetAbs: path.join(rulesDir, `${snakeCase(resource)}_rules.py`),
94
+ content: renderDerivedModule(resource, rules, featureId),
95
+ }));
96
+
97
+ const result = emitUnits({ repoRoot, featureId, provider: 'python-fastapi', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
98
+
99
+ for (const unit of derivedUnits) {
100
+ const relPath = path.relative(repoRoot, unit.targetAbs);
101
+ const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
102
+ const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
103
+ if (!dryRun) writeUnit(unit.targetAbs, unit.content);
104
+ result.written.push(relPath);
105
+ const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
106
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
107
+ result.actions.push(actionEntry);
108
+ }
109
+
110
+ // The compiled artifact, copied verbatim -- byte-identical to the file `bskel rules check`
111
+ // already wrote and schema-validated, deliberately NOT re-serialized here, the same "exactly
112
+ // one representation of these rules" invariant java-spring's own emitter holds.
113
+ const content = `${JSON.stringify(artifact, null, '\t')}\n`;
114
+ const target = path.join(rulesDir, 'rules_schemas', `${featureId}.rules.json`);
115
+ const relPath = path.relative(repoRoot, target);
116
+ const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
117
+ const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
118
+ if (!dryRun) writeUnit(target, content);
119
+ result.written.push(relPath);
120
+ const actionEntry = { path: relPath, kind: 'spec', action };
121
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
122
+ result.actions.push(actionEntry);
123
+
124
+ return {
125
+ ...result,
126
+ postEmitNotes: [
127
+ `field/cross rules are LOADED but not yet ACTIVE: decorate the real route handler with @enforce_rules(operation_id="<operationId>", body_param="<arg name>") to have them checked automatically on every call.`,
128
+ `defaults to OBSERVE (logs to the "bskel.rules.violations" logger, 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.`,
129
+ `transition rules are NOT checked by @enforce_rules -- they need the resource's CURRENT state, which no decorator can supply generically. Call rule_check.check_transitions(rules, body, {"/status": current.status}) directly wherever your service layer has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
130
+ ...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${snakeCase(u.resource)}_rules.py`).join(', ')}: complete, callable 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.`] : []),
131
+ ],
132
+ };
133
+ }
@@ -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)