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,100 @@
1
+ // D-contract-csv: a spreadsheet-shaped projection of a feature contract -- one row per operation,
2
+ // opened by someone who will never read a JSON Schema. Pure module, no I/O, no gate awareness, no
3
+ // process -- mirrors contracts/export.mjs's own contract (see that file's header comment) exactly,
4
+ // so this file can be unit-tested with hand-built `contract` objects and nothing else.
5
+ //
6
+ // C1: a fixed 14-column header, always -- see C2 in DECISIONS.md for why a column is never
7
+ // dropped just because every operation leaves it blank (a scan-only contract's `summary`/`tags`/
8
+ // `security` columns ARE the finding "nobody stated these", not something to hide by omitting the
9
+ // column). C3: `description` is deliberately NOT a column (measured average 2,442.7 bytes/op,
10
+ // larger than every other copied field combined -- see D-openapi-description).
11
+ export const CSV_COLUMNS = Object.freeze([
12
+ { name: 'operation_id', extract: (op, operationId) => operationId },
13
+ { name: 'verb', extract: (op) => op.verb },
14
+ { name: 'path', extract: (op) => op.path },
15
+ { name: 'path_params', extract: (op) => sortedKeys(op.pathParams?.properties).join(', ') },
16
+ { name: 'path_params_unverified', extract: (op) => (op.pathParamsHeuristic ?? []).join(', ') },
17
+ { name: 'body', extract: (op) => String(op.body) },
18
+ { name: 'request_body_required', extract: (op) => (op.requestBodyRequired == null ? '' : String(op.requestBodyRequired)) },
19
+ { name: 'request_body_fields', extract: (op) => sortedKeys(op.requestBodySchema?.properties).join(', ') },
20
+ { name: 'response_fields', extract: (op) => sortedKeys(op.responseSchema?.properties).join(', ') },
21
+ { name: 'error_fields', extract: (op) => sortedKeys(op.errorSchema?.properties).join(', ') },
22
+ { name: 'provenance', extract: (op) => op.provenance },
23
+ { name: 'summary', extract: (op) => op.sourceSummary ?? '' },
24
+ { name: 'tags', extract: (op) => (op.sourceTags ?? []).join(', ') },
25
+ { name: 'security', extract: (op) => extractSecurity(op.sourceSecurity) },
26
+ ]);
27
+
28
+ function sortedKeys(properties) {
29
+ return properties ? Object.keys(properties).sort() : [];
30
+ }
31
+
32
+ // `security: []` is a genuine positive claim ("this operation declared no auth requirement") --
33
+ // see schemas/feature-contract.schema.json's own sourceSecurity description. An ABSENT
34
+ // sourceSecurity means the source document said nothing about security for this operation at all.
35
+ // The two must render distinguishably, or a reviewer cannot tell "confirmed open" from "unknown".
36
+ function extractSecurity(sourceSecurity) {
37
+ if (sourceSecurity == null) return '';
38
+ if (sourceSecurity.length === 0) return '(source-declared: none)';
39
+ const schemeNames = new Set();
40
+ for (const requirement of sourceSecurity) {
41
+ for (const scheme of Object.keys(requirement)) schemeNames.add(scheme);
42
+ }
43
+ return [...schemeNames].sort().join(', ');
44
+ }
45
+
46
+ // C4: RFC 4180, hand-rolled -- quote a field iff it contains a double quote, comma, CR, or LF;
47
+ // an embedded double quote is escaped by doubling it. Deliberately does NOT quote a field that
48
+ // needs none of this (the negative case is what catches an over-eager escaper in tests). No new
49
+ // dependency: this is the entire rule CSV has needed since RFC 4180 (2005).
50
+ export function escapeCsvField(value) {
51
+ const str = String(value);
52
+ if (/["\n\r,]/.test(str)) {
53
+ return `"${str.replaceAll('"', '""')}"`;
54
+ }
55
+ return str;
56
+ }
57
+
58
+ // C4: LF line terminator (not RFC 4180's CRLF) -- every other artifact this project writes is
59
+ // LF, and these files get committed/diffed. No comment/provenance preamble: line 1 is always the
60
+ // header row, because a leading `#`/comment line breaks `pandas.read_csv`/`csv.DictReader`/every
61
+ // spreadsheet importer's default settings -- provenance goes to stderr/`--json`, never into the
62
+ // file itself.
63
+ export function toCsv(header, rows) {
64
+ const lines = [header, ...rows].map((row) => row.map(escapeCsvField).join(','));
65
+ return `${lines.join('\n')}\n`;
66
+ }
67
+
68
+ // C5: deliberately does NOT check gate state, does NOT read a scan report, does NOT look at
69
+ // path-prefix signals -- this is a pure projection of an already-loaded `contract` object. The
70
+ // caller (bin/bskel.mjs's cmdContractExportCsv) owns every refusal/warning decision; this
71
+ // function only ever succeeds or reports "there's nothing to export" for a genuinely empty
72
+ // contract.
73
+ export function buildContractCsv({ contract }) {
74
+ const operationIds = Object.keys(contract.operations).sort((a, b) => a.localeCompare(b));
75
+ if (operationIds.length === 0) {
76
+ return { ok: false, error: 'contract has zero operations' };
77
+ }
78
+
79
+ const header = CSV_COLUMNS.map((c) => c.name);
80
+ const rows = operationIds.map((operationId) => {
81
+ const op = contract.operations[operationId];
82
+ return CSV_COLUMNS.map((c) => c.extract(op, operationId));
83
+ });
84
+
85
+ // C2: coverage is reported alongside the file, never inferred by a reader staring at blank
86
+ // cells wondering whether the tool is broken.
87
+ const columns = CSV_COLUMNS.map((c, i) => ({
88
+ name: c.name,
89
+ populatedRows: rows.filter((row) => row[i] !== '').length,
90
+ }));
91
+ const emptyColumns = columns.filter((c) => c.populatedRows === 0).map((c) => c.name);
92
+
93
+ return {
94
+ ok: true,
95
+ csv: toCsv(header, rows),
96
+ rowCount: rows.length,
97
+ columns,
98
+ emptyColumns,
99
+ };
100
+ }
@@ -0,0 +1,143 @@
1
+ // D-business-rules (R9): the emit-side half of compiled business rules for java-spring. Its own
2
+ // file, sibling to observe.mjs and emit.mjs, for exactly the reason observe.mjs's own header gives:
3
+ // rules, observe, and handles are orthogonal capabilities that happen to share the same repo-wide
4
+ // "generated infra" pattern, not the same feature.
5
+ //
6
+ // This emitter is deliberately THIN. It renders two fixed infra classes and copies an
7
+ // already-compiled artifact onto the classpath -- it makes no decision about what a rule means.
8
+ // Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
9
+ // (R3), which is the invariant that lets three languages agree.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
14
+ import { detectJacksonPackage } from './emit.mjs';
15
+ import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
16
+ import { pascalCase } from '../../../rules/vocabulary.mjs';
17
+
18
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
19
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
20
+
21
+ // Repo-wide, shared across every feature that ever runs `bskel rules emit` -- RuleSetLoader
22
+ // discovers every `bskel/*.rules.json` classpath resource at startup rather than being regenerated
23
+ // per feature, so these files are true infra (create-once-per-repo, all-or-nothing conflict unit),
24
+ // the same treatment observe.mjs's own INFRA_FILES get. EnforceRules/RuleEnforcementAspect (R8) are
25
+ // the automatic field/cross wiring path -- transition rules stay a manual RuleCheck.checkTransitions
26
+ // call, since a transition guard needs the resource's current state, which no annotation can supply
27
+ // generically (see EnforceRules.java.tmpl's own javadoc).
28
+ const INFRA_FILES = [
29
+ { template: 'RuleSetLoader.java.tmpl', target: 'global/rules/RuleSetLoader.java' },
30
+ { template: 'RuleCheck.java.tmpl', target: 'global/rules/RuleCheck.java' },
31
+ { template: 'EnforceRules.java.tmpl', target: 'global/rules/EnforceRules.java' },
32
+ { template: 'RuleEnforcementAspect.java.tmpl', target: 'global/rules/RuleEnforcementAspect.java' },
33
+ ];
34
+
35
+ function render(templatePath, vars) {
36
+ let content = fs.readFileSync(templatePath, 'utf8');
37
+ for (const [key, value] of Object.entries(vars)) {
38
+ content = content.replaceAll(`{{${key}}}`, String(value));
39
+ }
40
+ return content;
41
+ }
42
+
43
+ function writeUnit(target, content) {
44
+ fs.mkdirSync(path.dirname(target), { recursive: true });
45
+ fs.writeFileSync(target, content);
46
+ }
47
+
48
+ // R5/Phase 3: one file per resource, one static method per derived field. A COMPLETE, compiling
49
+ // pure function -- never a stub -- matching R5's own "the call site stays the human's, the
50
+ // function itself does not" framing. Params are always `double`: this vocabulary's operator set
51
+ // (add/sub/mul/div, R3) is numeric-only by construction, so there is no type to infer beyond
52
+ // "a number" -- see rules/vocabulary.mjs's own DERIVED_OPS.
53
+ function renderDerivedClass(resource, rules, featureId, basePackage) {
54
+ const methods = rules.map((rule) => {
55
+ const params = rule.params.map((p) => `double ${p}`).join(', ');
56
+ const formula = renderExprInfix(rule.expr);
57
+ return `\t/** {@code ${rule.field} = ${formula}} -- rule "${rule.id}". */\n\tpublic static double compute${pascalCase(rule.field)}(${params}) {\n\t\treturn ${formula};\n\t}`;
58
+ });
59
+ return `package ${basePackage}.global.rules;
60
+
61
+ /**
62
+ * Generated by backend-skeleton ({@code bskel rules emit}) for feature ${featureId}.
63
+ *
64
+ * <p>D-business-rules (R5): a complete, compiling pure function per derived field -- never a
65
+ * stub. Nothing calls these methods; wire the call site yourself wherever a computed field on
66
+ * {@code ${resource}} should actually be set (e.g. before persisting).
67
+ */
68
+ public final class ${resource}Rules {
69
+
70
+ private ${resource}Rules() {}
71
+
72
+ ${methods.join('\n\n')}
73
+ }
74
+ `;
75
+ }
76
+
77
+ /**
78
+ * @param {object} args
79
+ * @param {object} args.artifact the already-compiled, already-schema-validated rules artifact
80
+ * (rules/store.mjs's loadRulesArtifact) -- this function never reads
81
+ * specs/ itself, the same split observe.mjs holds for the contract.
82
+ */
83
+ export function emitRulesJavaSpring({ repoRoot, featureId, artifact, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
84
+ const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
85
+ const jacksonPackage = detectJacksonPackage(repoRoot);
86
+
87
+ const infraUnits = INFRA_FILES.map((f) => ({
88
+ id: f.template,
89
+ templatePath: path.join(TEMPLATES_DIR, f.template),
90
+ targetAbs: path.join(javaSrcRoot, f.target),
91
+ rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
92
+ }));
93
+
94
+ // R5/Phase 3: one <Resource>Rules.java per resource with a derived field, emitted the SAME
95
+ // unconditional way the .rules.json spec resource below is -- a complete, never-hand-edited
96
+ // pure function file, the same category as RuleCheck.java itself, not a resolver stub. See
97
+ // groupDerivedByResource()'s own doc comment for the named cross-feature-collision limitation.
98
+ const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
99
+ const derivedUnits = derivedGroups.map(([resource, rules]) => ({
100
+ resource,
101
+ targetAbs: path.join(javaSrcRoot, 'global', 'rules', `${resource}Rules.java`),
102
+ content: renderDerivedClass(resource, rules, featureId, basePackage),
103
+ }));
104
+
105
+ const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
106
+
107
+ for (const unit of derivedUnits) {
108
+ const relPath = path.relative(repoRoot, unit.targetAbs);
109
+ const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
110
+ const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
111
+ if (!dryRun) writeUnit(unit.targetAbs, unit.content);
112
+ result.written.push(relPath);
113
+ const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
114
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
115
+ result.actions.push(actionEntry);
116
+ }
117
+
118
+ // The compiled artifact, copied verbatim onto the classpath. Byte-identical to the file
119
+ // `bskel rules check` already wrote and schema-validated -- deliberately NOT re-serialized
120
+ // here, so there is exactly one representation of these rules and no chance of the runtime
121
+ // executing something subtly different from what was audited. `kind: 'spec'` = always
122
+ // regenerated, not conflict-tracked (nobody hand-finishes a generated data file).
123
+ const content = `${JSON.stringify(artifact, null, '\t')}\n`;
124
+ const target = path.join(repoRoot, 'src', 'main', 'resources', 'bskel', `${featureId}.rules.json`);
125
+ const relPath = path.relative(repoRoot, target);
126
+ const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
127
+ const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
128
+ if (!dryRun) writeUnit(target, content);
129
+ result.written.push(relPath);
130
+ const actionEntry = { path: relPath, kind: 'spec', action };
131
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
132
+ result.actions.push(actionEntry);
133
+
134
+ return {
135
+ ...result,
136
+ postEmitNotes: [
137
+ `field/cross rules are LOADED but not yet ACTIVE: add @EnforceRules(operationId = "<operationId>") to the real controller method to have them checked automatically on every call.`,
138
+ `defaults to OBSERVE (logs to the "bskel.rules.violations" logger, never rejects a request) -- set bskel.rules.mode: enforce in your own application.yml when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
139
+ `transition rules are NOT checked by @EnforceRules -- they need the resource's CURRENT state, which no annotation can supply generically. Call RuleCheck.checkTransitions(rules, body, Map.of("/status", current.getStatus())) directly wherever your service layer has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
140
+ ...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${u.resource}Rules`).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.`] : []),
141
+ ],
142
+ };
143
+ }
@@ -0,0 +1,44 @@
1
+ package {{BASE_PACKAGE}}.global.rules;
2
+
3
+ import java.lang.annotation.ElementType;
4
+ import java.lang.annotation.Retention;
5
+ import java.lang.annotation.RetentionPolicy;
6
+ import java.lang.annotation.Target;
7
+
8
+ /**
9
+ * D-business-rules (R8/R9): apply this to an EXISTING controller method (never generated onto
10
+ * one -- a human decides which methods are worth checking) to have {@link RuleEnforcementAspect}
11
+ * run this operation's compiled FIELD and CROSS rules against the real request body.
12
+ *
13
+ * <p><b>Does not check TRANSITION rules.</b> A transition guard needs the resource's CURRENT
14
+ * persisted state, which this annotation's own request-scoped context has no way to obtain
15
+ * generically -- that is the one honest gap this automatic path leaves open, matching
16
+ * {@link RuleCheck#checkTransitions}'s own javadoc. Call it directly, with the current state your
17
+ * own service layer already has, wherever a transition needs checking.
18
+ *
19
+ * <p>Whether a violation is logged only, or rejects the request, is a RUNTIME property
20
+ * ({@code bskel.rules.mode: observe|enforce}, default {@code observe}) -- not baked into the
21
+ * generated code, so switching from observation to enforcement never requires re-running
22
+ * {@code bskel rules emit}. See {@link RuleEnforcementAspect} for the full behavior.
23
+ *
24
+ * <p>Must be applied to the CONCRETE implementation method, not an interface method it overrides
25
+ * -- same {@code AopUtils#getMostSpecificMethod} constraint {@code ObserveContract}'s own javadoc
26
+ * already documents.
27
+ *
28
+ * <p>Example:
29
+ * <pre>{@code
30
+ * @EnforceRules(operationId = "updateWidget")
31
+ * public ResponseEntity<WidgetResponse> updateWidget(@PathVariable UUID widgetId, @RequestBody UpdateWidgetRequest request) { ... }
32
+ * }</pre>
33
+ *
34
+ * <p>Generated by backend-skeleton ({@code bskel rules emit}). Requires {@code
35
+ * spring-boot-starter-aop} on the target repo's own classpath -- NOT added automatically, matching
36
+ * this project's established boundary of never auto-editing a target's build file.
37
+ */
38
+ @Retention(RetentionPolicy.RUNTIME)
39
+ @Target(ElementType.METHOD)
40
+ public @interface EnforceRules {
41
+
42
+ /** Must match a key in the emitted {@code <feature-id>.rules.json}'s own operations map. */
43
+ String operationId();
44
+ }
@@ -0,0 +1,215 @@
1
+ package {{BASE_PACKAGE}}.global.rules;
2
+
3
+ import {{JACKSON_PACKAGE}}.JsonNode;
4
+ import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.CrossRule;
5
+ import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.FieldRule;
6
+ import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.RuleOperation;
7
+ import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.TransitionRule;
8
+
9
+ import java.math.BigDecimal;
10
+ import java.util.ArrayList;
11
+ import java.util.List;
12
+ import java.util.Map;
13
+
14
+ /**
15
+ * D-business-rules (R3/R9): pure, static, non-Spring executor for one feature's compiled business
16
+ * rules. A DUMB executor by design -- every semantic decision (which assertions exist, whether an
17
+ * assertion may apply to a field's type, whether two fields are comparable, whether a transition's
18
+ * states are real) was already made in JS by {@code rules/compile.mjs} at {@code bskel rules check}
19
+ * time. This class never interprets a JSON Schema and never decides what is checkable; it only
20
+ * executes an already-simplified instruction set. That is the same boundary
21
+ * {@code ContractCheck}/{@code handles/observe-schema-projection.mjs} hold, and it is what lets the
22
+ * Java, Python, and TypeScript executors be guaranteed to agree.
23
+ *
24
+ * <p><b>Redaction invariant, inherited from {@code ContractCheck} and equally binding here:</b>
25
+ * {@link Violation#message()} MUST NEVER interpolate an observed payload value -- only the JSON
26
+ * Pointer, the rule id, and the rule's own declared bound. A bound is safe because it came from the
27
+ * contract or from {@code rules.yaml}, i.e. from the developer, never from a request. A real
28
+ * payload value must never leave this process, structurally rather than by convention.
29
+ *
30
+ * <p>Generated by backend-skeleton ({@code bskel rules emit}). Do not hand-edit -- change the
31
+ * source rule and regenerate.
32
+ */
33
+ public final class RuleCheck {
34
+
35
+ private RuleCheck() {}
36
+
37
+ public record Violation(String ruleId, String pointer, String kind, String message) {}
38
+
39
+ /**
40
+ * Runs every FIELD and CROSS rule for one operation against a request body. Transition rules are
41
+ * deliberately NOT run here -- see {@link #checkTransitions} for why they need an argument this
42
+ * method does not have.
43
+ */
44
+ public static List<Violation> check(RuleOperation rules, JsonNode body) {
45
+ List<Violation> violations = new ArrayList<>();
46
+ if (rules == null || body == null || !body.isObject()) return violations;
47
+ for (FieldRule rule : rules.field()) checkField(rule, body, violations);
48
+ for (CrossRule rule : rules.cross()) checkCross(rule, body, violations);
49
+ return violations;
50
+ }
51
+
52
+ private static JsonNode at(JsonNode body, String pointer) {
53
+ // Compiled pointers are always a single top-level "/field" segment (rules/compile.mjs
54
+ // refuses anything deeper), so this is a lookup, not a JSON Pointer implementation.
55
+ JsonNode node = body.get(pointer.substring(1));
56
+ return (node == null || node.isNull() || node.isMissingNode()) ? null : node;
57
+ }
58
+
59
+ private static void checkField(FieldRule rule, JsonNode body, List<Violation> out) {
60
+ JsonNode value = at(body, rule.pointer());
61
+ // An absent optional field is not a violation -- requiredness is the contract's own
62
+ // statement, enforced by ContractCheck, deliberately not duplicated in this vocabulary.
63
+ if (value == null) return;
64
+ switch (rule.assertion()) {
65
+ case "minLength" -> {
66
+ if (value.isTextual() && value.asText().length() < rule.value().intValue()) {
67
+ out.add(violation(rule.id(), rule.pointer(), "minLength", "shorter than the required minimum length of " + rule.value().intValue()));
68
+ }
69
+ }
70
+ case "maxLength" -> {
71
+ if (value.isTextual() && value.asText().length() > rule.value().intValue()) {
72
+ out.add(violation(rule.id(), rule.pointer(), "maxLength", "longer than the permitted maximum length of " + rule.value().intValue()));
73
+ }
74
+ }
75
+ case "minimum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) < 0, "is below the permitted minimum of ");
76
+ case "maximum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) > 0, "is above the permitted maximum of ");
77
+ case "exclusiveMinimum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) <= 0, "must be strictly greater than ");
78
+ case "exclusiveMaximum" -> compareNumber(rule, value, out, (actual, bound) -> actual.compareTo(bound) >= 0, "must be strictly less than ");
79
+ case "multipleOf" -> {
80
+ if (value.isNumber() && rule.value().decimalValue().signum() != 0) {
81
+ BigDecimal remainder = value.decimalValue().remainder(rule.value().decimalValue());
82
+ if (remainder.signum() != 0) {
83
+ out.add(violation(rule.id(), rule.pointer(), "multipleOf", "is not an exact multiple of " + rule.value().decimalValue().toPlainString()));
84
+ }
85
+ }
86
+ }
87
+ case "enum" -> {
88
+ boolean matched = false;
89
+ for (JsonNode allowed : rule.enumValues()) {
90
+ if (allowed.equals(value)) { matched = true; break; }
91
+ }
92
+ if (!matched) out.add(violation(rule.id(), rule.pointer(), "enum", "is not one of the " + rule.enumValues().size() + " permitted values"));
93
+ }
94
+ // No default branch that passes silently: an assertion name this executor does not know
95
+ // can only mean the artifact was produced by a NEWER bskel than the code generated here.
96
+ // Reported, never ignored -- the same posture ContractCheck's `unsupported` takes.
97
+ default -> out.add(violation(rule.id(), rule.pointer(), "unsupported",
98
+ "rule assertion \"" + rule.assertion() + "\" is not understood by this generated checker -- re-run `bskel rules emit` to regenerate it"));
99
+ }
100
+ }
101
+
102
+ private interface NumberPredicate { boolean violates(BigDecimal actual, BigDecimal bound); }
103
+
104
+ private static void compareNumber(FieldRule rule, JsonNode value, List<Violation> out, NumberPredicate predicate, String messagePrefix) {
105
+ if (!value.isNumber()) return;
106
+ BigDecimal bound = rule.value().decimalValue();
107
+ if (predicate.violates(value.decimalValue(), bound)) {
108
+ out.add(violation(rule.id(), rule.pointer(), rule.assertion(), messagePrefix + bound.toPlainString()));
109
+ }
110
+ }
111
+
112
+ private static void checkCross(CrossRule rule, JsonNode body, List<Violation> out) {
113
+ List<String> pointers = rule.pointers();
114
+ String first = pointers.get(0);
115
+ JsonNode a = at(body, first);
116
+
117
+ if ("requiredIf".equals(rule.assertion())) {
118
+ if (a != null && at(body, pointers.get(1)) == null) {
119
+ out.add(violation(rule.id(), pointers.get(1), "requiredIf", "is required because " + first + " is present"));
120
+ }
121
+ return;
122
+ }
123
+ if ("mutuallyExclusive".equals(rule.assertion())) {
124
+ List<String> present = new ArrayList<>();
125
+ for (String pointer : pointers) if (at(body, pointer) != null) present.add(pointer);
126
+ if (present.size() > 1) {
127
+ out.add(violation(rule.id(), first, "mutuallyExclusive", "at most one of " + String.join(", ", pointers) + " may be present, got " + present.size()));
128
+ }
129
+ return;
130
+ }
131
+
132
+ JsonNode b = at(body, pointers.get(1));
133
+ // Either side absent means there is nothing to compare. Deliberately not a violation:
134
+ // requiredness is the contract's statement, and a cross-field rule that also implied
135
+ // requiredness would enforce something the developer never declared.
136
+ if (a == null || b == null) return;
137
+
138
+ int cmp;
139
+ if (a.isNumber() && b.isNumber()) {
140
+ cmp = a.decimalValue().compareTo(b.decimalValue());
141
+ } else if (a.isTextual() && b.isTextual()) {
142
+ cmp = a.asText().compareTo(b.asText());
143
+ } else {
144
+ // rules/compile.mjs proved these two pointers were mutually comparable against the
145
+ // contract's declared types, so reaching here means the real payload disagrees with the
146
+ // contract. Reported as its own kind rather than silently passing.
147
+ out.add(violation(rule.id(), first, "incomparable", "could not be compared with " + pointers.get(1) + " -- the payload types differ from the contract's declared types"));
148
+ return;
149
+ }
150
+
151
+ boolean violated = switch (rule.assertion()) {
152
+ case "lt" -> cmp >= 0;
153
+ case "lte" -> cmp > 0;
154
+ case "gt" -> cmp <= 0;
155
+ case "gte" -> cmp < 0;
156
+ case "eq" -> cmp != 0;
157
+ case "neq" -> cmp == 0;
158
+ default -> false;
159
+ };
160
+ if (violated) {
161
+ out.add(violation(rule.id(), first, rule.assertion(), "must be " + symbolFor(rule.assertion()) + " " + pointers.get(1)));
162
+ } else if (!List.of("lt", "lte", "gt", "gte", "eq", "neq").contains(rule.assertion())) {
163
+ out.add(violation(rule.id(), first, "unsupported",
164
+ "rule assertion \"" + rule.assertion() + "\" is not understood by this generated checker -- re-run `bskel rules emit` to regenerate it"));
165
+ }
166
+ }
167
+
168
+ private static String symbolFor(String assertion) {
169
+ return switch (assertion) {
170
+ case "lt" -> "<"; case "lte" -> "<="; case "gt" -> ">"; case "gte" -> ">=";
171
+ case "eq" -> "equal to"; case "neq" -> "different from"; default -> assertion;
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Runs TRANSITION rules. Separate from {@link #check} because a transition guard is inherently a
177
+ * statement about a change, and a request body only carries the DESTINATION state -- the current
178
+ * state lives in the application's own datastore, which this class has no access to and must
179
+ * never acquire.
180
+ *
181
+ * <p>{@code currentStates} maps a rule's pointer (e.g. {@code "/status"}) to that resource's
182
+ * CURRENT persisted value, supplied by the caller. This is the one place a human must wire
183
+ * something -- the same honest boundary {@code ResourceResolver#patchField} draws.
184
+ *
185
+ * <p><b>Fail-closed:</b> a pointer with no entry in {@code currentStates} produces an
186
+ * {@code unchecked} violation, never a silent pass. A transition guard that quietly does nothing
187
+ * because nobody supplied the current state is exactly the "looks enforced, never fires" failure
188
+ * this whole item refuses elsewhere (see {@code RULE_TRANSITION_NOT_ENUM} in
189
+ * {@code rules/diagnostics.mjs}).
190
+ */
191
+ public static List<Violation> checkTransitions(RuleOperation rules, JsonNode body, Map<String, String> currentStates) {
192
+ List<Violation> violations = new ArrayList<>();
193
+ if (rules == null || body == null || !body.isObject()) return violations;
194
+ for (TransitionRule rule : rules.transition()) {
195
+ JsonNode target = at(body, rule.pointer());
196
+ if (target == null || !target.isTextual()) continue; // not changing this field
197
+ if (currentStates == null || !currentStates.containsKey(rule.pointer())) {
198
+ violations.add(violation(rule.id(), rule.pointer(), "unchecked",
199
+ "a transition guard is declared but the current state was not supplied, so it could not be evaluated -- pass it via checkTransitions(..., currentStates)"));
200
+ continue;
201
+ }
202
+ String from = currentStates.get(rule.pointer());
203
+ String to = target.asText();
204
+ if (from == null || from.equals(to)) continue; // no transition is occurring
205
+ if (rule.from().contains(from) && rule.to().contains(to)) continue; // explicitly allowed
206
+ violations.add(violation(rule.id(), rule.pointer(), "transition",
207
+ "this state change is not permitted -- allowed transitions are from {" + String.join(", ", rule.from()) + "} to {" + String.join(", ", rule.to()) + "}"));
208
+ }
209
+ return violations;
210
+ }
211
+
212
+ private static Violation violation(String ruleId, String pointer, String kind, String message) {
213
+ return new Violation(ruleId, pointer, kind, message);
214
+ }
215
+ }
@@ -0,0 +1,132 @@
1
+ package {{BASE_PACKAGE}}.global.rules;
2
+
3
+ import {{JACKSON_PACKAGE}}.JsonNode;
4
+ import {{JACKSON_PACKAGE}}.ObjectMapper;
5
+ import {{BASE_PACKAGE}}.global.rules.RuleSetLoader.RuleOperation;
6
+ import lombok.RequiredArgsConstructor;
7
+ import lombok.extern.slf4j.Slf4j;
8
+ import org.aspectj.lang.ProceedingJoinPoint;
9
+ import org.aspectj.lang.annotation.Around;
10
+ import org.aspectj.lang.annotation.Aspect;
11
+ import org.springframework.beans.factory.annotation.Value;
12
+ import org.springframework.http.HttpStatus;
13
+ import org.springframework.stereotype.Component;
14
+ import org.springframework.web.bind.annotation.RequestBody;
15
+ import org.springframework.web.server.ResponseStatusException;
16
+
17
+ import java.lang.annotation.Annotation;
18
+ import java.lang.reflect.Method;
19
+ import java.util.List;
20
+
21
+ /**
22
+ * D-business-rules (R8/R9): the opt-in AUTOMATIC half of business-rule checking -- exists only for
23
+ * methods a human has chosen to mark with {@link EnforceRules}. Checks FIELD and CROSS rules only;
24
+ * see {@link EnforceRules}'s own javadoc for why TRANSITION rules are excluded from this automatic
25
+ * path.
26
+ *
27
+ * <p><b>The observe/enforce split is a RUNTIME property, read once per call from {@code
28
+ * bskel.rules.mode} (default {@code "observe"}):</b>
29
+ * <ul>
30
+ * <li>{@code observe} (default): every violation is logged (see {@code bskel.rules.violations}
31
+ * logger), the wrapped call always proceeds. The same "measure before you enforce" posture
32
+ * {@code ContractObservationAspect} already established for contract shape checking -- see
33
+ * DECISIONS.md's D-openapi-request-schema for why turning validation on in a brownfield app
34
+ * is a real behavior change (a request that used to succeed can start failing), not a free
35
+ * safety improvement.</li>
36
+ * <li>{@code enforce}: a violation rejects the call with HTTP 400 BEFORE it reaches the wrapped
37
+ * method -- {@code joinPoint.proceed()} is never called. The rejection message may name a
38
+ * rule id, a JSON Pointer, and a rule's own declared bound; it NEVER contains an observed
39
+ * request value -- the same redaction invariant {@link RuleCheck#Violation} carries.</li>
40
+ * </ul>
41
+ *
42
+ * <p><b>Fail-open on an INTERNAL error, fail-closed on a REAL violation.</b> If this aspect itself
43
+ * cannot compute violations (a malformed request body, a loader problem), that is logged and the
44
+ * wrapped call proceeds unaffected, in BOTH modes -- an unrelated bug in this aspect must never
45
+ * become an outage for every request through an enforced endpoint. Only an actual, successfully
46
+ * computed rule violation can reject a call, and only in enforce mode.
47
+ *
48
+ * <p>Generated by backend-skeleton ({@code bskel rules emit}). Requires {@code
49
+ * spring-boot-starter-aop} on the classpath -- see {@link EnforceRules}'s own javadoc.
50
+ */
51
+ @Aspect
52
+ @Component
53
+ @RequiredArgsConstructor
54
+ @Slf4j
55
+ public class RuleEnforcementAspect {
56
+
57
+ private static final org.slf4j.Logger VIOLATIONS = org.slf4j.LoggerFactory.getLogger("bskel.rules.violations");
58
+
59
+ private final RuleSetLoader ruleSetLoader;
60
+ private final ObjectMapper objectMapper;
61
+
62
+ @Value("${bskel.rules.mode:observe}")
63
+ private String mode;
64
+
65
+ @Around("@annotation(enforceRules)")
66
+ public Object enforce(ProceedingJoinPoint joinPoint, EnforceRules enforceRules) throws Throwable {
67
+ String operationId = enforceRules.operationId();
68
+ RuleOperation rules = ruleSetLoader.forOperation(operationId);
69
+ if (rules.isEmpty()) {
70
+ // Nothing compiled for this operation -- most commonly a feature with no rules.yaml and
71
+ // a contract that projected nothing enforceable. Proceeds silently: an empty rule set is
72
+ // not a misconfiguration worth logging on every call.
73
+ return joinPoint.proceed();
74
+ }
75
+
76
+ List<RuleCheck.Violation> violations = safelyCheck(joinPoint, rules, operationId);
77
+ if (!violations.isEmpty()) {
78
+ logViolations(operationId, violations);
79
+ if ("enforce".equals(mode)) {
80
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, summarize(operationId, violations));
81
+ }
82
+ }
83
+ return joinPoint.proceed();
84
+ }
85
+
86
+ private List<RuleCheck.Violation> safelyCheck(ProceedingJoinPoint joinPoint, RuleOperation rules, String operationId) {
87
+ try {
88
+ Object requestBody = findRequestBodyArg(joinPoint);
89
+ if (requestBody == null) return List.of();
90
+ JsonNode body = objectMapper.valueToTree(requestBody);
91
+ return RuleCheck.check(rules, body);
92
+ } catch (Exception e) {
93
+ log.warn("RuleEnforcementAspect: could not evaluate rules for operation \"{}\" -- the wrapped call proceeds unaffected", operationId, e);
94
+ return List.of();
95
+ }
96
+ }
97
+
98
+ /** Same technique ContractObservationAspect's own findRequestBodyArg uses: a real, unambiguous `@RequestBody` signal on the method, never guessed by position. */
99
+ private static Object findRequestBodyArg(ProceedingJoinPoint joinPoint) {
100
+ if (!(joinPoint.getSignature() instanceof org.aspectj.lang.reflect.MethodSignature sig)) return null;
101
+ Method method = sig.getMethod();
102
+ Annotation[][] paramAnnotations = method.getParameterAnnotations();
103
+ Object[] args = joinPoint.getArgs();
104
+ for (int i = 0; i < paramAnnotations.length && i < args.length; i++) {
105
+ for (Annotation a : paramAnnotations[i]) {
106
+ if (a instanceof RequestBody) return args[i];
107
+ }
108
+ }
109
+ return null;
110
+ }
111
+
112
+ private void logViolations(String operationId, List<RuleCheck.Violation> violations) {
113
+ try {
114
+ for (RuleCheck.Violation v : violations) {
115
+ VIOLATIONS.warn("operation={} rule={} pointer={} kind={} mode={} -- {}", operationId, v.ruleId(), v.pointer(), v.kind(), mode, v.message());
116
+ }
117
+ } catch (Exception e) {
118
+ log.warn("RuleEnforcementAspect: could not log violations for operation \"{}\"", operationId, e);
119
+ }
120
+ }
121
+
122
+ /** Builds the HTTP 400 message from rule id / pointer / bound-derived text only -- never an observed value, the same redaction invariant every Violation#message() already holds. */
123
+ private static String summarize(String operationId, List<RuleCheck.Violation> violations) {
124
+ StringBuilder sb = new StringBuilder("business rule violation(s) for ").append(operationId).append(": ");
125
+ for (int i = 0; i < violations.size(); i++) {
126
+ if (i > 0) sb.append("; ");
127
+ RuleCheck.Violation v = violations.get(i);
128
+ sb.append(v.pointer()).append(" (").append(v.ruleId()).append("): ").append(v.message());
129
+ }
130
+ return sb.toString();
131
+ }
132
+ }