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.
- package/bin/bskel.mjs +308 -0
- package/contracts/emit.mjs +22 -6
- package/handles/providers/java-spring/plan.mjs +24 -3
- package/handles/providers/java-spring/rules.mjs +143 -0
- package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
- package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
- package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
- package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
- package/handles/providers/python-fastapi/rules.mjs +133 -0
- package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
- package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
- package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
- package/handles/providers/typescript-express/plan.mjs +15 -2
- package/handles/providers/typescript-express/rules.mjs +129 -0
- package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
- package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
- package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
- package/lib/cli.mjs +37 -0
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +9 -0
- package/package.json +2 -1
- package/rules/compile.mjs +433 -0
- package/rules/derived.mjs +87 -0
- package/rules/diagnostics.mjs +147 -0
- package/rules/store.mjs +141 -0
- package/rules/vocabulary.mjs +172 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +49 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- 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();
|
package/contracts/emit.mjs
CHANGED
|
@@ -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 "
|
|
21
|
-
// for this item (
|
|
22
|
-
//
|
|
23
|
-
export const CONTRACT_SCHEMA_VERSION = '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|