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,129 @@
1
+ // D-business-rules (R9): the emit-side half of compiled business rules for typescript-express.
2
+ // Mirrors handles/providers/python-fastapi/rules.mjs's own shape -- rules, observe, and handles
3
+ // are orthogonal capabilities that happen to share the same repo-wide "generated infra" pattern.
4
+ //
5
+ // This emitter is deliberately THIN. It renders three fixed infra modules and copies an
6
+ // already-compiled artifact into `rulesSchemas/` -- it makes no decision about what a rule means.
7
+ // Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
8
+ // (R3), which is the invariant that lets three languages agree.
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
13
+ import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
14
+ import { pascalCase } from '../../../rules/vocabulary.mjs';
15
+
16
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
17
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
18
+
19
+ // Repo-wide, shared across every feature that ever runs `bskel rules emit` -- ruleSet.ts
20
+ // discovers every `rulesSchemas/*.rules.json` file at module-load time rather than being
21
+ // regenerated per feature, so these files are true infra (create-once-per-repo, all-or-nothing
22
+ // conflict unit), the same treatment observe.mjs's own INFRA_FILES get. None need any {{VAR}}
23
+ // substitution -- same reasoning observe's own INFRA_FILES give.
24
+ const INFRA_FILES = [
25
+ { template: 'ruleCheck.ts.tmpl', target: 'ruleCheck.ts' },
26
+ { template: 'ruleSet.ts.tmpl', target: 'ruleSet.ts' },
27
+ { template: 'enforceRules.ts.tmpl', target: 'enforceRules.ts' },
28
+ ];
29
+
30
+ function render(templatePath, vars) {
31
+ let content = fs.readFileSync(templatePath, 'utf8');
32
+ for (const [key, value] of Object.entries(vars)) {
33
+ content = content.replaceAll(`{{${key}}}`, String(value));
34
+ }
35
+ return content;
36
+ }
37
+
38
+ function writeUnit(target, content) {
39
+ fs.mkdirSync(path.dirname(target), { recursive: true });
40
+ fs.writeFileSync(target, content);
41
+ }
42
+
43
+ function camelCase(name) {
44
+ const pascal = pascalCase(name);
45
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
46
+ }
47
+
48
+ // R5/Phase 3: one module per resource, one exported function per derived field -- same "complete,
49
+ // never a stub" posture java-spring's/python-fastapi's own renderers hold. All params are typed
50
+ // `number`: this vocabulary's operator set (add/sub/mul/div) is numeric-only by construction.
51
+ function renderDerivedModule(resource, rules, featureId) {
52
+ const functions = rules.map((rule) => {
53
+ const params = rule.params.map((p) => `${p}: number`).join(', ');
54
+ const formula = renderExprInfix(rule.expr);
55
+ return `/** \`${rule.field} = ${formula}\` -- rule "${rule.id}". */\nexport function compute${pascalCase(rule.field)}(${params}): number {\n\treturn ${formula};\n}`;
56
+ });
57
+ return `// Generated by backend-skeleton (bskel rules emit) for feature ${featureId}.
58
+ //
59
+ // D-business-rules (R5): a complete, compiling pure function per derived field -- never a stub.
60
+ // Nothing calls these; wire the call site yourself wherever a computed field on ${resource} should
61
+ // actually be set (e.g. before persisting).
62
+
63
+ ${functions.join('\n\n')}
64
+ `;
65
+ }
66
+
67
+ /**
68
+ * `plan` is the already-computed typescript-express resource plan (bin/bskel.mjs calls
69
+ * planTypeScriptExpress() before this) -- only plan.srcRoot is used, matching observe.mjs's own
70
+ * tolerance-of-unused-fields pattern. `artifact` is the already-compiled, already-schema-validated
71
+ * rules artifact (rules/store.mjs's loadRulesArtifact) -- this function never reads specs/ itself.
72
+ */
73
+ export function emitRulesTypeScriptExpress({ repoRoot, featureId, artifact, plan, force = false, reason = '', dryRun = false, computeDiff = false }) {
74
+ const rulesDir = path.join(plan.srcRoot, 'rules');
75
+
76
+ const infraUnits = INFRA_FILES.map((f) => ({
77
+ id: f.template,
78
+ templatePath: path.join(TEMPLATES_DIR, f.template),
79
+ targetAbs: path.join(rulesDir, f.target),
80
+ rendered: render(path.join(TEMPLATES_DIR, f.template), {}),
81
+ }));
82
+
83
+ // R5/Phase 3: one <resource>Rules.ts per resource with a derived field, emitted the SAME
84
+ // unconditional way the *.rules.json spec resource below is. See java-spring's own
85
+ // groupDerivedByResource() comment for the named cross-feature-collision limitation.
86
+ const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
87
+ const derivedUnits = derivedGroups.map(([resource, rules]) => ({
88
+ resource,
89
+ targetAbs: path.join(rulesDir, `${camelCase(resource)}Rules.ts`),
90
+ content: renderDerivedModule(resource, rules, featureId),
91
+ }));
92
+
93
+ const result = emitUnits({ repoRoot, featureId, provider: 'typescript-express', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
94
+
95
+ for (const unit of derivedUnits) {
96
+ const relPath = path.relative(repoRoot, unit.targetAbs);
97
+ const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
98
+ const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
99
+ if (!dryRun) writeUnit(unit.targetAbs, unit.content);
100
+ result.written.push(relPath);
101
+ const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
102
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
103
+ result.actions.push(actionEntry);
104
+ }
105
+
106
+ // The compiled artifact, copied verbatim -- byte-identical to the file `bskel rules check`
107
+ // already wrote and schema-validated, deliberately NOT re-serialized here, the same "exactly
108
+ // one representation of these rules" invariant every other provider's emitter holds.
109
+ const content = `${JSON.stringify(artifact, null, '\t')}\n`;
110
+ const target = path.join(rulesDir, 'rulesSchemas', `${featureId}.rules.json`);
111
+ const relPath = path.relative(repoRoot, target);
112
+ const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
113
+ const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
114
+ if (!dryRun) writeUnit(target, content);
115
+ result.written.push(relPath);
116
+ const actionEntry = { path: relPath, kind: 'spec', action };
117
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
118
+ result.actions.push(actionEntry);
119
+
120
+ return {
121
+ ...result,
122
+ postEmitNotes: [
123
+ `field/cross rules are LOADED but not yet ACTIVE: insert enforceRules("<operationId>") into the real route's own middleware array to have them checked automatically on every request.`,
124
+ `defaults to OBSERVE (logs violations to stderr, never rejects a request) -- set the BSKEL_RULES_MODE=enforce environment variable when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
125
+ `transition rules are NOT checked by enforceRules() -- they need the resource's CURRENT state, which no middleware can supply generically. Call ruleCheck.checkTransitions(rules, req.body, {"/status": current.status}) directly wherever your route handler has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
126
+ ...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${camelCase(u.resource)}Rules.ts`).join(', ')}: complete, compiling pure functions, but NOTHING calls them -- wire the call site yourself. If another feature also declares a derived field on the same resource, whichever feature's \`rules emit\` runs LAST wins for that file -- not merged.`] : []),
127
+ ],
128
+ };
129
+ }
@@ -0,0 +1,93 @@
1
+ // Generated by backend-skeleton (bskel rules emit). Do not hand-edit -- change the source rule
2
+ // and regenerate.
3
+ //
4
+ // D-business-rules (R8/R9): the opt-in AUTOMATIC half of business-rule checking -- exists only for
5
+ // a route a human has chosen to insert enforceRules('...') into. Mirrors observeContract.ts's own
6
+ // choice: Express middleware IS this ecosystem's own interception mechanism, so there is no
7
+ // separate "declare a marker" vs. "implement the interceptor" split worth preserving here either.
8
+ //
9
+ // SIMPLER than observeContract.ts's response-capture dance: this middleware only ever checks the
10
+ // REQUEST (field/cross rules have no response-shape concept), so there is no res.json patching,
11
+ // no res.on('finish', ...) -- a violation in enforce mode is rejected synchronously, before
12
+ // `next()` is ever called, the same way any ordinary Express validation middleware works.
13
+ //
14
+ // Checks FIELD and CROSS rules only. Does NOT check TRANSITION rules -- a transition guard needs
15
+ // the resource's CURRENT persisted state, which this middleware's own request-scoped context has
16
+ // no generic way to obtain. Call checkTransitions() directly, with the current state your own
17
+ // route handler already has, wherever a transition needs checking.
18
+ //
19
+ // The observe/enforce split is read from the BSKEL_RULES_MODE environment variable (default
20
+ // "observe") on every request, not baked in at emit time -- switching a deployed app from
21
+ // observation to enforcement is a config change, never a re-run of `bskel rules emit`. Same
22
+ // posture java-spring's `bskel.rules.mode` / python-fastapi's own BSKEL_RULES_MODE take.
23
+ //
24
+ // - observe (default): every violation is logged to stderr, the request always proceeds.
25
+ // - enforce: a violation responds 400 BEFORE the route handler runs. The response body may name a
26
+ // rule id, a JSON Pointer, and a rule's own declared bound; it NEVER contains an observed
27
+ // request value -- the same redaction invariant Violation carries.
28
+ //
29
+ // Fail-open on an INTERNAL error, fail-closed on a REAL violation: if this middleware itself
30
+ // cannot compute violations (a malformed body, a loader problem), that is logged and the request
31
+ // proceeds unaffected, in BOTH modes.
32
+ //
33
+ // Example:
34
+ // router.patch('/widgets/:id', [enforceRules('updateWidget')], updateWidget);
35
+
36
+ import type { RequestHandler, Request, Response, NextFunction } from 'express';
37
+ import * as ruleCheck from './ruleCheck';
38
+ import * as ruleSet from './ruleSet';
39
+ import type { RuleOperation, Violation } from './ruleCheck';
40
+
41
+ function mode(): string {
42
+ return process.env.BSKEL_RULES_MODE ?? 'observe';
43
+ }
44
+
45
+ function safelyCheck(rules: RuleOperation, body: unknown, operationId: string): Violation[] {
46
+ try {
47
+ if (body === undefined) return [];
48
+ return ruleCheck.check(rules, body);
49
+ } catch (err) {
50
+ console.warn(`enforceRules: could not evaluate rules for operation "${operationId}" -- the request proceeds unaffected`, err);
51
+ return [];
52
+ }
53
+ }
54
+
55
+ function logViolations(operationId: string, violations: Violation[], activeMode: string): void {
56
+ try {
57
+ for (const v of violations) {
58
+ console.warn(`bskel.rules.violations operation=${operationId} rule=${v.ruleId} pointer=${v.pointer} kind=${v.kind} mode=${activeMode} -- ${v.message}`);
59
+ }
60
+ } catch (err) {
61
+ console.warn(`enforceRules: could not log violations for operation "${operationId}"`, err);
62
+ }
63
+ }
64
+
65
+ /** Built from rule id / pointer / bound-derived text only -- never an observed value, the same
66
+ * redaction invariant every Violation.message already holds. */
67
+ function summarize(operationId: string, violations: Violation[]): string {
68
+ const parts = violations.map((v) => `${v.pointer} (${v.ruleId}): ${v.message}`);
69
+ return `business rule violation(s) for ${operationId}: ${parts.join('; ')}`;
70
+ }
71
+
72
+ export function enforceRules(operationId: string): RequestHandler {
73
+ return (req: Request, res: Response, next: NextFunction) => {
74
+ const rules = ruleSet.forOperation(operationId);
75
+ if (rules.field.length === 0 && rules.cross.length === 0) {
76
+ // Nothing compiled for this operation -- most commonly a feature with no rules.yaml and
77
+ // a contract that projected nothing enforceable. Proceeds silently.
78
+ next();
79
+ return;
80
+ }
81
+
82
+ const violations = safelyCheck(rules, req.body, operationId);
83
+ if (violations.length > 0) {
84
+ const activeMode = mode();
85
+ logViolations(operationId, violations, activeMode);
86
+ if (activeMode === 'enforce') {
87
+ res.status(400).json({ error: summarize(operationId, violations) });
88
+ return; // next() is never called -- the route handler must not run
89
+ }
90
+ }
91
+ next();
92
+ };
93
+ }
@@ -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
@@ -241,6 +241,33 @@ export const COMMANDS = {
241
241
  json: { type: 'boolean', default: false },
242
242
  },
243
243
  },
244
+ // D-contract-csv: deliberately fewer flags than `contract export` -- no `--allow-unprefixed`
245
+ // (there is no hard refusal here to override, only a warning that always fires), no
246
+ // `--status-codes` (not an OpenAPI-shaped concept). `--bom` is CSV-specific: opt-in UTF-8 BOM
247
+ // for Excel-on-Windows, see D-contract-csv in DECISIONS.md.
248
+ 'contract export-csv': {
249
+ usage: 'bskel contract export-csv --feature <id> [--out <path>] [--bom] [--json]',
250
+ options: {
251
+ feature: { type: 'string', default: null, required: true },
252
+ out: { type: 'string', default: null },
253
+ bom: { type: 'boolean', default: false },
254
+ json: { type: 'boolean', default: false },
255
+ },
256
+ },
257
+ // D-db-erd: the other "recognizable artifact" export, read-only, repo-independent like
258
+ // `bskel new` (no --feature -- the database plane is not feature-scoped, see E6 in D-db-erd,
259
+ // DECISIONS.md). Deliberately no `--db` flag: `db` IS the verb, so it's implied -- internally
260
+ // reuses `resolveDbSchemaOrExit(root, {...flags, db:true})` unchanged, so env-var handling and
261
+ // error strings stay byte-identical to `scan --db`.
262
+ 'db erd': {
263
+ usage: 'bskel db erd [--database-url-env <NAME>] [--schema public] [--out <path>] [--json]',
264
+ options: {
265
+ 'database-url-env': { type: 'string', default: null },
266
+ schema: { type: 'string', default: 'public' },
267
+ out: { type: 'string', default: null },
268
+ json: { type: 'boolean', default: false },
269
+ },
270
+ },
244
271
  // D-contract-history: read-only, so no --module/--openapi-file/etc -- it only ever reads what
245
272
  // git already recorded for this feature's own contract file.
246
273
  'contract history': {
@@ -312,6 +339,43 @@ export const COMMANDS = {
312
339
  json: { type: 'boolean', default: false },
313
340
  },
314
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
+ },
315
379
  'stack apply': {
316
380
  usage: 'bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]',
317
381
  options: {
@@ -425,12 +489,18 @@ export const COMMANDS = {
425
489
  // nobody actually asked for. The real defaults live in new/spring.mjs / new/fastapi.mjs, one
426
490
  // place each.
427
491
  new: {
428
- usage: 'bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none]',
492
+ usage: 'bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none] [--record-pattern --pattern-database-url-env <NAME>]',
429
493
  options: {
430
494
  stack: { type: 'string', default: null, required: true },
431
495
  slug: { type: 'string', default: null, required: true },
432
496
  dir: { type: 'string', default: null },
433
497
  offline: { type: 'boolean', default: false },
498
+ // D-pattern-accrual: both opt-in, both required together -- omitting either leaves
499
+ // `bskel new` byte-identical to today (no recording, no DB connection attempted). The
500
+ // actual write is best-effort (never fails the scaffold); only the "flag given but the
501
+ // other half missing / env var unset" usage mistake is checked before scaffold.
502
+ 'record-pattern': { type: 'boolean', default: false },
503
+ 'pattern-database-url-env': { type: 'string', default: null },
434
504
 
435
505
  // Both stacks.
436
506
  name: { type: 'string', default: null },
@@ -466,6 +536,34 @@ export const COMMANDS = {
466
536
  'boot-version': { type: 'string', default: null, hidden: true },
467
537
  },
468
538
  },
539
+ // D-pattern-accrual: all three read-only, all requiring --pattern-database-url-env --
540
+ // there is no meaningful "run without a live connection" mode, same posture as `handles audit`
541
+ // (O7). Distinct flag name from every other command's --database-url-env: that flag always means
542
+ // the TARGET APPLICATION's database; this one is the user's own cross-project pattern store.
543
+ 'pattern list': {
544
+ usage: 'bskel pattern list --pattern-database-url-env <NAME> [--stack spring|fastapi] [--json]',
545
+ options: {
546
+ 'pattern-database-url-env': { type: 'string', default: null, required: true },
547
+ stack: { type: 'string', default: null },
548
+ json: { type: 'boolean', default: false },
549
+ },
550
+ },
551
+ 'pattern show': {
552
+ usage: 'bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]',
553
+ options: {
554
+ 'pattern-database-url-env': { type: 'string', default: null, required: true },
555
+ json: { type: 'boolean', default: false },
556
+ },
557
+ allowPositionals: true,
558
+ },
559
+ 'pattern suggest': {
560
+ usage: 'bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]',
561
+ options: {
562
+ stack: { type: 'string', default: null, required: true },
563
+ 'pattern-database-url-env': { type: 'string', default: null, required: true },
564
+ json: { type: 'boolean', default: false },
565
+ },
566
+ },
469
567
  'handles patch approve': {
470
568
  usage: 'bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]',
471
569
  options: {
package/lib/doctor.mjs CHANGED
@@ -2,10 +2,18 @@
2
2
  // D1's lib/workflow.mjs separates `computeWorkflowState()` from its cmdStatus/cmdNext callers --
3
3
  // this stays pure enough to unit test without spawning the CLI, and bin/bskel.mjs just renders
4
4
  // whatever this returns.
5
- import { execFileSync } from 'node:child_process';
6
5
  import { detectBuildCommand } from './verify.mjs';
7
6
  import { listCatalogChoices, loadCatalogEntry } from '../stack/apply.mjs';
8
7
  import { detectAstHelperAvailable } from '../handles/providers/java-spring/ast-bridge.mjs';
8
+ // D-zero-config-scan: re-exported from scanners/text-util.mjs (a cycle-free leaf module), NOT
9
+ // defined here -- this file transitively imports scanners/registry.mjs (via ./verify.mjs), which
10
+ // dynamically import()s every scanner adapter; an adapter importing binaryAvailable() FROM here
11
+ // would close that cycle. See scanners/text-util.mjs's own comment on binaryAvailable() for the
12
+ // real hang this caused before the fix. Every existing caller of `binaryAvailable` from this file
13
+ // (bin/bskel.mjs) needs no change -- it's still available at this same import path.
14
+ import { binaryAvailable } from '../scanners/text-util.mjs';
15
+
16
+ export { binaryAvailable };
9
17
 
10
18
  // D5: the three workflows that have tool requirements beyond "git + a supported Node runtime"
11
19
  // (every workflow needs those two -- preflight/contract don't need anything ELSE, so they're
@@ -34,15 +42,8 @@ function nodeVersionOk(versionString) {
34
42
  }
35
43
 
36
44
  function binaryCheck(name, { required, remediation }) {
37
- let ok = true;
38
- let detail = '';
39
- try {
40
- execFileSync(name, ['--version'], { stdio: 'pipe' });
41
- } catch {
42
- ok = false;
43
- detail = 'not found on PATH';
44
- }
45
- return { name: `binary: ${name}`, required, ok, detail, remediation: ok ? null : remediation };
45
+ const ok = binaryAvailable(name);
46
+ return { name: `binary: ${name}`, required, ok, detail: ok ? '' : 'not found on PATH', remediation: ok ? null : remediation };
46
47
  }
47
48
 
48
49
  function nodeVersionCheck() {