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,433 @@
1
+ // D-business-rules (R3/R4/R6): the compiler. Turns a hand-authored rules source document plus the
2
+ // feature's own contract into the bounded, deterministic artifact every target-language runtime
3
+ // executes.
4
+ //
5
+ // This is the ONE place rule semantics are decided -- the same doctrine
6
+ // handles/observe-schema-projection.mjs states for its own projection: "What's checkable is
7
+ // decided ONCE here, in JS ... each provider's own generated checker is a dumb, mechanical
8
+ // executor of an already-simplified instruction set, never a second independent JSON-Schema
9
+ // interpreter." Three languages can only be guaranteed to agree if they are given instructions,
10
+ // not expressions.
11
+ //
12
+ // PURE. No filesystem, no git, no CLI, no process.exit -- the caller owns I/O and exit codes, the
13
+ // same split contracts/emit.mjs holds against bin/bskel.mjs.
14
+ import {
15
+ FIELD_ASSERTS, CROSS_ASSERTS, PREDICATE_KINDS, ALL_RULE_KINDS, RULE_SCALAR_TYPES, DERIVED_OPS,
16
+ FIELD_ASSERT_NAMES, CROSS_ASSERT_NAMES,
17
+ getFieldAssert, getCrossAssert, valueMatchesType, typesAreComparable,
18
+ } from './vocabulary.mjs';
19
+ import { makeRuleDiagnostic, isBlocking } from './diagnostics.mjs';
20
+ import { collectParams } from './derived.mjs';
21
+
22
+ export const RULES_SCHEMA_VERSION = '1';
23
+ export const RULES_SOURCE_SCHEMA = 'sbf.feature-rules-source/1';
24
+
25
+ // JSON Schema keywords `handles/observe-schema-projection.mjs` already projects and every
26
+ // generated checker already enforces. Seeing one of these while projecting contract constraints is
27
+ // not an expressiveness gap -- it is already covered, one layer down -- so it is skipped silently
28
+ // rather than reported as unsupported. Keeping this list explicit (rather than "anything not in
29
+ // FIELD_ASSERTS") is what makes a genuinely-new keyword show up as a warning instead of vanishing.
30
+ const OBSERVE_OWNED_KEYWORDS = Object.freeze(new Set(['type', 'pattern', 'required', 'properties', 'additionalProperties', 'description', 'example', 'examples', 'title', 'default', 'nullable', 'deprecated', 'readOnly', 'writeOnly', '$id', '$schema', '$comment', 'format']));
31
+
32
+ // Only top-level `/field` pointers are addressable, matching observe-schema-projection.mjs's own
33
+ // scalar-leaf boundary exactly -- that module projects `schema.properties[key]` one level deep and
34
+ // marks anything deeper `unsupported`. Addressing deeper here would mean this vocabulary could
35
+ // describe constraints the runtime checkers have no projected value to evaluate against.
36
+ const TOP_LEVEL_POINTER_RE = /^\/[^/]+$/;
37
+
38
+ function pointerField(pointer) {
39
+ return typeof pointer === 'string' && TOP_LEVEL_POINTER_RE.test(pointer) ? pointer.slice(1) : null;
40
+ }
41
+
42
+ // Resolves a pointer against an operation's projected request body schema, returning the scalar
43
+ // leaf's own {type, enum} or a diagnostic-shaped reason it could not. Fail-closed: an operation
44
+ // with no requestBodySchema resolves NOTHING (rather than accepting every pointer blind), which is
45
+ // what RULE_NO_CONTRACT_SCHEMA reports.
46
+ function resolvePointer(opContract, pointer) {
47
+ const schema = opContract?.requestBodySchema;
48
+ if (!schema || typeof schema !== 'object' || schema.type !== 'object' || !schema.properties) {
49
+ return { ok: false, code: 'RULE_NO_CONTRACT_SCHEMA' };
50
+ }
51
+ const field = pointerField(pointer);
52
+ if (!field || !Object.hasOwn(schema.properties, field)) {
53
+ return { ok: false, code: 'RULE_UNKNOWN_POINTER', known: Object.keys(schema.properties).sort() };
54
+ }
55
+ const propSchema = schema.properties[field];
56
+ if (!propSchema || typeof propSchema !== 'object' || !RULE_SCALAR_TYPES.includes(propSchema.type)) {
57
+ return { ok: false, code: 'RULE_UNKNOWN_POINTER', known: Object.keys(schema.properties).sort(), nonScalar: true };
58
+ }
59
+ return { ok: true, type: propSchema.type, enum: Array.isArray(propSchema.enum) ? propSchema.enum : null };
60
+ }
61
+
62
+ // R4: every constraint the user's own OpenAPI document already asserts, transported into a rule.
63
+ // Not synthesis -- the same "copying is not synthesizing" posture D-openapi-passthrough (A7)
64
+ // established for the contract itself. A constraint this vocabulary cannot express is recorded in
65
+ // `unsupported[]`, never dropped.
66
+ export function projectContractRules(operationId, opContract) {
67
+ const rules = [];
68
+ const diagnostics = [];
69
+ const schema = opContract?.requestBodySchema;
70
+ if (!schema || typeof schema !== 'object' || schema.type !== 'object' || !schema.properties) {
71
+ return { rules, diagnostics };
72
+ }
73
+ for (const [field, propSchema] of Object.entries(schema.properties).sort(([a], [b]) => a.localeCompare(b))) {
74
+ const pointer = `/${field}`;
75
+ if (!propSchema || typeof propSchema !== 'object') continue;
76
+ if (!RULE_SCALAR_TYPES.includes(propSchema.type)) {
77
+ // Deeper than a scalar leaf -- observe already marks this pointer `unsupported` in its own
78
+ // projection; recorded here too so a rules audit is self-contained.
79
+ diagnostics.push(makeRuleDiagnostic('RULE_NON_SCALAR_TARGET', {
80
+ subject: `${operationId}${pointer}`,
81
+ message: `${operationId} ${pointer}: not a scalar leaf, so no field rule was projected for it`,
82
+ detail: { operation: operationId, pointer },
83
+ }));
84
+ continue;
85
+ }
86
+ for (const [keyword, value] of Object.entries(propSchema).sort(([a], [b]) => a.localeCompare(b))) {
87
+ if (OBSERVE_OWNED_KEYWORDS.has(keyword)) continue;
88
+ const spec = getFieldAssert(keyword);
89
+ if (!spec) {
90
+ diagnostics.push(makeRuleDiagnostic('RULE_UNSUPPORTED_CONSTRAINT', {
91
+ subject: `${operationId}${pointer}`,
92
+ message: `${operationId} ${pointer}: "${keyword}" has no equivalent in this rule vocabulary and is NOT enforced`,
93
+ detail: { operation: operationId, pointer, keyword },
94
+ }));
95
+ continue;
96
+ }
97
+ if (!spec.appliesTo.includes(propSchema.type) || !valueMatchesType(spec.valueType, value)) {
98
+ // The document itself is internally inconsistent (e.g. minLength on an integer).
99
+ // Reported, not refused: this is someone else's document, not a rule the user authored
100
+ // here, so the same "a contract is built from whatever was found" posture applies.
101
+ diagnostics.push(makeRuleDiagnostic('RULE_UNSUPPORTED_CONSTRAINT', {
102
+ subject: `${operationId}${pointer}`,
103
+ message: `${operationId} ${pointer}: "${keyword}" does not apply to a ${propSchema.type} field, so it was NOT enforced`,
104
+ detail: { operation: operationId, pointer, keyword, fieldType: propSchema.type },
105
+ }));
106
+ continue;
107
+ }
108
+ rules.push({
109
+ id: `contract:${operationId}:${field}:${keyword}`,
110
+ pointer,
111
+ assert: keyword,
112
+ value,
113
+ origin: 'contract',
114
+ });
115
+ }
116
+ }
117
+ return { rules, diagnostics };
118
+ }
119
+
120
+ function compileFieldRule(rule, opContract) {
121
+ const resolved = resolvePointer(opContract, rule.pointer);
122
+ if (!resolved.ok) {
123
+ return { error: makeRuleDiagnostic(resolved.code, {
124
+ subject: rule.id,
125
+ message: resolved.code === 'RULE_NO_CONTRACT_SCHEMA'
126
+ ? `rule "${rule.id}": operation "${rule.operation}" has no projected requestBodySchema, so ${rule.pointer} cannot be verified`
127
+ : `rule "${rule.id}": ${rule.pointer} is not a scalar field of operation "${rule.operation}"${resolved.known ? ` -- known fields: ${resolved.known.join(', ')}` : ''}`,
128
+ detail: { rule: rule.id, operation: rule.operation, pointer: rule.pointer, ...(resolved.known ? { knownFields: resolved.known } : {}) },
129
+ }) };
130
+ }
131
+ const spec = getFieldAssert(rule.assert);
132
+ if (!spec) {
133
+ return { error: makeRuleDiagnostic('RULE_UNKNOWN_ASSERT', {
134
+ subject: rule.id,
135
+ message: `rule "${rule.id}": unknown field assertion "${rule.assert}" -- known field assertions: ${FIELD_ASSERT_NAMES.join(', ')}`,
136
+ detail: { rule: rule.id, assert: rule.assert, known: FIELD_ASSERT_NAMES },
137
+ }) };
138
+ }
139
+ if (!spec.appliesTo.includes(resolved.type)) {
140
+ return { error: makeRuleDiagnostic('RULE_ASSERT_NOT_APPLICABLE', {
141
+ subject: rule.id,
142
+ message: `rule "${rule.id}": "${rule.assert}" cannot apply to ${rule.pointer}, which the contract declares as ${resolved.type} (it applies to: ${spec.appliesTo.join(', ')})`,
143
+ detail: { rule: rule.id, assert: rule.assert, fieldType: resolved.type, appliesTo: [...spec.appliesTo] },
144
+ }) };
145
+ }
146
+ if (!valueMatchesType(spec.valueType, rule.value)) {
147
+ return { error: makeRuleDiagnostic('RULE_VALUE_TYPE', {
148
+ subject: rule.id,
149
+ message: `rule "${rule.id}": "${rule.assert}" needs ${spec.valueType === 'integer' || spec.valueType === 'array' ? 'an' : 'a'} ${spec.valueType} value, got ${JSON.stringify(rule.value)}`,
150
+ detail: { rule: rule.id, assert: rule.assert, expected: spec.valueType, got: rule.value ?? null },
151
+ }) };
152
+ }
153
+ return { compiled: { id: rule.id, pointer: rule.pointer, assert: rule.assert, value: rule.value, origin: 'declared' } };
154
+ }
155
+
156
+ function compileCrossRule(rule, opContract) {
157
+ const spec = getCrossAssert(rule.assert);
158
+ if (!spec) {
159
+ return { error: makeRuleDiagnostic('RULE_UNKNOWN_ASSERT', {
160
+ subject: rule.id,
161
+ message: `rule "${rule.id}": unknown cross-field assertion "${rule.assert}" -- known cross-field assertions: ${CROSS_ASSERT_NAMES.join(', ')}`,
162
+ detail: { rule: rule.id, assert: rule.assert, known: CROSS_ASSERT_NAMES },
163
+ }) };
164
+ }
165
+ const pointers = Array.isArray(rule.pointers) ? rule.pointers : [];
166
+ const arityOk = spec.arity === 'n' ? pointers.length >= 2 : pointers.length === spec.arity;
167
+ if (!arityOk) {
168
+ return { error: makeRuleDiagnostic('RULE_ARITY', {
169
+ subject: rule.id,
170
+ message: `rule "${rule.id}": "${rule.assert}" needs ${spec.arity === 'n' ? 'two or more' : `exactly ${spec.arity}`} pointers, got ${pointers.length}`,
171
+ detail: { rule: rule.id, assert: rule.assert, arity: spec.arity, got: pointers.length },
172
+ }) };
173
+ }
174
+ const types = [];
175
+ for (const pointer of pointers) {
176
+ const resolved = resolvePointer(opContract, pointer);
177
+ if (!resolved.ok) {
178
+ return { error: makeRuleDiagnostic(resolved.code, {
179
+ subject: rule.id,
180
+ message: resolved.code === 'RULE_NO_CONTRACT_SCHEMA'
181
+ ? `rule "${rule.id}": operation "${rule.operation}" has no projected requestBodySchema, so ${pointer} cannot be verified`
182
+ : `rule "${rule.id}": ${pointer} is not a scalar field of operation "${rule.operation}"${resolved.known ? ` -- known fields: ${resolved.known.join(', ')}` : ''}`,
183
+ detail: { rule: rule.id, operation: rule.operation, pointer, ...(resolved.known ? { knownFields: resolved.known } : {}) },
184
+ }) };
185
+ }
186
+ types.push(resolved.type);
187
+ }
188
+ if (spec.comparison && !typesAreComparable(types[0], types[1])) {
189
+ return { error: makeRuleDiagnostic('RULE_INCOMPARABLE_TYPES', {
190
+ subject: rule.id,
191
+ message: `rule "${rule.id}": cannot compare ${pointers[0]} (${types[0]}) with ${pointers[1]} (${types[1]}) -- both must be numeric, or both string`,
192
+ detail: { rule: rule.id, pointers: [...pointers], types },
193
+ }) };
194
+ }
195
+ return { compiled: { id: rule.id, pointers: [...pointers], assert: rule.assert, types, origin: 'declared' } };
196
+ }
197
+
198
+ function compileTransitionRule(rule, opContract) {
199
+ const resolved = resolvePointer(opContract, rule.pointer);
200
+ if (!resolved.ok) {
201
+ return { error: makeRuleDiagnostic(resolved.code, {
202
+ subject: rule.id,
203
+ message: resolved.code === 'RULE_NO_CONTRACT_SCHEMA'
204
+ ? `rule "${rule.id}": operation "${rule.operation}" has no projected requestBodySchema, so ${rule.pointer} cannot be verified`
205
+ : `rule "${rule.id}": ${rule.pointer} is not a scalar field of operation "${rule.operation}"${resolved.known ? ` -- known fields: ${resolved.known.join(', ')}` : ''}`,
206
+ detail: { rule: rule.id, operation: rule.operation, pointer: rule.pointer, ...(resolved.known ? { knownFields: resolved.known } : {}) },
207
+ }) };
208
+ }
209
+ // A transition guard is an allow-list over a CLOSED set of states. Without an enum there is no
210
+ // closed set, so a typo'd state name would compile into a guard that silently never fires --
211
+ // refused rather than accepted, the same fail-closed call D-security-7 made for an
212
+ // unrecognized @PreAuthorize shape.
213
+ if (!resolved.enum) {
214
+ return { error: makeRuleDiagnostic('RULE_TRANSITION_NOT_ENUM', {
215
+ subject: rule.id,
216
+ message: `rule "${rule.id}": ${rule.pointer} declares no enum in the contract, so its transition states cannot be verified -- add an enum to the OpenAPI document and re-run \`bskel contract emit\``,
217
+ detail: { rule: rule.id, pointer: rule.pointer },
218
+ }) };
219
+ }
220
+ const from = Array.isArray(rule.from) ? rule.from : [];
221
+ const to = Array.isArray(rule.to) ? rule.to : [];
222
+ const unknown = [...from, ...to].filter((s) => !resolved.enum.includes(s));
223
+ if (unknown.length > 0) {
224
+ return { error: makeRuleDiagnostic('RULE_TRANSITION_UNKNOWN_STATE', {
225
+ subject: rule.id,
226
+ message: `rule "${rule.id}": state(s) ${unknown.map((s) => JSON.stringify(s)).join(', ')} are not in ${rule.pointer}'s declared enum -- known states: ${resolved.enum.join(', ')}`,
227
+ detail: { rule: rule.id, pointer: rule.pointer, unknown, known: [...resolved.enum] },
228
+ }) };
229
+ }
230
+ if (from.length === 0 || to.length === 0) {
231
+ return { error: makeRuleDiagnostic('RULE_ARITY', {
232
+ subject: rule.id,
233
+ message: `rule "${rule.id}": a transition rule needs at least one \`from\` and one \`to\` state`,
234
+ detail: { rule: rule.id, from: from.length, to: to.length },
235
+ }) };
236
+ }
237
+ return { compiled: { id: rule.id, pointer: rule.pointer, from: [...from], to: [...to], origin: 'declared' } };
238
+ }
239
+
240
+ const KIND_COMPILERS = Object.freeze({
241
+ field: compileFieldRule,
242
+ cross: compileCrossRule,
243
+ transition: compileTransitionRule,
244
+ });
245
+
246
+ // R5/Phase 3: validates an authored expr node against the closed grammar
247
+ // ({op,args}/{ref}/{const}), recursively. Returns {ok:true} or {ok:false, error} -- the error is
248
+ // a plain string (not yet a full diagnostic; the caller attaches rule id/subject context, since
249
+ // this function has no access to the enclosing rule's own id).
250
+ function validateDerivedExpr(node, fieldName) {
251
+ if (node && typeof node === 'object' && Object.hasOwn(node, 'ref')) {
252
+ if (typeof node.ref !== 'string' || node.ref.length === 0) return { ok: false, error: 'a `ref` leaf must be a non-empty string' };
253
+ if (node.ref === fieldName) return { ok: false, error: 'self-reference', code: 'RULE_DERIVED_SELF_REFERENCE' };
254
+ return { ok: true };
255
+ }
256
+ if (node && typeof node === 'object' && Object.hasOwn(node, 'const')) {
257
+ if (typeof node.const !== 'number' || !Number.isFinite(node.const)) return { ok: false, error: 'a `const` leaf must be a finite number' };
258
+ return { ok: true };
259
+ }
260
+ if (node && typeof node === 'object' && Object.hasOwn(node, 'op')) {
261
+ if (!DERIVED_OPS.includes(node.op)) return { ok: false, error: `unknown op "${node.op}" -- known ops: ${DERIVED_OPS.join(', ')}` };
262
+ if (!Array.isArray(node.args) || node.args.length !== 2) return { ok: false, error: `op "${node.op}" needs exactly 2 args, got ${Array.isArray(node.args) ? node.args.length : 'none'}` };
263
+ if (node.op === 'div' && node.args[1] && typeof node.args[1] === 'object' && node.args[1].const === 0) {
264
+ return { ok: false, error: 'division by a compile-time-known literal zero', code: 'RULE_DERIVED_DIVIDE_BY_ZERO' };
265
+ }
266
+ for (const arg of node.args) {
267
+ const result = validateDerivedExpr(arg, fieldName);
268
+ if (!result.ok) return result;
269
+ }
270
+ return { ok: true };
271
+ }
272
+ return { ok: false, error: 'must be one of {op,args}, {ref}, or {const}' };
273
+ }
274
+
275
+ function compileDerivedRule(rule) {
276
+ const id = typeof rule?.id === 'string' ? rule.id : '';
277
+ const resource = typeof rule?.resource === 'string' ? rule.resource : '';
278
+ const field = typeof rule?.field === 'string' ? rule.field : '';
279
+ if (!resource || !field || !rule?.expr || typeof rule.expr !== 'object') {
280
+ return { error: makeRuleDiagnostic('RULE_DERIVED_MISSING_FIELDS', {
281
+ subject: id || '(unnamed rule)',
282
+ message: `rule "${id || '(unnamed)'}": a derived rule needs a non-empty resource, field, and expr`,
283
+ detail: { rule: id, resource: resource || null, field: field || null, hasExpr: Boolean(rule?.expr) },
284
+ }) };
285
+ }
286
+ const validated = validateDerivedExpr(rule.expr, field);
287
+ if (!validated.ok) {
288
+ return { error: makeRuleDiagnostic(validated.code ?? 'RULE_DERIVED_INVALID_EXPR', {
289
+ subject: id,
290
+ message: `rule "${id}" (${resource}.${field}): ${validated.error}`,
291
+ detail: { rule: id, resource, field },
292
+ }) };
293
+ }
294
+ const params = collectParams(rule.expr);
295
+ return { compiled: { id, resource, field, params, expr: rule.expr, origin: 'declared' } };
296
+ }
297
+
298
+ /**
299
+ * Compiles an authored rules source document against a feature contract.
300
+ *
301
+ * @param {object} args
302
+ * @param {object} args.contract a feature contract (schemas/feature-contract.schema.json shape)
303
+ * @param {object} args.source the parsed rules.yaml document ({schema, rules: []}); null/absent is legal and yields a contract-only artifact
304
+ * @param {string} args.contractRef sha256 of the contract file on disk, so a compiled artifact can never be silently paired with a different contract
305
+ * @returns {{artifact: object|null, diagnostics: object[], blocking: boolean}}
306
+ */
307
+ export function compileRules({ contract, source = null, contractRef = '' }) {
308
+ const diagnostics = [];
309
+ const authored = Array.isArray(source?.rules) ? source.rules : [];
310
+ const operations = contract?.operations ?? {};
311
+
312
+ // Ids address a rule in `rules explain` and in every runtime violation report, so a duplicate
313
+ // makes a violation unattributable. Checked across ALL kinds and operations, once, up front.
314
+ const seenIds = new Set();
315
+ for (const rule of authored) {
316
+ const id = typeof rule?.id === 'string' ? rule.id : '';
317
+ if (!id) continue;
318
+ if (seenIds.has(id)) {
319
+ diagnostics.push(makeRuleDiagnostic('RULE_DUPLICATE_ID', {
320
+ subject: id,
321
+ message: `rule id "${id}" is used more than once -- ids must be unique within a feature`,
322
+ detail: { rule: id },
323
+ }));
324
+ }
325
+ seenIds.add(id);
326
+ }
327
+
328
+ // Contract-projected rules first (R4), so `origin: 'contract'` entries are always present even
329
+ // when rules.yaml does not exist at all -- pointing at an OpenAPI document is enough to get
330
+ // real enforcement, with zero authoring.
331
+ const byOperation = new Map();
332
+ function bucket(operationId) {
333
+ if (!byOperation.has(operationId)) byOperation.set(operationId, { field: [], cross: [], transition: [] });
334
+ return byOperation.get(operationId);
335
+ }
336
+ for (const [operationId, opContract] of Object.entries(operations).sort(([a], [b]) => a.localeCompare(b))) {
337
+ const projected = projectContractRules(operationId, opContract);
338
+ diagnostics.push(...projected.diagnostics);
339
+ if (projected.rules.length > 0) bucket(operationId).field.push(...projected.rules);
340
+ }
341
+
342
+ const derivedCompiled = [];
343
+ for (const rule of authored) {
344
+ const id = typeof rule?.id === 'string' ? rule.id : '';
345
+ const kind = rule?.kind;
346
+ if (!ALL_RULE_KINDS.includes(kind)) {
347
+ diagnostics.push(makeRuleDiagnostic('RULE_UNKNOWN_KIND', {
348
+ subject: id || '(unnamed rule)',
349
+ message: `rule "${id || '(unnamed)'}": unknown kind "${kind}" -- known kinds: ${ALL_RULE_KINDS.join(', ')}`,
350
+ detail: { rule: id, kind: kind ?? null, known: [...ALL_RULE_KINDS] },
351
+ }));
352
+ continue;
353
+ }
354
+ // `derived` is resource-scoped, not operation-scoped (R5) -- it has no `operation` field
355
+ // to resolve against the contract at all, so it is compiled on its own path entirely.
356
+ if (kind === 'derived') {
357
+ const { compiled, error } = compileDerivedRule(rule);
358
+ if (error) { diagnostics.push(error); continue; }
359
+ derivedCompiled.push(compiled);
360
+ continue;
361
+ }
362
+ const opContract = operations[rule.operation];
363
+ if (!opContract) {
364
+ diagnostics.push(makeRuleDiagnostic('RULE_UNKNOWN_OPERATION', {
365
+ subject: id || '(unnamed rule)',
366
+ message: `rule "${id || '(unnamed)'}": operation "${rule.operation}" is not in this feature's contract -- known operations: ${Object.keys(operations).sort().join(', ') || '(none)'}`,
367
+ detail: { rule: id, operation: rule.operation ?? null, known: Object.keys(operations).sort() },
368
+ }));
369
+ continue;
370
+ }
371
+ const { compiled, error } = KIND_COMPILERS[kind](rule, opContract);
372
+ if (error) { diagnostics.push(error); continue; }
373
+ bucket(rule.operation)[kind].push(compiled);
374
+ }
375
+
376
+ const blocking = isBlocking(diagnostics);
377
+ if (blocking) return { artifact: null, diagnostics, blocking };
378
+
379
+ // Deterministic ordering everywhere: operations by id, rules by id within each kind. A second
380
+ // compile of the same inputs must produce a byte-identical artifact (the bar
381
+ // handles/conformance.mjs already sets for emit, applied here to compilation).
382
+ const compiledOperations = {};
383
+ for (const operationId of [...byOperation.keys()].sort((a, b) => a.localeCompare(b))) {
384
+ const kinds = byOperation.get(operationId);
385
+ const entry = {};
386
+ for (const kind of PREDICATE_KINDS) {
387
+ if (kinds[kind].length === 0) continue;
388
+ entry[kind] = kinds[kind].slice().sort((a, b) => a.id.localeCompare(b.id));
389
+ }
390
+ if (Object.keys(entry).length > 0) compiledOperations[operationId] = entry;
391
+ }
392
+
393
+ return {
394
+ artifact: {
395
+ sbf_feature_rules: RULES_SCHEMA_VERSION,
396
+ feature_id: contract.feature_id,
397
+ feature_uid: contract.feature_uid,
398
+ contract_ref: contractRef,
399
+ operations: compiledOperations,
400
+ // R5/Phase 3: `derived` compiles to a generated pure function rather than a predicate,
401
+ // so it is a separate top-level list, sorted by id for the same determinism guarantee
402
+ // every other rule list already holds.
403
+ derived: derivedCompiled.slice().sort((a, b) => a.id.localeCompare(b.id)),
404
+ unsupported: diagnostics
405
+ .filter((d) => d.severity === 'warn')
406
+ .map((d) => ({ code: d.code, subject: d.subject, reason: d.message }))
407
+ .sort((a, b) => `${a.code}${a.subject}`.localeCompare(`${b.code}${b.subject}`)),
408
+ },
409
+ diagnostics,
410
+ blocking: false,
411
+ };
412
+ }
413
+
414
+ // Counts by kind/origin, for `rules check`/`rules list` reporting. Pure, so the CLI never
415
+ // re-derives these inline.
416
+ export function summarizeArtifact(artifact) {
417
+ const summary = { operations: 0, field: 0, cross: 0, transition: 0, derived: 0, fromContract: 0, declared: 0, unsupported: 0 };
418
+ if (!artifact) return summary;
419
+ summary.operations = Object.keys(artifact.operations ?? {}).length;
420
+ summary.derived = (artifact.derived ?? []).length;
421
+ summary.declared += summary.derived; // every derived rule is origin:"declared" -- no contract-projection equivalent exists for a computed field
422
+ summary.unsupported = (artifact.unsupported ?? []).length;
423
+ for (const kinds of Object.values(artifact.operations ?? {})) {
424
+ for (const kind of PREDICATE_KINDS) {
425
+ for (const rule of kinds[kind] ?? []) {
426
+ summary[kind] += 1;
427
+ if (rule.origin === 'contract') summary.fromContract += 1;
428
+ else summary.declared += 1;
429
+ }
430
+ }
431
+ }
432
+ return summary;
433
+ }
@@ -0,0 +1,87 @@
1
+ // D-business-rules (R5/R9, Phase 3): pure, shared rendering for a `derived` rule's compiled expr
2
+ // tree into real source. One function, reused by all three providers, rather than three drifting
3
+ // copies -- possible ONLY because the closed operator set (add/sub/mul/div) happens to share
4
+ // identical infix syntax across Java, Python, and TypeScript when every operand is typed
5
+ // numerically (no operator overloading ambiguity, no language-specific precedence quirks at this
6
+ // grammar's depth). If a future op ever needed different per-language syntax, this is the single
7
+ // place that assumption would break and need splitting -- named here, not discovered by surprise.
8
+ //
9
+ // Validation of an authored expr tree lives in rules/compile.mjs (the "decide semantics once in
10
+ // JS" doctrine every other rule kind already follows) -- this module ONLY renders an
11
+ // already-validated tree. It never throws on a well-formed tree; a malformed one is a compiler
12
+ // bug upstream, not this module's concern to defend against a second time.
13
+ import { DERIVED_OPS } from './vocabulary.mjs';
14
+
15
+ const OP_SYMBOLS = Object.freeze({ add: '+', sub: '-', mul: '*', div: '/' });
16
+
17
+ /**
18
+ * Renders an already-validated expr node as a parenthesized infix expression, e.g.
19
+ * `((price * quantity) - discount)`. `refName` maps a `{ref}` leaf's name to the exact identifier
20
+ * to emit -- callers pass a per-language transform (e.g. Java/TS keep the name as-is, Python
21
+ * might not need one at all since parameter names are already valid Python identifiers by
22
+ * construction, see collectParams()'s own identifier rule).
23
+ */
24
+ export function renderExprInfix(node, refName = (name) => name) {
25
+ if (Object.hasOwn(node, 'ref')) return refName(node.ref);
26
+ if (Object.hasOwn(node, 'const')) return formatConst(node.const);
27
+ if (Object.hasOwn(node, 'op') && DERIVED_OPS.includes(node.op)) {
28
+ const [left, right] = node.args;
29
+ return `(${renderExprInfix(left, refName)} ${OP_SYMBOLS[node.op]} ${renderExprInfix(right, refName)})`;
30
+ }
31
+ // Unreachable for a tree that passed rules/compile.mjs's own validateDerivedExpr() --
32
+ // deliberately throws rather than emitting silently-wrong source if it somehow is.
33
+ throw new Error(`renderExprInfix: not a validated expr node: ${JSON.stringify(node)}`);
34
+ }
35
+
36
+ // A literal that round-trips identically in Java/Python/TypeScript source for every value this
37
+ // grammar's own validateDerivedExpr() ever admits (a finite JS number) -- integers print without a
38
+ // decimal point in all three (matching each language's own int-literal-as-double/float/number
39
+ // promotion), and JS's own Number-to-string conversion already avoids exponential notation for
40
+ // every magnitude a real business-rule constant would plausibly use.
41
+ function formatConst(value) {
42
+ return String(value);
43
+ }
44
+
45
+ /**
46
+ * Walks an already-validated expr tree and returns every distinct `{ref}` name, in first-appearance
47
+ * (pre-order, left-to-right) order -- this becomes the generated function's own parameter list, so
48
+ * the order must be deterministic and match how a human reading the source rule would expect
49
+ * argument order to fall out.
50
+ */
51
+ export function collectParams(node, seen = new Set(), out = []) {
52
+ if (Object.hasOwn(node, 'ref')) {
53
+ if (!seen.has(node.ref)) { seen.add(node.ref); out.push(node.ref); }
54
+ return out;
55
+ }
56
+ if (Object.hasOwn(node, 'const')) return out;
57
+ if (Object.hasOwn(node, 'op')) {
58
+ for (const arg of node.args) collectParams(arg, seen, out);
59
+ return out;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Groups a compiled `derived[]` list into one entry per resource, each provider emits one file
66
+ * per group (a `<Resource>Rules` class/module). Resources sorted alphabetically, rules within a
67
+ * resource sorted by field name -- deterministic and independent of whatever arbitrary order
68
+ * rule ids happened to sort into (`artifact.derived` is sorted by id, not by field).
69
+ *
70
+ * COST, named rather than silently accepted: if two DIFFERENT features both declare a derived
71
+ * rule for the same resource name, each feature's own `rules emit` regenerates that resource's
72
+ * WHOLE file from its own view alone -- whichever feature emits last wins, silently dropping the
73
+ * other feature's methods for that resource. Merging across features would need real cross-
74
+ * feature provenance tracking this slice does not build (no proven need yet -- the same
75
+ * measurement-before-infra discipline `D-javascript-express-adapter` already applied elsewhere).
76
+ * Named here, in every provider's own postEmitNotes, and in DECISIONS.md -- not hidden.
77
+ */
78
+ export function groupDerivedByResource(derived) {
79
+ const byResource = new Map();
80
+ for (const rule of derived) {
81
+ if (!byResource.has(rule.resource)) byResource.set(rule.resource, []);
82
+ byResource.get(rule.resource).push(rule);
83
+ }
84
+ return [...byResource.entries()]
85
+ .sort(([a], [b]) => a.localeCompare(b))
86
+ .map(([resource, rules]) => [resource, rules.slice().sort((a, b) => a.field.localeCompare(b.field))]);
87
+ }
@@ -0,0 +1,147 @@
1
+ // D-business-rules (R6): the frozen code table for every way `bskel rules check` can reject or
2
+ // downgrade an authored rule. Mirrors contracts/completeness.mjs's own WARNING_CODES shape
3
+ // (a frozen table, severity stamped from the table rather than supplied by the caller, a
4
+ // `{code, subject}` key), on its own axis -- the same call lib/cross-feature-collisions.mjs made
5
+ // when it added a second evaluator rather than widening WARNING_CODES.
6
+ //
7
+ // THE SPLIT THAT MATTERS, and why this table has no waiver machinery unlike its contract sibling:
8
+ // a contract is built from whatever a real scan and a real OpenAPI document happened to contain,
9
+ // so its ERROR warnings describe facts about someone else's repo that a human may legitimately
10
+ // need to acknowledge and move past -- hence `bskel contract waive`. `rules.yaml` is 100%
11
+ // hand-authored by the person running the command. A rule naming an operation that does not
12
+ // exist is not a fact to be waived, it is a typo to be fixed, and offering a waiver for it would
13
+ // let a rule that can never fire sit in the artifact looking like enforcement. So:
14
+ //
15
+ // severity 'error' -> `rules check` REFUSES (exit BAD_ARGS). Fix the rule; there is no waiver.
16
+ // severity 'warn' -> the rule is dropped into the artifact's `unsupported[]` with this code and
17
+ // reported, never silently discarded. The remaining rules still compile.
18
+ //
19
+ // Every WARN member is an EXPRESSIVENESS gap (this vocabulary cannot say that), never a
20
+ // correctness gap -- that distinction is the whole reason the two severities exist here.
21
+ export const RULE_SEVERITY = Object.freeze({ ERROR: 'error', WARN: 'warn' });
22
+
23
+ export const RULE_DIAGNOSTICS = Object.freeze({
24
+ // ---- ERROR: authored mistakes, refused ----------------------------------------------------
25
+ RULE_UNKNOWN_OPERATION: Object.freeze({
26
+ severity: RULE_SEVERITY.ERROR,
27
+ summary: 'a rule names an operationId that this feature\'s contract does not define',
28
+ }),
29
+ RULE_UNKNOWN_POINTER: Object.freeze({
30
+ severity: RULE_SEVERITY.ERROR,
31
+ summary: 'a rule names a JSON Pointer that the operation\'s request body schema does not describe',
32
+ }),
33
+ RULE_UNKNOWN_ASSERT: Object.freeze({
34
+ severity: RULE_SEVERITY.ERROR,
35
+ summary: 'a rule uses an assertion name outside this vocabulary',
36
+ }),
37
+ RULE_UNKNOWN_KIND: Object.freeze({
38
+ severity: RULE_SEVERITY.ERROR,
39
+ // Deliberately its own code rather than folded into RULE_UNKNOWN_ASSERT: `kind` selects
40
+ // WHICH vocabulary applies, so a wrong kind and a wrong assertion are different mistakes
41
+ // with different fixes. Same "never share a code" reasoning contracts/completeness.mjs
42
+ // used to keep CONTRACT_OPENAPI_DRIFT and CONTRACT_OPENAPI_MISSING_OPERATION apart.
43
+ summary: 'a rule declares a kind outside {field, cross, transition}',
44
+ }),
45
+ RULE_VALUE_TYPE: Object.freeze({
46
+ severity: RULE_SEVERITY.ERROR,
47
+ summary: 'a rule\'s `value` is the wrong shape for its assertion (e.g. minLength: "3" instead of 3)',
48
+ }),
49
+ RULE_ASSERT_NOT_APPLICABLE: Object.freeze({
50
+ severity: RULE_SEVERITY.ERROR,
51
+ // The case this exists for: `minLength` on an integer field. Left uncaught it would compile
52
+ // into a rule that is simply never true, which reads as "enforced" to anyone auditing the
53
+ // artifact -- strictly worse than refusing.
54
+ summary: 'a rule\'s assertion cannot apply to that field\'s declared type',
55
+ }),
56
+ RULE_INCOMPARABLE_TYPES: Object.freeze({
57
+ severity: RULE_SEVERITY.ERROR,
58
+ summary: 'a cross-field comparison names two fields whose types are not mutually comparable',
59
+ }),
60
+ RULE_ARITY: Object.freeze({
61
+ severity: RULE_SEVERITY.ERROR,
62
+ summary: 'a cross-field rule names the wrong number of pointers for its assertion',
63
+ }),
64
+ RULE_DUPLICATE_ID: Object.freeze({
65
+ severity: RULE_SEVERITY.ERROR,
66
+ summary: 'two rules share the same id -- ids address a rule in `rules explain` and in every violation report, so they must be unique',
67
+ }),
68
+ RULE_TRANSITION_NOT_ENUM: Object.freeze({
69
+ severity: RULE_SEVERITY.ERROR,
70
+ // A transition guard is an allow-list over a closed set of states. Without an enum in the
71
+ // contract there is no closed set, so `from`/`to` values cannot be checked for typos --
72
+ // and a typo'd state name is a guard that silently never fires.
73
+ summary: 'a transition rule targets a field whose contract schema declares no enum, so its states cannot be verified',
74
+ }),
75
+ RULE_TRANSITION_UNKNOWN_STATE: Object.freeze({
76
+ severity: RULE_SEVERITY.ERROR,
77
+ summary: 'a transition rule names a state absent from that field\'s declared enum',
78
+ }),
79
+ RULE_NO_CONTRACT_SCHEMA: Object.freeze({
80
+ severity: RULE_SEVERITY.ERROR,
81
+ // Fail-closed, deliberately. Without a request body schema there is nothing to validate a
82
+ // pointer against, so every rule on that operation would be accepted blind.
83
+ summary: 'a rule targets an operation with no projected requestBodySchema -- re-run `bskel contract emit --openapi-file <doc>` so pointers can be verified',
84
+ }),
85
+
86
+ // ---- ERROR: derived-field authoring mistakes (R5/Phase 3), refused --------------------------
87
+ RULE_DERIVED_MISSING_FIELDS: Object.freeze({
88
+ severity: RULE_SEVERITY.ERROR,
89
+ summary: 'a derived rule is missing a resource name, a field name, or an expr',
90
+ }),
91
+ RULE_DERIVED_INVALID_EXPR: Object.freeze({
92
+ severity: RULE_SEVERITY.ERROR,
93
+ summary: 'a derived rule\'s expr is not a well-formed {op,args}/{ref}/{const} node -- see rules/derived.mjs for the closed grammar',
94
+ }),
95
+ RULE_DERIVED_DIVIDE_BY_ZERO: Object.freeze({
96
+ severity: RULE_SEVERITY.ERROR,
97
+ // Compile-time-known zero only -- a divisor that is a ref (a real runtime value) is not
98
+ // this compiler's concern; the generated code divides by whatever the caller passes,
99
+ // exactly as any ordinary division does.
100
+ summary: 'a derived rule divides by a compile-time-known literal zero, which would always throw/produce Infinity at runtime',
101
+ }),
102
+ RULE_DERIVED_SELF_REFERENCE: Object.freeze({
103
+ severity: RULE_SEVERITY.ERROR,
104
+ summary: 'a derived rule\'s expr references its own field as an input, which has no defined value to read',
105
+ }),
106
+
107
+ // ---- WARN: expressiveness gaps, recorded in `unsupported[]` --------------------------------
108
+ RULE_UNSUPPORTED_CONSTRAINT: Object.freeze({
109
+ severity: RULE_SEVERITY.WARN,
110
+ summary: 'a constraint in the contract\'s own schema has no equivalent in this vocabulary and is not enforced',
111
+ }),
112
+ RULE_NON_SCALAR_TARGET: Object.freeze({
113
+ severity: RULE_SEVERITY.WARN,
114
+ summary: 'a contract constraint sits on a nested/array field this vocabulary only addresses at scalar leaves',
115
+ }),
116
+ });
117
+
118
+ export const RULE_DIAGNOSTIC_NAMES = Object.freeze(Object.keys(RULE_DIAGNOSTICS).sort());
119
+
120
+ export function getRuleDiagnostic(code) {
121
+ return Object.hasOwn(RULE_DIAGNOSTICS, code) ? RULE_DIAGNOSTICS[code] : null;
122
+ }
123
+
124
+ export function requireRuleDiagnostic(code) {
125
+ const spec = getRuleDiagnostic(code);
126
+ if (!spec) throw new Error(`unknown rule diagnostic "${code}" -- known codes: ${RULE_DIAGNOSTIC_NAMES.join(', ')}`);
127
+ return spec;
128
+ }
129
+
130
+ // Severity is stamped FROM the table, never taken from the caller -- contracts/completeness.mjs's
131
+ // makeWarning() established this exact rule, for the exact reason that a caller free to pick a
132
+ // severity can silently downgrade a refusal into a warning.
133
+ export function makeRuleDiagnostic(code, { subject = null, message = '', detail = {} } = {}) {
134
+ const spec = requireRuleDiagnostic(code);
135
+ return { code, severity: spec.severity, subject, message, detail };
136
+ }
137
+
138
+ // The addressing key, code+subject only. Same reasoning as contracts/completeness.mjs's
139
+ // warningKey(): message text gets rephrased over time and anything keyed on it silently stops
140
+ // matching. `subject` here is a rule id or a source pointer -- both stable.
141
+ export function ruleDiagnosticKey(diagnostic) {
142
+ return `${diagnostic.code}::${diagnostic.subject ?? '*'}`;
143
+ }
144
+
145
+ export function isBlocking(diagnostics) {
146
+ return diagnostics.some((d) => d.severity === RULE_SEVERITY.ERROR);
147
+ }