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
package/bin/bskel.mjs CHANGED
@@ -46,6 +46,13 @@ import {
46
46
  } from '../lib/cross-feature-collisions.mjs';
47
47
  import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting, reusableParamsFor } from '../new/index.mjs';
48
48
  import { recordPattern, listPatterns, getPattern, isMissingPatternTable, summarizePatternFrequency } from '../patterns/store.mjs';
49
+ // D-business-rules. rules/compile.mjs is pure (no fs/git/exit); rules/store.mjs owns all disk I/O.
50
+ import { compileRules, summarizeArtifact } from '../rules/compile.mjs';
51
+ import { rulesSourcePath, loadRulesSource, loadRulesArtifact, saveRulesArtifact, starterRulesSource } from '../rules/store.mjs';
52
+ import { PREDICATE_KINDS, explainRule } from '../rules/vocabulary.mjs';
53
+ import { emitRulesJavaSpring } from '../handles/providers/java-spring/rules.mjs';
54
+ import { emitRulesPythonFastApi } from '../handles/providers/python-fastapi/rules.mjs';
55
+ import { emitRulesTypeScriptExpress } from '../handles/providers/typescript-express/rules.mjs';
49
56
  import {
50
57
  requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
51
58
  requireValidPythonVersion, requireValidLicense, requireValidDatabase, requireSupportedJavaVersion,
@@ -107,6 +114,10 @@ function usage() {
107
114
  bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
108
115
  bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
109
116
  bskel dependency list --feature <id> [--json]
117
+ bskel rules check --feature <id> [--init] [--json]
118
+ bskel rules list --feature <id> [--json]
119
+ bskel rules explain --feature <id> --rule <id> [--json]
120
+ bskel rules emit --feature <id> [--module <name>] [--check] [--diff] [--force --reason "..."] [--json]
110
121
  bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]
111
122
  bskel catalog lint [<choice>] [--json]
112
123
  bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
@@ -2015,6 +2026,292 @@ function cmdDependencyList(args) {
2015
2026
  process.exit(0);
2016
2027
  }
2017
2028
 
2029
+ // D-business-rules (R6): compiles specs/<id>/rules.yaml (optional) plus the feature's own contract
2030
+ // into specs/<id>/rules/<id>.rules.json, and establishes the `rules` gate.
2031
+ //
2032
+ // Gated on `contract` having PASSED, the same posture `contract export`/`handles emit` take and
2033
+ // deliberately not the ungated posture `contract validate` takes: every rule's pointer, scalar
2034
+ // type, and enum state is verified against the contract, so compiling against a contract nobody
2035
+ // has accepted yet would bake unaccepted facts into an artifact that later drives real codegen.
2036
+ //
2037
+ // Refuses rather than approximates (R6): an ERROR-severity diagnostic is an authored mistake with
2038
+ // a real fix, not a fact to be waived -- see rules/diagnostics.mjs's own header for why this
2039
+ // command has no `waive` sibling the way `contract` does.
2040
+ function cmdRulesCheck(args) {
2041
+ const flags = parseCommand('rules check', args);
2042
+ if (flags.help) { console.log(renderCommandHelp('rules check')); process.exit(0); }
2043
+ setContext('rules check', flags);
2044
+ const root = requireRepoRoot();
2045
+ requirePreflightPassed(root);
2046
+ const contractResult = requireNamedGate(root, 'contract', flags.feature);
2047
+ if (contractResult.code !== EXIT.PASS) {
2048
+ const hint = contractResult.status === 'awaiting_disposition'
2049
+ ? `resolve it first -- \`bskel contract waive --feature ${flags.feature} --code <CODE> (--subject "..."|--all) --reason "..."\`, or \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if intentional.`
2050
+ : `run \`bskel contract emit --feature ${flags.feature}\` first.`;
2051
+ fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- ${hint}`, {
2052
+ next_actions: [{ command: `bskel contract emit --feature ${flags.feature}`, reason: 'the contract gate has not passed yet', mutating: true }],
2053
+ });
2054
+ }
2055
+
2056
+ const contract = loadContract(root, flags.feature);
2057
+ const contractRef = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
2058
+
2059
+ const sourcePath = rulesSourcePath(root, flags.feature);
2060
+ if (flags.init) {
2061
+ // Never overwrites: a starter file is a convenience for an empty slot, not a reset button.
2062
+ if (fs.existsSync(sourcePath)) {
2063
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--init refuses to overwrite an existing ${path.relative(root, sourcePath)} -- edit it directly, or delete it first if you really want a fresh starter.`);
2064
+ }
2065
+ fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
2066
+ writeFileAtomic(sourcePath, starterRulesSource(flags.feature));
2067
+ }
2068
+
2069
+ let source;
2070
+ try {
2071
+ source = loadRulesSource(root, flags.feature);
2072
+ } catch (err) {
2073
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
2074
+ }
2075
+
2076
+ const { artifact, diagnostics, blocking } = compileRules({ contract, source, contractRef });
2077
+ const errors = diagnostics.filter((d) => d.severity === 'error');
2078
+ const warnings = diagnostics.filter((d) => d.severity === 'warn');
2079
+
2080
+ if (blocking) {
2081
+ // Nothing is written on refusal -- the same "Nothing was written." posture
2082
+ // explainMissingCapability() uses. A half-compiled artifact would be worse than none.
2083
+ const detail = errors.map((e) => ` ${e.code}${e.subject ? ` (${e.subject})` : ''}: ${e.message}`).join('\n');
2084
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `blocked: ${errors.length} rule error(s) in ${path.relative(root, sourcePath)} -- nothing was written.\n${detail}`, {
2085
+ next_actions: [{ command: `bskel rules check --feature ${flags.feature}`, reason: 'fix the rule(s) above and re-run', mutating: true }],
2086
+ });
2087
+ }
2088
+
2089
+ saveRulesArtifact(root, flags.feature, artifact);
2090
+ const summary = summarizeArtifact(artifact);
2091
+ const gateState = passNamedGate(root, 'rules', flags.feature, {
2092
+ rule_count: summary.field + summary.cross + summary.transition + summary.derived,
2093
+ from_contract: summary.fromContract,
2094
+ declared: summary.declared,
2095
+ unsupported: summary.unsupported,
2096
+ });
2097
+
2098
+ if (flags.json) {
2099
+ // `gateState.gates.rules`, not the whole state object -- passNamedGate() returns the full
2100
+ // state, and every other command's --json exposes just its own gate record (cmdScan,
2101
+ // cmdScanDisposition, cmdScanCrossFeatureCheck all do exactly this).
2102
+ console.log(JSON.stringify({ feature_id: flags.feature, summary, unsupported: artifact.unsupported, gate: gateState.gates.rules }, null, 2));
2103
+ } else {
2104
+ console.log(`rules -- feature ${flags.feature}`);
2105
+ console.log(` ${summary.field} field, ${summary.cross} cross-field, ${summary.transition} transition across ${summary.operations} operation(s), ${summary.derived} derived (resource-scoped)`);
2106
+ console.log(` ${summary.fromContract} projected from the contract's own schema, ${summary.declared} declared in rules.yaml`);
2107
+ if (warnings.length > 0) {
2108
+ // Warnings go to stderr so `--json` stdout stays exactly one JSON document, and so
2109
+ // --quiet never suppresses them -- D-cli-contract's own rule.
2110
+ console.error(`\n${warnings.length} constraint(s) in the contract are NOT enforced by these rules:`);
2111
+ for (const w of warnings) console.error(` ${w.code}: ${w.message}`);
2112
+ }
2113
+ if (summary.field + summary.cross + summary.transition + summary.derived === 0) {
2114
+ console.log(` (no rules yet -- run \`bskel rules check --feature ${flags.feature} --init\` for a starter rules.yaml, or point \`bskel contract emit\` at an OpenAPI document to pick up its constraints automatically)`);
2115
+ }
2116
+ }
2117
+ process.exit(0);
2118
+ }
2119
+
2120
+ function cmdRulesList(args) {
2121
+ const flags = parseCommand('rules list', args);
2122
+ if (flags.help) { console.log(renderCommandHelp('rules list')); process.exit(0); }
2123
+ setContext('rules list', flags);
2124
+ const root = requireRepoRoot();
2125
+ const artifact = loadRulesArtifactOrExit(root, flags.feature);
2126
+
2127
+ if (flags.json) { console.log(JSON.stringify(artifact, null, 2)); process.exit(0); }
2128
+
2129
+ console.log(`rules -- feature ${artifact.feature_id}`);
2130
+ for (const [operationId, kinds] of Object.entries(artifact.operations)) {
2131
+ console.log(`\n ${operationId}`);
2132
+ for (const r of kinds.field ?? []) console.log(` [field] ${r.id} ${r.pointer} ${r.assert} ${JSON.stringify(r.value)} (${r.origin})`);
2133
+ for (const r of kinds.cross ?? []) console.log(` [cross] ${r.id} ${r.pointers.join(` ${r.assert} `)} (${r.origin})`);
2134
+ for (const r of kinds.transition ?? []) console.log(` [transition] ${r.id} ${r.pointer}: ${r.from.join('|')} -> ${r.to.join('|')} (${r.origin})`);
2135
+ }
2136
+ if (Object.keys(artifact.operations).length === 0 && (artifact.derived ?? []).length === 0) console.log(' (none)');
2137
+ if ((artifact.derived ?? []).length > 0) {
2138
+ console.log('\n derived (resource-scoped, not operation-scoped):');
2139
+ for (const r of artifact.derived) console.log(` [derived] ${r.id} ${r.resource}.${r.field} <- (${r.params.join(', ')}) (${r.origin})`);
2140
+ }
2141
+ if (artifact.unsupported.length > 0) {
2142
+ console.log(`\n NOT enforced (${artifact.unsupported.length}):`);
2143
+ for (const u of artifact.unsupported) console.log(` ${u.code}: ${u.reason}`);
2144
+ }
2145
+ process.exit(0);
2146
+ }
2147
+
2148
+ function cmdRulesExplain(args) {
2149
+ const flags = parseCommand('rules explain', args);
2150
+ if (flags.help) { console.log(renderCommandHelp('rules explain')); process.exit(0); }
2151
+ setContext('rules explain', flags);
2152
+ const root = requireRepoRoot();
2153
+ const artifact = loadRulesArtifactOrExit(root, flags.feature);
2154
+
2155
+ const found = [];
2156
+ for (const [operationId, kinds] of Object.entries(artifact.operations)) {
2157
+ for (const kind of PREDICATE_KINDS) {
2158
+ for (const rule of kinds[kind] ?? []) {
2159
+ if (rule.id === flags.rule) found.push({ operation: operationId, kind, rule });
2160
+ }
2161
+ }
2162
+ }
2163
+ // `derived` rules are resource-scoped, not operation-scoped (R5) -- searched separately, same
2164
+ // reasoning compileRules() gives for compiling them on their own path.
2165
+ for (const rule of artifact.derived ?? []) {
2166
+ if (rule.id === flags.rule) found.push({ operation: null, kind: 'derived', rule });
2167
+ }
2168
+ if (found.length === 0) {
2169
+ const known = [];
2170
+ for (const kinds of Object.values(artifact.operations)) {
2171
+ for (const kind of PREDICATE_KINDS) for (const r of kinds[kind] ?? []) known.push(r.id);
2172
+ }
2173
+ for (const r of artifact.derived ?? []) known.push(r.id);
2174
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no rule "${flags.rule}" in feature ${flags.feature} -- known rule ids: ${known.sort().join(', ') || '(none)'}`);
2175
+ }
2176
+ const [{ operation, kind, rule }] = found;
2177
+ const explanation = explainRule({ operation, kind, rule });
2178
+ if (flags.json) console.log(JSON.stringify({ feature_id: artifact.feature_id, operation, kind, rule, explanation }, null, 2));
2179
+ else {
2180
+ console.log(`rule "${rule.id}" -- feature ${artifact.feature_id}`);
2181
+ if (operation) console.log(` operation: ${operation}`);
2182
+ else console.log(` resource: ${rule.resource}.${rule.field}`);
2183
+ console.log(` kind: ${kind}`);
2184
+ console.log(` origin: ${rule.origin}${rule.origin === 'contract' ? " (projected from this operation's own requestBodySchema -- change the OpenAPI document, not rules.yaml)" : ' (declared in rules.yaml)'}`);
2185
+ console.log(` means: ${explanation}`);
2186
+ }
2187
+ process.exit(0);
2188
+ }
2189
+
2190
+ // D-business-rules (R9): emits the generic rule executor plus the compiled artifact as a classpath
2191
+ // resource. Mirrors cmdObserveEmit's own precondition chain and blocked/--check reporting shape --
2192
+ // same emitUnits() conflict machinery, same --check/--diff/--force/--reason semantics, and the same
2193
+ // explicit adapter dispatch rather than handles/registry.mjs's plan+emit provider mechanism (this
2194
+ // command has no `plan` verb either, and operates directly on an already-compiled artifact).
2195
+ //
2196
+ // Gated on the `rules` gate rather than `contract`: the artifact this emits is what `rules check`
2197
+ // produced and schema-validated, so emitting while that gate is stale would ship a runtime resource
2198
+ // that no longer matches the rules anyone reviewed.
2199
+ function cmdRulesEmit(args) {
2200
+ const flags = parseCommand('rules emit', args);
2201
+ if (flags.help) { console.log(renderCommandHelp('rules emit')); process.exit(0); }
2202
+ setContext('rules emit', flags);
2203
+ const root = requireRepoRoot();
2204
+ requirePreflightPassed(root);
2205
+ if (flags.force && (!flags.reason || !flags.reason.trim())) {
2206
+ fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel rules emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
2207
+ }
2208
+
2209
+ const rulesResult = requireNamedGate(root, 'rules', flags.feature);
2210
+ if (rulesResult.code !== EXIT.PASS) {
2211
+ fail(rulesResult.code, gateReasonForCode(rulesResult.code), `blocked: \`rules\` gate for ${flags.feature} is ${rulesResult.status} -- run \`bskel rules check --feature ${flags.feature}\` first.`, {
2212
+ next_actions: [{ command: `bskel rules check --feature ${flags.feature}`, reason: 'the rules gate has not passed yet', mutating: true }],
2213
+ });
2214
+ }
2215
+
2216
+ const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
2217
+ const artifact = loadRulesArtifactOrExit(root, flags.feature);
2218
+ const dryRun = flags.check || flags.diff;
2219
+
2220
+ let result;
2221
+ if (scanReport.adapter === 'java-spring') {
2222
+ let basePackage;
2223
+ try {
2224
+ basePackage = detectBasePackage(root);
2225
+ } catch (err) {
2226
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2227
+ }
2228
+ if (!basePackage) {
2229
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', 'could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
2230
+ }
2231
+ try {
2232
+ result = emitRulesJavaSpring({ repoRoot: root, featureId: flags.feature, artifact, basePackage, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
2233
+ } catch (err) {
2234
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2235
+ }
2236
+ } else if (scanReport.adapter === 'python-fastapi') {
2237
+ // python's own package-root detection needs a module to anchor itself -- same asymmetry
2238
+ // observe emit's own comment already documents for this exact adapter.
2239
+ let fastApiPlan;
2240
+ try {
2241
+ fastApiPlan = planPythonFastApi({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
2242
+ } catch (err) {
2243
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2244
+ }
2245
+ try {
2246
+ result = emitRulesPythonFastApi({ repoRoot: root, featureId: flags.feature, artifact, plan: fastApiPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
2247
+ } catch (err) {
2248
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2249
+ }
2250
+ } else if (scanReport.adapter === 'typescript-express') {
2251
+ // TS's own project-root detection needs a module to anchor itself too -- same --module
2252
+ // dependency python-fastapi's own rules emit already established.
2253
+ let tsPlan;
2254
+ try {
2255
+ tsPlan = planTypeScriptExpress({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
2256
+ } catch (err) {
2257
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2258
+ }
2259
+ try {
2260
+ result = emitRulesTypeScriptExpress({ repoRoot: root, featureId: flags.feature, artifact, plan: tsPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
2261
+ } catch (err) {
2262
+ fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
2263
+ }
2264
+ } else {
2265
+ fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel rules emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi, typescript-express).`);
2266
+ }
2267
+
2268
+ const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
2269
+ const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
2270
+ const allNotes = [...notes];
2271
+ if (flags.force && forced.length === 0 && conflicts.length === 0) allNotes.push('--force had no effect: 0 conflicts found in this run\'s scope');
2272
+ else if (flags.force && forced.length > 0) allNotes.push(`--force overwrote ${forced.length} diverged file(s): ${forced.join(', ')}`);
2273
+
2274
+ if (blocked) {
2275
+ if (flags.json) {
2276
+ console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: true, check: dryRun }, null, 2));
2277
+ } else {
2278
+ const verb = dryRun ? 'would be blocked' : 'blocked';
2279
+ console.error(`${verb}: ${conflicts.length} generated file(s) diverged from what backend-skeleton last wrote -- ${dryRun ? 'a real run would refuse to overwrite them' : 'refusing to overwrite'} without --force:`);
2280
+ for (const c of conflicts) console.error(` ${c.path} (${c.kind})\n ${c.reason}`);
2281
+ if (!dryRun) console.error(`\nre-run with: bskel rules emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''} --force --reason "..."`);
2282
+ }
2283
+ process.exit(EXIT_CODES.HANDLES_CONFLICT);
2284
+ }
2285
+
2286
+ if (flags.json) {
2287
+ console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, check: dryRun, postEmitNotes }, null, 2));
2288
+ } else if (!flags.quiet) {
2289
+ console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
2290
+ for (const w of written) console.log(` ${w}`);
2291
+ if (allNotes.length > 0) {
2292
+ console.log('\nnotes:');
2293
+ for (const n of allNotes) console.log(` - ${n}`);
2294
+ }
2295
+ if (dryRun) console.log(`\n${renderFileActions(actions)}`);
2296
+ else for (const n of postEmitNotes) console.log(`\n${n}`);
2297
+ }
2298
+ if (dryRun) process.exit(wouldChange ? EXIT_CODES.CHECK_FAILED : EXIT_CODES.OK);
2299
+ process.exit(0);
2300
+ }
2301
+
2302
+ function loadRulesArtifactOrExit(root, featureId) {
2303
+ let artifact;
2304
+ try {
2305
+ artifact = loadRulesArtifact(root, featureId);
2306
+ } catch (err) {
2307
+ fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', err.message);
2308
+ }
2309
+ if (!artifact) {
2310
+ fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no compiled rules for ${featureId} -- run \`bskel rules check --feature ${featureId}\` first`);
2311
+ }
2312
+ return artifact;
2313
+ }
2314
+
2018
2315
  // D-contract-history: a derived VIEW over the contract file's own git history in whatever repo
2019
2316
  // bskel is invoked in -- reads, never writes. Deliberately does NOT try to correlate a commit to
2020
2317
  // a specific `.sbf/<feature>.history.jsonl` gate-pass event: that file is per-machine, gitignored,
@@ -4156,6 +4453,17 @@ async function dispatchCommand(cmd, rest) {
4156
4453
  process.exit(14);
4157
4454
  break;
4158
4455
  }
4456
+ case 'rules': {
4457
+ const sub = rest[0];
4458
+ const subArgs = rest.slice(1);
4459
+ if (sub === 'check') return cmdRulesCheck(subArgs);
4460
+ if (sub === 'list') return cmdRulesList(subArgs);
4461
+ if (sub === 'explain') return cmdRulesExplain(subArgs);
4462
+ if (sub === 'emit') return cmdRulesEmit(subArgs);
4463
+ usage();
4464
+ process.exit(14);
4465
+ break;
4466
+ }
4159
4467
  case 'stack': {
4160
4468
  if (rest[0] === 'apply') return cmdStackApply(rest.slice(1));
4161
4469
  usage();
@@ -15,12 +15,12 @@ import { pathPrefixCandidates, unreflectedPathPrefixes } from './export.mjs';
15
15
  // a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
16
16
  export const BARE_UUID_PATTERN = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
17
17
 
18
- // A7/A8/A9/A10: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
18
+ // A7/A8/A9/A10/X2: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
19
19
  // const -- bin/bskel.mjs's loadContract() imports this too, so the friendly "re-emit with the
20
- // current bskel" message and the value actually written here cannot drift apart. Bumped "7" -> "8"
21
- // for this item (sourceDescription) -- again cheap, the friendly re-emit pre-check needs zero
22
- // code change -- see D-openapi-description.
23
- export const CONTRACT_SCHEMA_VERSION = '8';
20
+ // current bskel" message and the value actually written here cannot drift apart. Bumped "8" -> "9"
21
+ // for this item (the `expansion` field, D-route-expansion-provenance) -- again cheap, the friendly
22
+ // re-emit pre-check needs zero code change.
23
+ export const CONTRACT_SCHEMA_VERSION = '9';
24
24
 
25
25
  // A9 (D-openapi-path-params): `sourcePathParamSchemas` (a Map<name, schema>, contracts/openapi.mjs's
26
26
  // applyPathParameterSchemas -- present only for a matched/adopted operation whose source document
@@ -66,7 +66,11 @@ function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
66
66
  // Object>>`) -- findMethodParams() shares the same balanced-delimiter analyzer that fixes the
67
67
  // scanner's identical GenericWithSpaceController case.
68
68
  function detectRequestBody(filePath, methodName) {
69
- if (!filePath || !fs.existsSync(filePath)) return null;
69
+ // X5 (D-route-expansion-provenance): explicit guard, not the incidental fact that a regex built
70
+ // from the literal string "null" also happens not to match anything -- a null methodName means
71
+ // there is genuinely no literal per-action source method to look in (see the same reasoning
72
+ // D-typescript-express-inline-handlers already established for resolveHandlerFile()).
73
+ if (!filePath || !methodName || !fs.existsSync(filePath)) return null;
70
74
  const text = fs.readFileSync(filePath, 'utf8');
71
75
  const params = findMethodParams(text, methodName);
72
76
  if (params === null) return null;
@@ -377,6 +381,15 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
377
381
  }));
378
382
  }
379
383
  const { pathParams, pathParamsHeuristic } = pathParamsSchema(route, pathParamSchemas);
384
+ // X2 (D-route-expansion-provenance): present only when the scan adapter recorded which
385
+ // multi-route declaration this endpoint came from (ep.declarationIndex) AND that
386
+ // declaration actually resolves on the owning controller -- omitted for every ordinary
387
+ // 1:1 endpoint, the same "absent unless it applies" discipline A9's pathParamsHeuristic
388
+ // already established. No adapter populates declarationIndex yet (forward-compatible
389
+ // shape only) -- see test/contract.test.mjs's expansion-field tests for a hand-built
390
+ // fixture exercising this.
391
+ const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
392
+ const expansion = declaration ? { rule: declaration.rule, declarationLine: declaration.line, label: declaration.label ?? null } : null;
380
393
  operations[operationId] = {
381
394
  verb,
382
395
  path: route,
@@ -397,6 +410,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
397
410
  ...(sourceRequestBody ? { sourceRequestBody } : {}),
398
411
  // A9: omitted (not []) when every segment resolved from source, or the route has none.
399
412
  ...(pathParamsHeuristic ? { pathParamsHeuristic } : {}),
413
+ // X2: omitted entirely when this endpoint wasn't expanded from a multi-route
414
+ // declaration -- see the computation above.
415
+ ...(expansion ? { expansion } : {}),
400
416
  // A10: omitted entirely when --descriptions was not passed, the source had none, or
401
417
  // it failed the length cap -- same "omitted, never null/false" discipline as every
402
418
  // other field above.
@@ -25,7 +25,13 @@ function findFetchOperation(controllers, entityClassName) {
25
25
  if (ep.verb !== 'GET' || !ep.operationId) continue;
26
26
  const suffix = ep.path.slice(controller.basePath.length);
27
27
  if (/^\/\{[^/]+\}$/.test(suffix)) {
28
- return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className };
28
+ // X5 (D-route-expansion-provenance): threaded through so the caller can name the real
29
+ // cause when ep.method is null instead of a bare "method not found" message -- a 1:N
30
+ // framework-synthesized route (e.g. Spring Data REST) has a real operationId but no
31
+ // literal per-action method to correlate to. null on every endpoint in today's adapter
32
+ // (it never populates declarationIndex) -- forward-compatible only, not yet reachable.
33
+ const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
34
+ return { operationId: ep.operationId, method: ep.method, path: ep.path, controllerFile: controller.file, controllerClassName: controller.className, declaration };
29
35
  }
30
36
  }
31
37
  }
@@ -282,6 +288,12 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
282
288
  }
283
289
 
284
290
  const fetchOp = findFetchOperation(targetModule.controllers, entity.className);
291
+ // X5 (D-route-expansion-provenance): a real, already-fail-closed case, checked explicitly
292
+ // instead of relying on findRequiredAuthority()/countServiceMethodParams()'s own `!methodName`
293
+ // guards to silently swallow it -- those guards return safely (no crash) either way, but
294
+ // without this check the notes below would read literally "...found for X.null" / "could not
295
+ // find a null(...) method", which is confusing, not honest. See D-resolver-scope.
296
+ const fetchOpMissingMethod = Boolean(fetchOp && !fetchOp.method);
285
297
  const authorityResult = findRequiredAuthority(fetchOp?.controllerFile ?? null, fetchOp?.method ?? null);
286
298
  const requiredAuthority = authorityResult.authority;
287
299
  const service = pkIsNonUuid ? null : findServiceFile(javaSrcRoot, targetModule.module, entity.className);
@@ -300,6 +312,12 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
300
312
 
301
313
  if (!fetchOp) {
302
314
  notes.push(`${entity.className}: no single-resource GET endpoint found on a controller whose name contains "${entity.className}" -- fetch() will need to be hand-written`);
315
+ } else if (fetchOpMissingMethod) {
316
+ const declaration = fetchOp.declaration;
317
+ const declNote = declaration
318
+ ? ` -- it was expanded from ${declaration.label ?? declaration.rule} at ${path.relative(javaSrcRoot, fetchOp.controllerFile)}:${declaration.line} (rule: ${declaration.rule}); the framework generates this handler at runtime, so no literal per-action source method exists to correlate to`
319
+ : ' -- no literal per-action source method exists to correlate to';
320
+ notes.push(`${entity.className}: the matched endpoint (GET ${fetchOp.path})${declNote}. Resolver NOT generated -- this is a structural boundary of static-scan-based handles codegen, not a bug. See D-resolver-scope.`);
303
321
  } else if (authorityResult.unsupported) {
304
322
  notes.push(`${entity.className}: @PreAuthorize found on ${fetchOp.controllerClassName}.${fetchOp.method} (or its class) but not in the simple hasRole('X')/hasAuthority('X') shape this scanner understands (e.g. hasAnyRole/SpEL) -- requiredAuthority() defaults to "TODO_ROLE" (fails closed) until a human fixes it`);
305
323
  } else if (!requiredAuthority) {
@@ -316,12 +334,15 @@ export function planHandles({ javaSrcRoot, scanReport, module: moduleName, resou
316
334
  if (!pkIsNonUuid) {
317
335
  notes.push(`${entity.className}: no ${entity.className}Service found under domain/${targetModule.module}/application/ or ${targetModule.module}/ -- resolver NOT generated for this entity (would produce a broken import). Emit it by hand once the right service is identified.`);
318
336
  }
319
- } else if (fetchOp && serviceParamCount !== 1) {
337
+ } else if (fetchOp && !fetchOpMissingMethod && serviceParamCount !== 1) {
320
338
  const reason = serviceParamCount === null
321
339
  ? `could not find a ${fetchOp.method}(...) method on ${service.serviceType} to confirm its argument count`
322
340
  : `${service.serviceType}.${fetchOp.method} takes ${serviceParamCount} argument(s), not the single resource UUID the generated resolver always passes`;
323
341
  notes.push(`${entity.className}: ${reason} -- resolver NOT generated (would either fail to compile or silently call the wrong overload and drop a required scoping argument, e.g. an organization/cohort id). Wire it by hand -- ResourceResolver#fetch/#patchField receive the request's Authentication (D-resolver-authentication-context) for exactly this case, e.g. deriving a tenant/org id the same way the resource's own controller already does.`);
324
342
  }
343
+ // X5: fetchOpMissingMethod already pushed its own single, clear note above -- suppressing
344
+ // this one avoids a second, confusing "could not find a null(...) method" note for the same
345
+ // root cause.
325
346
 
326
347
  // A3 (D-patch-strategy): only worth computing once fetch()/the resolver itself is actually
327
348
  // going to be generated -- an entity with no resolver has nowhere for patchField() codegen
@@ -447,7 +468,7 @@ export function plan({ repoRoot, scanReport, module: moduleName, resourceFilter
447
468
  module: inner.module,
448
469
  resources: inner.resources.map((r) => ({
449
470
  ...r,
450
- readPath: (r.service && r.fetchOperation) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
471
+ readPath: (r.service && r.fetchOperation && r.fetchOperation.method) ? `${r.service.serviceType}.${r.fetchOperation.method}()` : null,
451
472
  })),
452
473
  notes: inner.notes,
453
474
  };
@@ -0,0 +1,143 @@
1
+ // D-business-rules (R9): the emit-side half of compiled business rules for java-spring. Its own
2
+ // file, sibling to observe.mjs and emit.mjs, for exactly the reason observe.mjs's own header gives:
3
+ // rules, observe, and handles are orthogonal capabilities that happen to share the same repo-wide
4
+ // "generated infra" pattern, not the same feature.
5
+ //
6
+ // This emitter is deliberately THIN. It renders two fixed infra classes and copies an
7
+ // already-compiled artifact onto the classpath -- it makes no decision about what a rule means.
8
+ // Every such decision was made and verified in JS by rules/compile.mjs at `bskel rules check` time
9
+ // (R3), which is the invariant that lets three languages agree.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { emitUnits, unifiedDiff } from '../../_engine.mjs';
14
+ import { detectJacksonPackage } from './emit.mjs';
15
+ import { renderExprInfix, groupDerivedByResource } from '../../../rules/derived.mjs';
16
+ import { pascalCase } from '../../../rules/vocabulary.mjs';
17
+
18
+ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
19
+ const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
20
+
21
+ // Repo-wide, shared across every feature that ever runs `bskel rules emit` -- RuleSetLoader
22
+ // discovers every `bskel/*.rules.json` classpath resource at startup rather than being regenerated
23
+ // per feature, so these files are true infra (create-once-per-repo, all-or-nothing conflict unit),
24
+ // the same treatment observe.mjs's own INFRA_FILES get. EnforceRules/RuleEnforcementAspect (R8) are
25
+ // the automatic field/cross wiring path -- transition rules stay a manual RuleCheck.checkTransitions
26
+ // call, since a transition guard needs the resource's current state, which no annotation can supply
27
+ // generically (see EnforceRules.java.tmpl's own javadoc).
28
+ const INFRA_FILES = [
29
+ { template: 'RuleSetLoader.java.tmpl', target: 'global/rules/RuleSetLoader.java' },
30
+ { template: 'RuleCheck.java.tmpl', target: 'global/rules/RuleCheck.java' },
31
+ { template: 'EnforceRules.java.tmpl', target: 'global/rules/EnforceRules.java' },
32
+ { template: 'RuleEnforcementAspect.java.tmpl', target: 'global/rules/RuleEnforcementAspect.java' },
33
+ ];
34
+
35
+ function render(templatePath, vars) {
36
+ let content = fs.readFileSync(templatePath, 'utf8');
37
+ for (const [key, value] of Object.entries(vars)) {
38
+ content = content.replaceAll(`{{${key}}}`, String(value));
39
+ }
40
+ return content;
41
+ }
42
+
43
+ function writeUnit(target, content) {
44
+ fs.mkdirSync(path.dirname(target), { recursive: true });
45
+ fs.writeFileSync(target, content);
46
+ }
47
+
48
+ // R5/Phase 3: one file per resource, one static method per derived field. A COMPLETE, compiling
49
+ // pure function -- never a stub -- matching R5's own "the call site stays the human's, the
50
+ // function itself does not" framing. Params are always `double`: this vocabulary's operator set
51
+ // (add/sub/mul/div, R3) is numeric-only by construction, so there is no type to infer beyond
52
+ // "a number" -- see rules/vocabulary.mjs's own DERIVED_OPS.
53
+ function renderDerivedClass(resource, rules, featureId, basePackage) {
54
+ const methods = rules.map((rule) => {
55
+ const params = rule.params.map((p) => `double ${p}`).join(', ');
56
+ const formula = renderExprInfix(rule.expr);
57
+ return `\t/** {@code ${rule.field} = ${formula}} -- rule "${rule.id}". */\n\tpublic static double compute${pascalCase(rule.field)}(${params}) {\n\t\treturn ${formula};\n\t}`;
58
+ });
59
+ return `package ${basePackage}.global.rules;
60
+
61
+ /**
62
+ * Generated by backend-skeleton ({@code bskel rules emit}) for feature ${featureId}.
63
+ *
64
+ * <p>D-business-rules (R5): a complete, compiling pure function per derived field -- never a
65
+ * stub. Nothing calls these methods; wire the call site yourself wherever a computed field on
66
+ * {@code ${resource}} should actually be set (e.g. before persisting).
67
+ */
68
+ public final class ${resource}Rules {
69
+
70
+ private ${resource}Rules() {}
71
+
72
+ ${methods.join('\n\n')}
73
+ }
74
+ `;
75
+ }
76
+
77
+ /**
78
+ * @param {object} args
79
+ * @param {object} args.artifact the already-compiled, already-schema-validated rules artifact
80
+ * (rules/store.mjs's loadRulesArtifact) -- this function never reads
81
+ * specs/ itself, the same split observe.mjs holds for the contract.
82
+ */
83
+ export function emitRulesJavaSpring({ repoRoot, featureId, artifact, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
84
+ const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
85
+ const jacksonPackage = detectJacksonPackage(repoRoot);
86
+
87
+ const infraUnits = INFRA_FILES.map((f) => ({
88
+ id: f.template,
89
+ templatePath: path.join(TEMPLATES_DIR, f.template),
90
+ targetAbs: path.join(javaSrcRoot, f.target),
91
+ rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
92
+ }));
93
+
94
+ // R5/Phase 3: one <Resource>Rules.java per resource with a derived field, emitted the SAME
95
+ // unconditional way the .rules.json spec resource below is -- a complete, never-hand-edited
96
+ // pure function file, the same category as RuleCheck.java itself, not a resolver stub. See
97
+ // groupDerivedByResource()'s own doc comment for the named cross-feature-collision limitation.
98
+ const derivedGroups = groupDerivedByResource(artifact.derived ?? []);
99
+ const derivedUnits = derivedGroups.map(([resource, rules]) => ({
100
+ resource,
101
+ targetAbs: path.join(javaSrcRoot, 'global', 'rules', `${resource}Rules.java`),
102
+ content: renderDerivedClass(resource, rules, featureId, basePackage),
103
+ }));
104
+
105
+ const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
106
+
107
+ for (const unit of derivedUnits) {
108
+ const relPath = path.relative(repoRoot, unit.targetAbs);
109
+ const diskContent = fs.existsSync(unit.targetAbs) ? fs.readFileSync(unit.targetAbs, 'utf8') : null;
110
+ const action = diskContent === null ? 'create' : (diskContent === unit.content ? 'unchanged' : 'update');
111
+ if (!dryRun) writeUnit(unit.targetAbs, unit.content);
112
+ result.written.push(relPath);
113
+ const actionEntry = { path: relPath, kind: 'derived', resourceType: unit.resource, action };
114
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, unit.content);
115
+ result.actions.push(actionEntry);
116
+ }
117
+
118
+ // The compiled artifact, copied verbatim onto the classpath. Byte-identical to the file
119
+ // `bskel rules check` already wrote and schema-validated -- deliberately NOT re-serialized
120
+ // here, so there is exactly one representation of these rules and no chance of the runtime
121
+ // executing something subtly different from what was audited. `kind: 'spec'` = always
122
+ // regenerated, not conflict-tracked (nobody hand-finishes a generated data file).
123
+ const content = `${JSON.stringify(artifact, null, '\t')}\n`;
124
+ const target = path.join(repoRoot, 'src', 'main', 'resources', 'bskel', `${featureId}.rules.json`);
125
+ const relPath = path.relative(repoRoot, target);
126
+ const diskContent = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
127
+ const action = diskContent === null ? 'create' : (diskContent === content ? 'unchanged' : 'update');
128
+ if (!dryRun) writeUnit(target, content);
129
+ result.written.push(relPath);
130
+ const actionEntry = { path: relPath, kind: 'spec', action };
131
+ if (computeDiff && action === 'update') actionEntry.diff = unifiedDiff(relPath, diskContent, content);
132
+ result.actions.push(actionEntry);
133
+
134
+ return {
135
+ ...result,
136
+ postEmitNotes: [
137
+ `field/cross rules are LOADED but not yet ACTIVE: add @EnforceRules(operationId = "<operationId>") to the real controller method to have them checked automatically on every call.`,
138
+ `defaults to OBSERVE (logs to the "bskel.rules.violations" logger, never rejects a request) -- set bskel.rules.mode: enforce in your own application.yml when you're ready for a real violation to reject with HTTP 400. No re-run of \`bskel rules emit\` needed to switch.`,
139
+ `transition rules are NOT checked by @EnforceRules -- they need the resource's CURRENT state, which no annotation can supply generically. Call RuleCheck.checkTransitions(rules, body, Map.of("/status", current.getStatus())) directly wherever your service layer has that state. A transition whose current state is not supplied reports "unchecked", never a silent pass.`,
140
+ ...(derivedUnits.length > 0 ? [`derived field(s) compiled to ${derivedUnits.map((u) => `${u.resource}Rules`).join(', ')}: complete, compiling pure functions, but NOTHING calls them -- wire the call site yourself. If another feature also declares a derived field on the same resource, whichever feature's \`rules emit\` runs LAST wins for that file -- not merged.`] : []),
141
+ ],
142
+ };
143
+ }
@@ -0,0 +1,44 @@
1
+ package {{BASE_PACKAGE}}.global.rules;
2
+
3
+ import java.lang.annotation.ElementType;
4
+ import java.lang.annotation.Retention;
5
+ import java.lang.annotation.RetentionPolicy;
6
+ import java.lang.annotation.Target;
7
+
8
+ /**
9
+ * D-business-rules (R8/R9): apply this to an EXISTING controller method (never generated onto
10
+ * one -- a human decides which methods are worth checking) to have {@link RuleEnforcementAspect}
11
+ * run this operation's compiled FIELD and CROSS rules against the real request body.
12
+ *
13
+ * <p><b>Does not check TRANSITION rules.</b> A transition guard needs the resource's CURRENT
14
+ * persisted state, which this annotation's own request-scoped context has no way to obtain
15
+ * generically -- that is the one honest gap this automatic path leaves open, matching
16
+ * {@link RuleCheck#checkTransitions}'s own javadoc. Call it directly, with the current state your
17
+ * own service layer already has, wherever a transition needs checking.
18
+ *
19
+ * <p>Whether a violation is logged only, or rejects the request, is a RUNTIME property
20
+ * ({@code bskel.rules.mode: observe|enforce}, default {@code observe}) -- not baked into the
21
+ * generated code, so switching from observation to enforcement never requires re-running
22
+ * {@code bskel rules emit}. See {@link RuleEnforcementAspect} for the full behavior.
23
+ *
24
+ * <p>Must be applied to the CONCRETE implementation method, not an interface method it overrides
25
+ * -- same {@code AopUtils#getMostSpecificMethod} constraint {@code ObserveContract}'s own javadoc
26
+ * already documents.
27
+ *
28
+ * <p>Example:
29
+ * <pre>{@code
30
+ * @EnforceRules(operationId = "updateWidget")
31
+ * public ResponseEntity<WidgetResponse> updateWidget(@PathVariable UUID widgetId, @RequestBody UpdateWidgetRequest request) { ... }
32
+ * }</pre>
33
+ *
34
+ * <p>Generated by backend-skeleton ({@code bskel rules emit}). Requires {@code
35
+ * spring-boot-starter-aop} on the target repo's own classpath -- NOT added automatically, matching
36
+ * this project's established boundary of never auto-editing a target's build file.
37
+ */
38
+ @Retention(RetentionPolicy.RUNTIME)
39
+ @Target(ElementType.METHOD)
40
+ public @interface EnforceRules {
41
+
42
+ /** Must match a key in the emitted {@code <feature-id>.rules.json}'s own operations map. */
43
+ String operationId();
44
+ }