backend-skeleton 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/bin/bskel.mjs +308 -0
  2. package/contracts/emit.mjs +22 -6
  3. package/handles/providers/java-spring/plan.mjs +24 -3
  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/plan.mjs +15 -2
  14. package/handles/providers/typescript-express/rules.mjs +129 -0
  15. package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
  16. package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
  17. package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
  18. package/lib/cli.mjs +37 -0
  19. package/lib/gate-definitions.mjs +27 -1
  20. package/lib/workflow.mjs +9 -0
  21. package/package.json +2 -1
  22. package/rules/compile.mjs +433 -0
  23. package/rules/derived.mjs +87 -0
  24. package/rules/diagnostics.mjs +147 -0
  25. package/rules/store.mjs +141 -0
  26. package/rules/vocabulary.mjs +172 -0
  27. package/scanners/adapters/_java-spring-analyzer.mjs +49 -0
  28. package/scanners/adapters/java-spring.mjs +154 -3
  29. package/scanners/index.mjs +10 -0
  30. package/schemas/feature-contract.schema.json +12 -1
  31. package/schemas/feature-rules.schema.json +139 -0
@@ -0,0 +1,209 @@
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, dependency-free executor for one feature's compiled business
5
+ // rules. A DUMB executor by design -- every semantic decision (which assertions exist, whether one
6
+ // applies to a field's type, whether two fields are comparable, whether a transition's states are
7
+ // real) was already made in JS by rules/compile.mjs at `bskel rules check` time. Mirrors
8
+ // handles/providers/java-spring/templates/RuleCheck.java.tmpl (and its python-fastapi sibling
9
+ // rule_check.py.tmpl) field-for-field, so all three runtimes are guaranteed to agree.
10
+ //
11
+ // Redaction invariant, inherited from contractCheck.ts and equally binding here: `Violation.message`
12
+ // MUST NEVER interpolate an observed payload value -- only the JSON Pointer, the rule id, and the
13
+ // rule's own declared bound.
14
+
15
+ export interface Violation {
16
+ ruleId: string;
17
+ pointer: string;
18
+ kind: string;
19
+ message: string;
20
+ }
21
+
22
+ export interface FieldRule {
23
+ id: string;
24
+ pointer: string;
25
+ assert: string;
26
+ value: unknown;
27
+ origin: string;
28
+ }
29
+
30
+ export interface CrossRule {
31
+ id: string;
32
+ pointers: string[];
33
+ assert: string;
34
+ types: string[];
35
+ origin: string;
36
+ }
37
+
38
+ export interface TransitionRule {
39
+ id: string;
40
+ pointer: string;
41
+ from: string[];
42
+ to: string[];
43
+ origin: string;
44
+ }
45
+
46
+ export interface RuleOperation {
47
+ field: FieldRule[];
48
+ cross: CrossRule[];
49
+ transition: TransitionRule[];
50
+ }
51
+
52
+ /** Compiled pointers are always a single top-level "/field" segment (rules/compile.mjs refuses
53
+ * anything deeper) -- this is a lookup, not a JSON Pointer implementation. */
54
+ function at(body: Record<string, unknown>, pointer: string): unknown {
55
+ const value = body[pointer.slice(1)];
56
+ return value === null || value === undefined ? undefined : value;
57
+ }
58
+
59
+ const CROSS_SYMBOLS: Record<string, string> = { lt: '<', lte: '<=', gt: '>', gte: '>=', eq: 'equal to', neq: 'different from' };
60
+
61
+ /** Runs every FIELD and CROSS rule in `rules` against a request body. TRANSITION rules are
62
+ * deliberately NOT run here -- see checkTransitions() for why they need an argument this function
63
+ * does not have. */
64
+ export function check(rules: RuleOperation, body: unknown): Violation[] {
65
+ const violations: Violation[] = [];
66
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return violations;
67
+ const record = body as Record<string, unknown>;
68
+ for (const rule of rules.field) violations.push(...checkField(rule, record));
69
+ for (const rule of rules.cross) violations.push(...checkCross(rule, record));
70
+ return violations;
71
+ }
72
+
73
+ function checkField(rule: FieldRule, body: Record<string, unknown>): Violation[] {
74
+ const value = at(body, rule.pointer);
75
+ // An absent optional field is not a violation -- requiredness is the contract's own statement,
76
+ // enforced by contractCheck.ts, deliberately not duplicated in this vocabulary.
77
+ if (value === undefined) return [];
78
+
79
+ switch (rule.assert) {
80
+ case 'minLength': {
81
+ const bound = rule.value as number;
82
+ if (typeof value === 'string' && value.length < bound) {
83
+ return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'minLength', message: `shorter than the required minimum length of ${bound}` }];
84
+ }
85
+ return [];
86
+ }
87
+ case 'maxLength': {
88
+ const bound = rule.value as number;
89
+ if (typeof value === 'string' && value.length > bound) {
90
+ return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'maxLength', message: `longer than the permitted maximum length of ${bound}` }];
91
+ }
92
+ return [];
93
+ }
94
+ case 'minimum':
95
+ return compareNumber(rule, value, (a, b) => a < b, 'is below the permitted minimum of ');
96
+ case 'maximum':
97
+ return compareNumber(rule, value, (a, b) => a > b, 'is above the permitted maximum of ');
98
+ case 'exclusiveMinimum':
99
+ return compareNumber(rule, value, (a, b) => a <= b, 'must be strictly greater than ');
100
+ case 'exclusiveMaximum':
101
+ return compareNumber(rule, value, (a, b) => a >= b, 'must be strictly less than ');
102
+ case 'multipleOf': {
103
+ const bound = rule.value as number;
104
+ if (typeof value === 'number' && Number.isFinite(value) && bound) {
105
+ const remainder = value % bound;
106
+ if (Math.min(Math.abs(remainder), Math.abs(remainder - bound)) > 1e-9) {
107
+ return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'multipleOf', message: `is not an exact multiple of ${bound}` }];
108
+ }
109
+ }
110
+ return [];
111
+ }
112
+ case 'enum': {
113
+ const allowed = (rule.value as unknown[]) ?? [];
114
+ if (!allowed.includes(value)) {
115
+ return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'enum', message: `is not one of the ${allowed.length} permitted values` }];
116
+ }
117
+ return [];
118
+ }
119
+ default:
120
+ // No default branch that passes silently: an assertion name this executor does not know
121
+ // can only mean the artifact was produced by a NEWER bskel than the code generated here.
122
+ return [{ ruleId: rule.id, pointer: rule.pointer, kind: 'unsupported', message: `rule assertion "${rule.assert}" is not understood by this generated checker -- re-run \`bskel rules emit\` to regenerate it` }];
123
+ }
124
+ }
125
+
126
+ function compareNumber(rule: FieldRule, value: unknown, violates: (a: number, b: number) => boolean, messagePrefix: string): Violation[] {
127
+ if (typeof value !== 'number' || !Number.isFinite(value)) return [];
128
+ const bound = rule.value as number;
129
+ if (violates(value, bound)) {
130
+ return [{ ruleId: rule.id, pointer: rule.pointer, kind: rule.assert, message: `${messagePrefix}${bound}` }];
131
+ }
132
+ return [];
133
+ }
134
+
135
+ function checkCross(rule: CrossRule, body: Record<string, unknown>): Violation[] {
136
+ const [first, second] = rule.pointers;
137
+ const a = at(body, first);
138
+
139
+ if (rule.assert === 'requiredIf') {
140
+ if (a !== undefined && at(body, second) === undefined) {
141
+ return [{ ruleId: rule.id, pointer: second, kind: 'requiredIf', message: `is required because ${first} is present` }];
142
+ }
143
+ return [];
144
+ }
145
+ if (rule.assert === 'mutuallyExclusive') {
146
+ const present = rule.pointers.filter((p) => at(body, p) !== undefined);
147
+ if (present.length > 1) {
148
+ return [{ ruleId: rule.id, pointer: first, kind: 'mutuallyExclusive', message: `at most one of ${rule.pointers.join(', ')} may be present, got ${present.length}` }];
149
+ }
150
+ return [];
151
+ }
152
+
153
+ const b = at(body, second);
154
+ // Either side absent means there is nothing to compare -- same reasoning as RuleCheck.java.
155
+ if (a === undefined || b === undefined) return [];
156
+
157
+ let cmp: number;
158
+ if (typeof a === 'number' && typeof b === 'number') {
159
+ cmp = a === b ? 0 : a > b ? 1 : -1;
160
+ } else if (typeof a === 'string' && typeof b === 'string') {
161
+ cmp = a === b ? 0 : a > b ? 1 : -1;
162
+ } else {
163
+ // rules/compile.mjs proved these two pointers were mutually comparable against the contract's
164
+ // declared types, so reaching here means the real payload disagrees with the contract.
165
+ return [{ ruleId: rule.id, pointer: first, kind: 'incomparable', message: `could not be compared with ${second} -- the payload types differ from the contract's declared types` }];
166
+ }
167
+
168
+ if (!(rule.assert in CROSS_SYMBOLS)) {
169
+ return [{ ruleId: rule.id, pointer: first, kind: 'unsupported', message: `rule assertion "${rule.assert}" is not understood by this generated checker -- re-run \`bskel rules emit\` to regenerate it` }];
170
+ }
171
+ const violated = ({ lt: cmp >= 0, lte: cmp > 0, gt: cmp <= 0, gte: cmp < 0, eq: cmp !== 0, neq: cmp === 0 } as Record<string, boolean>)[rule.assert];
172
+ if (violated) {
173
+ return [{ ruleId: rule.id, pointer: first, kind: rule.assert, message: `must be ${CROSS_SYMBOLS[rule.assert]} ${second}` }];
174
+ }
175
+ return [];
176
+ }
177
+
178
+ /**
179
+ * Runs TRANSITION rules. Separate from check() because a transition guard is inherently a
180
+ * statement about a change, and a request body only carries the DESTINATION state -- the current
181
+ * state lives in the application's own datastore, which this function has no access to and must
182
+ * never acquire.
183
+ *
184
+ * `currentStates` maps a rule's pointer (e.g. "/status") to that resource's CURRENT persisted
185
+ * value, supplied by the caller. This is the one place a human must wire something -- the same
186
+ * honest boundary resolver.ts's own patchField draws.
187
+ *
188
+ * Fail-closed: a pointer with no entry in `currentStates` produces an "unchecked" violation, never
189
+ * a silent pass.
190
+ */
191
+ export function checkTransitions(rules: RuleOperation, body: unknown, currentStates: Record<string, string> | null): Violation[] {
192
+ const violations: Violation[] = [];
193
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return violations;
194
+ const record = body as Record<string, unknown>;
195
+ for (const rule of rules.transition) {
196
+ const target = at(record, rule.pointer);
197
+ if (typeof target !== 'string') continue; // not changing this field
198
+ if (currentStates === null || !(rule.pointer in currentStates)) {
199
+ violations.push({ ruleId: rule.id, pointer: rule.pointer, kind: 'unchecked', message: '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
+ const fromState = currentStates[rule.pointer];
203
+ const toState = target;
204
+ if (fromState === toState) continue; // no transition is occurring
205
+ if (rule.from.includes(fromState) && rule.to.includes(toState)) continue; // explicitly allowed
206
+ violations.push({ ruleId: rule.id, pointer: rule.pointer, kind: 'transition', message: `this state change is not permitted -- allowed transitions are from {${rule.from.join(', ')}} to {${rule.to.join(', ')}}` });
207
+ }
208
+ return violations;
209
+ }
@@ -0,0 +1,61 @@
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
+ // `rulesSchemas/` directory (one per feature `bskel rules emit` has been run against) at MODULE
6
+ // LOAD time and merges them into one flat map keyed by operationId. Mirrors observedSchema.ts's
7
+ // own shape exactly -- same `fs.readdirSync` discovery, same CommonJS `__dirname` anchor (see that
8
+ // module's own doc comment for the full reasoning).
9
+ //
10
+ // This module does NO interpretation. It deserializes an artifact whose every semantic decision
11
+ // was already made and verified in JS by rules/compile.mjs -- see ruleCheck.ts's own doc comment
12
+ // for that boundary.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import type { RuleOperation } from './ruleCheck';
17
+
18
+ const SCHEMAS_DIR = path.join(__dirname, 'rulesSchemas');
19
+
20
+ const EMPTY_OPERATION: RuleOperation = { field: [], cross: [], transition: [] };
21
+
22
+ function loadOne(filePath: string): Record<string, RuleOperation> {
23
+ let raw: any;
24
+ try {
25
+ raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
26
+ } catch (err) {
27
+ console.warn(`ruleSet: could not read/parse ${filePath} -- its rules will not be enforced`, err);
28
+ return {};
29
+ }
30
+ const operations: Record<string, RuleOperation> = {};
31
+ for (const [operationId, entry] of Object.entries<any>(raw.operations ?? {})) {
32
+ operations[operationId] = {
33
+ field: entry.field ?? [],
34
+ cross: entry.cross ?? [],
35
+ transition: entry.transition ?? [],
36
+ };
37
+ }
38
+ return operations;
39
+ }
40
+
41
+ function loadAll(): Map<string, RuleOperation> {
42
+ const merged = new Map<string, RuleOperation>();
43
+ if (!fs.existsSync(SCHEMAS_DIR)) return merged;
44
+ const files = fs.readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.rules.json')).sort();
45
+ for (const file of files) {
46
+ for (const [operationId, ops] of Object.entries(loadOne(path.join(SCHEMAS_DIR, file)))) {
47
+ if (merged.has(operationId)) {
48
+ console.warn(`ruleSet: operationId "${operationId}" is declared by more than one *.rules.json on disk -- the last one loaded wins`);
49
+ }
50
+ merged.set(operationId, ops);
51
+ }
52
+ }
53
+ return merged;
54
+ }
55
+
56
+ const OPERATIONS = loadAll();
57
+
58
+ /** Never undefined -- an operation with no rules returns the shared empty operation object. */
59
+ export function forOperation(operationId: string): RuleOperation {
60
+ return OPERATIONS.get(operationId) ?? EMPTY_OPERATION;
61
+ }
package/lib/cli.mjs CHANGED
@@ -339,6 +339,43 @@ export const COMMANDS = {
339
339
  json: { type: 'boolean', default: false },
340
340
  },
341
341
  },
342
+ // D-business-rules: `check` compiles + verifies + establishes the `rules` gate; `list`/`explain`
343
+ // are read-only. `--init` writes a starter rules.yaml only when none exists (never overwrites).
344
+ 'rules check': {
345
+ usage: 'bskel rules check --feature <id> [--init] [--json]',
346
+ options: {
347
+ feature: { type: 'string', default: null, required: true },
348
+ init: { type: 'boolean', default: false },
349
+ json: { type: 'boolean', default: false },
350
+ },
351
+ },
352
+ 'rules list': {
353
+ usage: 'bskel rules list --feature <id> [--json]',
354
+ options: {
355
+ feature: { type: 'string', default: null, required: true },
356
+ json: { type: 'boolean', default: false },
357
+ },
358
+ },
359
+ 'rules explain': {
360
+ usage: 'bskel rules explain --feature <id> --rule <id> [--json]',
361
+ options: {
362
+ feature: { type: 'string', default: null, required: true },
363
+ rule: { type: 'string', default: null, required: true },
364
+ json: { type: 'boolean', default: false },
365
+ },
366
+ },
367
+ 'rules emit': {
368
+ usage: 'bskel rules emit --feature <id> [--module <name>] [--check] [--diff] [--force --reason "..."] [--json]',
369
+ options: {
370
+ feature: { type: 'string', default: null, required: true },
371
+ module: { type: 'string', default: null },
372
+ check: { type: 'boolean', default: false },
373
+ diff: { type: 'boolean', default: false },
374
+ force: { type: 'boolean', default: false },
375
+ reason: { type: 'string', default: '' },
376
+ json: { type: 'boolean', default: false },
377
+ },
378
+ },
342
379
  'stack apply': {
343
380
  usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
344
381
  options: {
@@ -295,6 +295,32 @@ export const GATE_DEFINITIONS = Object.freeze({
295
295
  return inputs;
296
296
  },
297
297
  },
298
+ // D-business-rules (R7): a feature's compiled business rules. Its own gate rather than a
299
+ // widening of `dependencies` -- the same call cross_feature/dependencies/patch_transactions/
300
+ // conformance each made rather than overloading a neighbour, and the two genuinely differ:
301
+ // a dependency is an edge between two fields, a rule is a constraint checked against one
302
+ // feature's own contract. REQUIRED_WHEN_PRESENT because most features will never declare one.
303
+ //
304
+ // Positioned between `dependencies` and `handles`: after dependencies because a derived-field
305
+ // rule references a declared dependency edge, before handles because that is where generated
306
+ // code appears.
307
+ //
308
+ // contract_hash is an input, not just the two rules files: every pointer, scalar type, and
309
+ // enum state in a compiled rule was verified against the contract at compile time, so
310
+ // re-emitting the contract can invalidate a rule that still looks fine on its own. Without
311
+ // this token a contract change could silently leave a rule enforcing against a field that no
312
+ // longer exists. (`contract` is itself a REQUIRED gate, so this is defence in depth, not the
313
+ // only line -- the same relationship `handles`'s own contract_hash token already has.)
314
+ rules: {
315
+ name: 'rules',
316
+ scope: SCOPE.FEATURE,
317
+ verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
318
+ recompute: (root, featureId) => ({
319
+ rules_source_hash: sha256File(specPath(root, featureId, 'rules.yaml')),
320
+ rules_compiled_hash: sha256File(specPath(root, featureId, 'rules', `${featureId}.rules.json`)),
321
+ contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
322
+ }),
323
+ },
298
324
  // Staleness = the generated Java (or the contract it was generated from) has moved since
299
325
  // emit -- NOT "does the migration still match the DB schema" (unknowable without a live DB
300
326
  // connection this tool deliberately never opens on its own, see D-migration-scope). Note
@@ -403,7 +429,7 @@ export const GATE_DEFINITIONS = Object.freeze({
403
429
  // test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
404
430
  // key set, so a gate added to one and not the other fails loudly instead of silently vanishing
405
431
  // from `bskel verify` the way `stack` did before this module existed.
406
- export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'handles', 'stack', 'patch_transactions', 'conformance']);
432
+ export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'rules', 'handles', 'stack', 'patch_transactions', 'conformance']);
407
433
 
408
434
  export function getGateDefinition(name) {
409
435
  return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;
package/lib/workflow.mjs CHANGED
@@ -44,6 +44,11 @@ const ESTABLISH_COMMAND = {
44
44
  // directly on the first real stale occurrence and throws a raw TypeError instead of a clean
45
45
  // stale report.
46
46
  dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
47
+ // D-business-rules (R7): wired in the SAME commit as the `rules` gate itself, per the repeated
48
+ // warnings above -- this exact gap has now been found live three times (dependencies,
49
+ // conformance, and the cross_feature near-miss) and is the single most reliably-recurring
50
+ // mistake in this file.
51
+ rules: (id) => `bskel rules check --feature ${id}`,
47
52
  handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
48
53
  stack: () => 'bskel stack apply --choice <id> --apply',
49
54
  // D-patch-transactions: added in the SAME commit as the gate itself -- per the
@@ -89,6 +94,10 @@ const MUTATING_PREFIXES = [
89
94
  'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
90
95
  'bskel handles plan', 'bskel stack apply', 'bskel observe emit', 'bskel observe import',
91
96
  'bskel patch propose', 'bskel patch approve', 'bskel patch apply', 'bskel patch rollback',
97
+ // D-business-rules (R7): `rules check` writes the compiled artifact and passes its gate;
98
+ // `rules emit` writes generated source. `rules list`/`rules explain` are read-only and
99
+ // correctly absent -- the same per-verb split `pattern list|show|suggest` was checked against.
100
+ 'bskel rules check', 'bskel rules emit',
92
101
  ];
93
102
 
94
103
  // Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -27,6 +27,7 @@
27
27
  "new/",
28
28
  "stack/",
29
29
  "patterns/",
30
+ "rules/",
30
31
  "schemas/",
31
32
  "scripts/preflight-base-ref.sh",
32
33
  "LICENSE",