backend-skeleton 1.0.0-beta.4 → 1.0.0-beta.6
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/README.md +27 -15
- package/bin/bskel.mjs +252 -2
- package/handles/_engine.mjs +3 -1
- package/handles/observe-schema-projection.mjs +92 -0
- package/handles/providers/java-spring/emit.mjs +51 -27
- package/handles/providers/java-spring/observe.mjs +93 -0
- package/handles/providers/java-spring/templates/ContractCheck.java.tmpl +129 -0
- package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +190 -0
- package/handles/providers/java-spring/templates/ObserveContract.java.tmpl +42 -0
- package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +134 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +54 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +10 -12
- package/handles/providers/python-fastapi/emit.mjs +47 -21
- package/handles/providers/python-fastapi/observe.mjs +96 -0
- package/handles/providers/python-fastapi/templates/contract_check.py.tmpl +112 -0
- package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +165 -0
- package/handles/providers/python-fastapi/templates/observed_schema.py.tmpl +105 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +6 -8
- package/handles/providers/python-fastapi/templates/resolver_policy.py.tmpl +19 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +5 -0
- package/lib/cli.mjs +26 -0
- package/lib/gate-definitions.mjs +20 -1
- package/package.json +2 -2
- package/schemas/conformance-report.schema.json +49 -0
- package/schemas/observe-receipt.schema.json +32 -0
package/README.md
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/backend-skeleton)
|
|
6
6
|
[](https://github.com/popixoxipop-collab/backend-skeleton/releases)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
`bskel` is a deterministic gate layer for AI-assisted (and human) backend work: before a change is
|
|
9
|
+
allowed to count as done, `bskel` checks it against disk — not against what an agent or a person
|
|
10
|
+
claims. Is this branch actually based on the real default branch? Does this "new" module collide
|
|
11
|
+
with one that already exists elsewhere in the codebase? Does the emitted contract still match the
|
|
12
|
+
source it claims to describe? Did a hand-finished file just get silently overwritten? Every one of
|
|
13
|
+
those is a gate backed by a `content-hash` on disk, not a prompt instruction a future session could
|
|
14
|
+
ignore, forget, or talk itself past.
|
|
15
|
+
|
|
16
|
+
Scaffolding codegen — Java/Spring Boot, Python/FastAPI, and TypeScript/JavaScript Express repos,
|
|
10
17
|
feature_id-scoped machine-readable contracts, UUID-addressable field handles, and stack-choice
|
|
11
|
-
(e.g. ngrok) wiring —
|
|
12
|
-
|
|
18
|
+
(e.g. ngrok) wiring — rides on top of that same gate machinery. It's useful on its own, but the
|
|
19
|
+
reason the gates exist first is what makes the codegen safe to trust in a brownfield repo, instead
|
|
20
|
+
of just another thing to double-check by hand.
|
|
13
21
|
|
|
14
22
|
`bskel` exists because a previous ad-hoc agent-driven scaffolding attempt branched a worktree 658
|
|
15
23
|
commits behind the real default branch and never noticed. Every gate in this tool is a regression
|
|
@@ -24,13 +32,17 @@ stable. Split by actual maturity, not by feature list:
|
|
|
24
32
|
measured verification against a real production Spring Boot repo (see `DECISIONS.md`), plus a
|
|
25
33
|
synthetic fixture corpus for every adapter, run in CI on every change.
|
|
26
34
|
- **`handles`** (the UUID-addressable resolver/codec/router codegen) is functionally complete and
|
|
27
|
-
tested the same way, but has never been deployed to a real production repo
|
|
28
|
-
|
|
29
|
-
`HandleRegistry
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
tested the same way, but has never been deployed to a real production repo. Two gaps named in
|
|
36
|
+
earlier betas have since been partially closed: `O3` (opt-in `--enforce-registry`, checked
|
|
37
|
+
fetch/patch/recover against `HandleRegistry`, revocation-aware) and `O5` (fetch/patch now derive
|
|
38
|
+
independently correct roles instead of silently sharing one) are both implemented -- see
|
|
39
|
+
`DECISIONS.md`'s `D-handle-registry-enforcement`/`D-resolver-authorization-action-aware`. Real
|
|
40
|
+
gaps remain and are explicitly still open, not closed: registry enforcement is opt-in, off by
|
|
41
|
+
default; authorization inference still only recognizes a single `@PreAuthorize(hasRole(...))`
|
|
42
|
+
shape (`hasAuthority`, role lists, ownership/tenant policy are unaddressed); and Java/Python are
|
|
43
|
+
the only providers either applies to -- TypeScript Express has no persistent handle table at
|
|
44
|
+
all. Treat `handles emit`'s output as a scaffold to finish by hand, not a production-ready
|
|
45
|
+
subsystem, until a real deployment happens.
|
|
34
46
|
|
|
35
47
|
Version numbers, install instructions, and a real feedback path will firm up as this gets used
|
|
36
48
|
against more real repos.
|
|
@@ -227,10 +239,10 @@ audited, never silent.
|
|
|
227
239
|
## Security model
|
|
228
240
|
|
|
229
241
|
`bskel` generates code that runs in production, so it was put through an adversarial security
|
|
230
|
-
review (Codex, security-only lens, independent of the build process) —
|
|
231
|
-
with an inline `D-security-N` comment at its exact location in the code
|
|
232
|
-
|
|
233
|
-
section):
|
|
242
|
+
review (Codex, security-only lens, independent of the build process) — 10 numbered items, all
|
|
243
|
+
fixed, each with an inline `D-security-N` comment at its exact location in the code (the last item
|
|
244
|
+
bundles three additional lower-priority fixes from Codex's own broader "other things worth
|
|
245
|
+
checking" pass). Highlights (full record in `DECISIONS.md`'s "Security hardening pass" section):
|
|
234
246
|
|
|
235
247
|
- **Prototype-pollution guards** everywhere a user-controlled string indexes a plain object
|
|
236
248
|
(`operation_id` values like `"constructor"`/`"__proto__"` are rejected, not silently resolved via
|
package/bin/bskel.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { repoRoot, localDefaultBranch, fileHistory, showFileAtRevision, headSha,
|
|
|
9
9
|
import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, awaitNamedGateDisposition, EXIT } from '../lib/gates.mjs';
|
|
10
10
|
import { REPO_GATE_ID, GATE_NAMES, gateScopeId, requireGateDefinition } from '../lib/gate-definitions.mjs';
|
|
11
11
|
import { getGate, loadState, historyPath } from '../lib/state.mjs';
|
|
12
|
-
import { writeFileAtomic } from '../lib/fsutil.mjs';
|
|
12
|
+
import { writeFileAtomic, sha256File } from '../lib/fsutil.mjs';
|
|
13
13
|
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
14
|
import { withLockSync } from '../lib/lock.mjs';
|
|
15
15
|
import { specDir, specPath } from '../lib/paths.mjs';
|
|
@@ -43,6 +43,10 @@ import { buildOpenApiDocument, pathPrefixCandidates, unreflectedPathPrefixes, ST
|
|
|
43
43
|
import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../stack/apply.mjs';
|
|
44
44
|
import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
|
|
45
45
|
import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
46
|
+
import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
|
|
47
|
+
import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
|
|
48
|
+
import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
|
|
49
|
+
import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
|
|
46
50
|
import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
|
|
47
51
|
import { computeWorkflowState } from '../lib/workflow.mjs';
|
|
48
52
|
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
|
|
@@ -79,6 +83,8 @@ function usage() {
|
|
|
79
83
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
80
84
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
81
85
|
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
|
|
86
|
+
bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
|
|
87
|
+
bskel observe import --feature <id> --receipts <path> [--json]
|
|
82
88
|
bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
|
|
83
89
|
bskel status [--feature <id>] [--json]
|
|
84
90
|
bskel next [--feature <id>] [--json]
|
|
@@ -1972,7 +1978,8 @@ function cmdHandlesEmit(args) {
|
|
|
1972
1978
|
}
|
|
1973
1979
|
if (dryRun) console.error(`\n${renderFileActions(actions)}`);
|
|
1974
1980
|
}
|
|
1975
|
-
// D-process-exit-audit: bounded by 7 + plan.resources.length units
|
|
1981
|
+
// D-process-exit-audit: bounded by 7 + 2*plan.resources.length units (D-resolver-policy-split:
|
|
1982
|
+
// Resolver + Policy per resource), no pipe-truncation risk.
|
|
1976
1983
|
// Carries a real payload (already printed above in --json mode) -- no diagnostic envelope.
|
|
1977
1984
|
// P4 precedent (catalog lint): reused, not a new exit code -- --check reaching the exact
|
|
1978
1985
|
// same verdict a real run would (exit 15) is the point, not a distinct "check found a
|
|
@@ -2135,6 +2142,242 @@ async function cmdHandlesAudit(args) {
|
|
|
2135
2142
|
process.exit(0);
|
|
2136
2143
|
}
|
|
2137
2144
|
|
|
2145
|
+
// D-runtime-conformance-receipts: mirrors cmdHandlesEmit's own precondition chain and
|
|
2146
|
+
// blocked/--check reporting shape closely -- same "contract must be pass first" gate dependency,
|
|
2147
|
+
// same O2-style conflict machinery via emitUnits() (reused unmodified inside
|
|
2148
|
+
// emitObserveJavaSpring), same --check/--diff/--force/--reason semantics. Does not pass any gate
|
|
2149
|
+
// itself -- only `observe import` (real receipts imported) represents evidence worth gating on.
|
|
2150
|
+
function cmdObserveEmit(args) {
|
|
2151
|
+
const flags = parseCommand('observe emit', args);
|
|
2152
|
+
if (flags.help) { console.log(renderCommandHelp('observe emit')); process.exit(0); }
|
|
2153
|
+
setContext('observe emit', flags);
|
|
2154
|
+
const root = requireRepoRoot();
|
|
2155
|
+
requirePreflightPassed(root);
|
|
2156
|
+
if (flags.force && (!flags.reason || !flags.reason.trim())) {
|
|
2157
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel observe emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
2161
|
+
if (contractResult.code !== EXIT.PASS) {
|
|
2162
|
+
const hint = contractResult.status === 'awaiting_disposition'
|
|
2163
|
+
? `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.`
|
|
2164
|
+
: `run \`bskel contract emit --feature ${flags.feature}\` first.`;
|
|
2165
|
+
fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- ${hint}`, {
|
|
2166
|
+
next_actions: [{ command: `bskel contract emit --feature ${flags.feature}`, reason: 'the contract gate has not passed yet', mutating: true }],
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const scanReport = loadScanReportOrExit(root, flags.feature);
|
|
2171
|
+
const contract = loadContract(root, flags.feature);
|
|
2172
|
+
const dryRun = flags.check || flags.diff;
|
|
2173
|
+
|
|
2174
|
+
// D-runtime-conformance-receipts: explicit two-branch dispatch, not handles/registry.mjs's own
|
|
2175
|
+
// provider mechanism -- that registry's schema/loader (a closed plan+emit verb pair) is
|
|
2176
|
+
// specifically shaped for the HANDLES feature (per-resource resolver units); observe has no
|
|
2177
|
+
// `plan` verb and operates directly on contract.operations. Matches this project's own
|
|
2178
|
+
// established precedent for a single-other-provider feature (cmdHandlesPlan's --ast flag: a
|
|
2179
|
+
// bare adapter check, no registry involved) rather than adopting a mechanism built for a
|
|
2180
|
+
// different, unrelated concern.
|
|
2181
|
+
let result;
|
|
2182
|
+
if (scanReport.adapter === 'java-spring') {
|
|
2183
|
+
let basePackage;
|
|
2184
|
+
try {
|
|
2185
|
+
basePackage = detectBasePackage(root);
|
|
2186
|
+
} catch (err) {
|
|
2187
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2188
|
+
}
|
|
2189
|
+
if (!basePackage) {
|
|
2190
|
+
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?');
|
|
2191
|
+
}
|
|
2192
|
+
try {
|
|
2193
|
+
result = emitObserveJavaSpring({ repoRoot: root, featureId: flags.feature, contract, basePackage, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2194
|
+
} catch (err) {
|
|
2195
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2196
|
+
}
|
|
2197
|
+
} else if (scanReport.adapter === 'python-fastapi') {
|
|
2198
|
+
// python's own package-root detection needs a module to anchor itself (unlike java's
|
|
2199
|
+
// detectBasePackage(), which needs no module/feature context at all) -- a genuine
|
|
2200
|
+
// CLI-surface asymmetry between the two providers, not an oversight. See DECISIONS.md.
|
|
2201
|
+
let fastApiPlan;
|
|
2202
|
+
try {
|
|
2203
|
+
fastApiPlan = planPythonFastApi({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
|
|
2204
|
+
} catch (err) {
|
|
2205
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
result = emitObservePythonFastApi({ repoRoot: root, featureId: flags.feature, contract, plan: fastApiPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2209
|
+
} catch (err) {
|
|
2210
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2211
|
+
}
|
|
2212
|
+
} else {
|
|
2213
|
+
fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi)`);
|
|
2214
|
+
}
|
|
2215
|
+
const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
|
|
2216
|
+
const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
|
|
2217
|
+
const allNotes = [...notes];
|
|
2218
|
+
if (flags.force && forced.length === 0 && conflicts.length === 0) allNotes.push('--force had no effect: 0 conflicts found in this run\'s scope');
|
|
2219
|
+
else if (flags.force && forced.length > 0) allNotes.push(`--force overwrote ${forced.length} diverged file(s): ${forced.join(', ')}`);
|
|
2220
|
+
|
|
2221
|
+
if (blocked) {
|
|
2222
|
+
if (flags.json) {
|
|
2223
|
+
console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: true, check: dryRun }, null, 2));
|
|
2224
|
+
} else {
|
|
2225
|
+
const verb = dryRun ? 'would be blocked' : 'blocked';
|
|
2226
|
+
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:`);
|
|
2227
|
+
for (const c of conflicts) console.error(` ${c.path} (${c.kind})\n ${c.reason}`);
|
|
2228
|
+
if (written.length > 0) {
|
|
2229
|
+
console.error(`\n${written.length} other file(s) ${dryRun ? 'would still be written' : 'were still written this run'}:`);
|
|
2230
|
+
for (const w of written) console.error(` ${w}`);
|
|
2231
|
+
}
|
|
2232
|
+
if (!dryRun) console.error(`\nre-run with: bskel observe emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''} --force --reason "..."`);
|
|
2233
|
+
if (dryRun) console.error(`\n${renderFileActions(actions)}`);
|
|
2234
|
+
}
|
|
2235
|
+
// D-process-exit-audit: same shape/reasoning as `handles emit`'s own blocked path -- reused
|
|
2236
|
+
// exit code, not a new one (D-cli-contract: numbers are a public contract, not renumbered).
|
|
2237
|
+
process.exit(EXIT_CODES.HANDLES_CONFLICT);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
if (flags.json) {
|
|
2241
|
+
console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, check: dryRun, postEmitNotes }, null, 2));
|
|
2242
|
+
} else if (!flags.quiet) {
|
|
2243
|
+
console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
|
|
2244
|
+
for (const w of written) console.log(` ${w}`);
|
|
2245
|
+
if (allNotes.length > 0) {
|
|
2246
|
+
console.log('\nnotes:');
|
|
2247
|
+
for (const n of allNotes) console.log(` - ${n}`);
|
|
2248
|
+
}
|
|
2249
|
+
if (dryRun) {
|
|
2250
|
+
console.log(`\n${renderFileActions(actions)}`);
|
|
2251
|
+
} else {
|
|
2252
|
+
for (const n of postEmitNotes) console.log(`\n${n}`);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
if (dryRun) process.exit(wouldChange ? EXIT_CODES.CHECK_FAILED : EXIT_CODES.OK);
|
|
2256
|
+
process.exit(0);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// D-runtime-conformance-receipts: validation order mirrors `contract emit --openapi-file`'s
|
|
2260
|
+
// "compute+validate everything before writing anything" discipline. Two failure classes on a
|
|
2261
|
+
// per-line basis, handled differently -- a line that is not valid JSON at all is NOISE (a human's
|
|
2262
|
+
// log pipeline realistically is not perfectly scoped to just the receipts logger), counted and
|
|
2263
|
+
// warned, not fatal; a line that IS valid JSON but fails observe-receipt.schema.json is real
|
|
2264
|
+
// CORRUPTION -- abort the whole import loudly, same "a bad file must not leave a half-updated
|
|
2265
|
+
// state" principle `contract emit --openapi-file` already applies.
|
|
2266
|
+
const MAX_RECEIPTS_BYTES = 64 * 1024 * 1024; // provisional -- no real oracle to measure against yet, unlike A1's own measured caps (stated explicitly, not pretended-measured)
|
|
2267
|
+
const MAX_RECEIPTS_LINES = 200_000;
|
|
2268
|
+
|
|
2269
|
+
function cmdObserveImport(args) {
|
|
2270
|
+
const flags = parseCommand('observe import', args);
|
|
2271
|
+
if (flags.help) { console.log(renderCommandHelp('observe import')); process.exit(0); }
|
|
2272
|
+
setContext('observe import', flags);
|
|
2273
|
+
const root = requireRepoRoot();
|
|
2274
|
+
requirePreflightPassed(root);
|
|
2275
|
+
|
|
2276
|
+
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
2277
|
+
if (contractResult.code !== EXIT.PASS) {
|
|
2278
|
+
fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- receipts are only meaningful against an established contract. Run \`bskel contract emit --feature ${flags.feature}\` first.`);
|
|
2279
|
+
}
|
|
2280
|
+
const contract = loadContract(root, flags.feature);
|
|
2281
|
+
|
|
2282
|
+
let stat;
|
|
2283
|
+
try {
|
|
2284
|
+
stat = fs.statSync(flags.receipts);
|
|
2285
|
+
} catch {
|
|
2286
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no readable file at ${flags.receipts}`);
|
|
2287
|
+
}
|
|
2288
|
+
if (!stat.isFile()) {
|
|
2289
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.receipts} is not a regular file`);
|
|
2290
|
+
}
|
|
2291
|
+
if (stat.size > MAX_RECEIPTS_BYTES) {
|
|
2292
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.receipts} is ${stat.size} bytes, over the provisional ${MAX_RECEIPTS_BYTES}-byte cap`);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
const rawLines = fs.readFileSync(flags.receipts, 'utf8').split('\n').filter((l) => l.trim().length > 0);
|
|
2296
|
+
if (rawLines.length > MAX_RECEIPTS_LINES) {
|
|
2297
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.receipts} has ${rawLines.length} non-empty lines, over the provisional ${MAX_RECEIPTS_LINES}-line cap`);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
let noiseLines = 0;
|
|
2301
|
+
const receipts = [];
|
|
2302
|
+
for (const line of rawLines) {
|
|
2303
|
+
let parsed;
|
|
2304
|
+
try {
|
|
2305
|
+
parsed = JSON.parse(line);
|
|
2306
|
+
} catch {
|
|
2307
|
+
noiseLines++;
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
const { ok, errors } = validateAgainstSchema('observe-receipt.schema.json', parsed);
|
|
2311
|
+
if (!ok) {
|
|
2312
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a line is valid JSON but not a valid receipt -- ${formatSchemaErrors(errors).join('; ')}. Aborting the whole import (a corrupted receipts file must not partially land).`);
|
|
2313
|
+
}
|
|
2314
|
+
receipts.push(parsed);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
for (const r of receipts) {
|
|
2318
|
+
if (r.feature_id !== flags.feature || r.feature_uid !== contract.feature_uid) {
|
|
2319
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a receipt is for feature "${r.feature_id}" (${r.feature_uid}), not "${flags.feature}" (${contract.feature_uid}) -- aborting the whole import (a receipts file for the wrong feature must never partially land).`);
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const currentContractHash = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
|
|
2324
|
+
let matched = 0;
|
|
2325
|
+
let staleContractRef = 0;
|
|
2326
|
+
let violationCount = 0;
|
|
2327
|
+
let unsupportedCount = 0;
|
|
2328
|
+
const byOperation = {};
|
|
2329
|
+
for (const r of receipts) {
|
|
2330
|
+
const isMatched = r.contract_ref === currentContractHash;
|
|
2331
|
+
if (isMatched) matched++; else staleContractRef++;
|
|
2332
|
+
const opStats = byOperation[r.operation_id] ?? { matched: 0, stale_contract_ref: 0, violations: 0 };
|
|
2333
|
+
if (isMatched) opStats.matched++; else opStats.stale_contract_ref++;
|
|
2334
|
+
for (const v of r.violations ?? []) {
|
|
2335
|
+
if (v.keyword === 'unsupported') unsupportedCount++; else violationCount++;
|
|
2336
|
+
if (isMatched) opStats.violations++;
|
|
2337
|
+
}
|
|
2338
|
+
byOperation[r.operation_id] = opStats;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
const report = {
|
|
2342
|
+
sbf_conformance_report: '1',
|
|
2343
|
+
feature_id: flags.feature,
|
|
2344
|
+
feature_uid: contract.feature_uid,
|
|
2345
|
+
generated_at: new Date().toISOString(),
|
|
2346
|
+
source: describeSourceFile(root, flags.receipts),
|
|
2347
|
+
counts: {
|
|
2348
|
+
receipt_lines: receipts.length,
|
|
2349
|
+
noise_lines: noiseLines,
|
|
2350
|
+
matched, stale_contract_ref: staleContractRef,
|
|
2351
|
+
violations: violationCount,
|
|
2352
|
+
unsupported: unsupportedCount,
|
|
2353
|
+
},
|
|
2354
|
+
by_operation: byOperation,
|
|
2355
|
+
};
|
|
2356
|
+
const { ok: reportOk, errors: reportErrors } = validateAgainstSchema('conformance-report.schema.json', report);
|
|
2357
|
+
if (!reportOk) {
|
|
2358
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed conformance report failed its own schema -- ${formatSchemaErrors(reportErrors).join('; ')}`);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const reportPath = specPath(root, flags.feature, 'observe', `${flags.feature}.conformance-report.json`);
|
|
2362
|
+
writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
2363
|
+
|
|
2364
|
+
// Evidence-first, not verdict-first (same as `contract`'s own precedent: a `partial` contract
|
|
2365
|
+
// is still passable via waiver) -- passes on a successful STRUCTURAL import, never on "zero
|
|
2366
|
+
// violations found". Whether violation counts should block CI is a policy question for whoever
|
|
2367
|
+
// reads the report, deliberately not decided here -- see DECISIONS.md's own deferred list.
|
|
2368
|
+
const gateState = passNamedGate(root, 'conformance', flags.feature, { receipt_count: receipts.length, matched, violations: violationCount });
|
|
2369
|
+
|
|
2370
|
+
if (flags.json) {
|
|
2371
|
+
console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
|
|
2372
|
+
} else {
|
|
2373
|
+
console.log(`imported ${receipts.length} receipt(s) (${matched} matched the current contract, ${staleContractRef} stale, ${noiseLines} noise line(s) skipped)`);
|
|
2374
|
+
console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
|
|
2375
|
+
console.log(`wrote ${path.relative(root, reportPath)}`);
|
|
2376
|
+
console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
|
|
2377
|
+
}
|
|
2378
|
+
process.exit(0);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2138
2381
|
// S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
|
|
2139
2382
|
// input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
|
|
2140
2383
|
function describeStale(g) {
|
|
@@ -2628,6 +2871,13 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2628
2871
|
process.exit(14);
|
|
2629
2872
|
break;
|
|
2630
2873
|
}
|
|
2874
|
+
case 'observe': {
|
|
2875
|
+
if (rest[0] === 'emit') return cmdObserveEmit(rest.slice(1));
|
|
2876
|
+
if (rest[0] === 'import') return cmdObserveImport(rest.slice(1));
|
|
2877
|
+
usage();
|
|
2878
|
+
process.exit(14);
|
|
2879
|
+
break;
|
|
2880
|
+
}
|
|
2631
2881
|
case 'verify':
|
|
2632
2882
|
cmdVerify(rest);
|
|
2633
2883
|
break;
|
package/handles/_engine.mjs
CHANGED
|
@@ -277,5 +277,7 @@ export function emitUnits({ repoRoot, featureId, provider, force = false, reason
|
|
|
277
277
|
|
|
278
278
|
if (!dryRun && manifestChanged) saveManifest(repoRoot, manifest);
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// D-resolver-policy-split: two units (Resolver + Policy) now share one resourceType, so
|
|
281
|
+
// resolverStubs.push above runs twice per resource -- dedupe here rather than at every push site.
|
|
282
|
+
return { written, resolverStubs: [...new Set(resolverStubs)], conflicts, orphans, notes, forced, blocked: conflicts.length > 0, actions };
|
|
281
283
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// D-runtime-conformance-receipts: pure functions projecting a feature contract's own
|
|
2
|
+
// `operations[opId]` shape down to the bounded, checkable-only structure every observe provider's
|
|
3
|
+
// generated runtime checker understands. Extracted out of handles/providers/java-spring/observe.mjs
|
|
4
|
+
// (the first provider to need this) once python-fastapi needed the identical logic -- nothing here
|
|
5
|
+
// touches java-specific or python-specific concepts, it operates only on the contract's own
|
|
6
|
+
// JSON-schema-shaped fields (contracts/emit.mjs's own output). See DECISIONS.md
|
|
7
|
+
// D-runtime-conformance-receipts, Decision B, for the full "what's checkable is decided ONCE here"
|
|
8
|
+
// reasoning.
|
|
9
|
+
|
|
10
|
+
// A property this project's own contracts already fully control the generation of when there's
|
|
11
|
+
// nothing deeper than a directly-checkable value -- string/number/integer/boolean with no nested
|
|
12
|
+
// properties/items/$ref/anyOf/allOf/oneOf. What's checkable is decided ONCE here, in JS, at
|
|
13
|
+
// `observe emit` time (baked into a projected, pre-simplified resource for the target runtime);
|
|
14
|
+
// each provider's own generated checker is a dumb, mechanical executor of an already-simplified
|
|
15
|
+
// instruction set, never a second independent JSON-Schema interpreter.
|
|
16
|
+
const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean']);
|
|
17
|
+
|
|
18
|
+
export function isScalarLeaf(schema) {
|
|
19
|
+
if (!schema || typeof schema !== 'object') return false;
|
|
20
|
+
if (schema.properties || schema.items || schema.$ref || schema.anyOf || schema.allOf || schema.oneOf) return false;
|
|
21
|
+
return typeof schema.type === 'string' && SCALAR_TYPES.has(schema.type);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Projects a real (possibly arbitrarily deep) JSON Schema object -- contract.operations[id]'s own
|
|
25
|
+
// requestBodySchema/responseSchema/errorSchema, present only when `contract emit --openapi-file`
|
|
26
|
+
// was used and something resolved (contracts/emit.mjs) -- down to the bounded, checkable-only
|
|
27
|
+
// shape every generated checker understands: top-level `required`, scalar-leaf `properties` kept
|
|
28
|
+
// as {type, pattern?}, everything else marked `unsupported` at its own JSON Pointer rather than
|
|
29
|
+
// silently treated as pass. A non-object root (anyOf/allOf/oneOf/$ref/any non-'object' type -- the
|
|
30
|
+
// real shape a multi-status-unioned responseSchema can take) is marked unsupported at the root
|
|
31
|
+
// pointer "" wholesale, rather than guessed into a partial projection.
|
|
32
|
+
export function projectBodySchema(schema) {
|
|
33
|
+
if (!schema || typeof schema !== 'object') return null;
|
|
34
|
+
if (schema.anyOf || schema.allOf || schema.oneOf || schema.$ref || schema.type !== 'object') {
|
|
35
|
+
return { required: [], properties: {}, unsupported: [''] };
|
|
36
|
+
}
|
|
37
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === 'string') : [];
|
|
38
|
+
const properties = {};
|
|
39
|
+
const unsupported = [];
|
|
40
|
+
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
|
|
41
|
+
if (isScalarLeaf(propSchema)) {
|
|
42
|
+
properties[key] = { type: propSchema.type, ...(typeof propSchema.pattern === 'string' ? { pattern: propSchema.pattern } : {}) };
|
|
43
|
+
} else {
|
|
44
|
+
unsupported.push(`/${key}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { required, properties, unsupported };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// pathParams is ALWAYS the narrow, bskel-controlled vocabulary contracts/emit.mjs's own
|
|
51
|
+
// pathParamsSchema() produces (type:'object', additionalProperties:false, properties/required,
|
|
52
|
+
// each property {type:'string', pattern?}) -- passed through structurally rather than re-derived,
|
|
53
|
+
// but still routed through the same scalar-leaf check as body properties, defensively, in case a
|
|
54
|
+
// hand-edited contract ever puts something deeper there.
|
|
55
|
+
export function projectPathParams(pathParamsSchema) {
|
|
56
|
+
const required = Array.isArray(pathParamsSchema?.required) ? pathParamsSchema.required.filter((r) => typeof r === 'string') : [];
|
|
57
|
+
const properties = {};
|
|
58
|
+
const unsupported = [];
|
|
59
|
+
for (const [key, propSchema] of Object.entries(pathParamsSchema?.properties ?? {})) {
|
|
60
|
+
if (isScalarLeaf(propSchema)) {
|
|
61
|
+
properties[key] = { type: propSchema.type, ...(typeof propSchema.pattern === 'string' ? { pattern: propSchema.pattern } : {}) };
|
|
62
|
+
} else {
|
|
63
|
+
unsupported.push(`/${key}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { required, properties, unsupported };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A8: sourceResponses' own keys are literal status codes/ranges/"default" straight from a real
|
|
70
|
+
// source document (schemas/feature-contract.schema.json's own propertyNames pattern), never
|
|
71
|
+
// re-bucketed here -- matching status against them at runtime (a real observed code against
|
|
72
|
+
// "4XX"/"default") is a bounded, mechanical string comparison, not JSON-Schema interpretation, so
|
|
73
|
+
// doing it in each provider's own generated checker doesn't violate the "one interpreter" boundary.
|
|
74
|
+
export function projectStatuses(sourceResponses) {
|
|
75
|
+
return sourceResponses ? Object.keys(sourceResponses) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function projectOperation(opContract) {
|
|
79
|
+
return {
|
|
80
|
+
verb: opContract.verb,
|
|
81
|
+
path: opContract.path,
|
|
82
|
+
pathParams: projectPathParams(opContract.pathParams),
|
|
83
|
+
// Normalized to always a JSON string ("true"/"false"/"unknown") -- opContract.body is a
|
|
84
|
+
// true|false|'unknown' tri-state (mixed boolean/string), awkward for a generated checker to
|
|
85
|
+
// parse unambiguously; String() keeps the projected shape uniformly typed.
|
|
86
|
+
body: String(opContract.body),
|
|
87
|
+
request: opContract.body === false ? null : projectBodySchema(opContract.requestBodySchema),
|
|
88
|
+
response: projectBodySchema(opContract.responseSchema),
|
|
89
|
+
error: projectBodySchema(opContract.errorSchema),
|
|
90
|
+
statuses: projectStatuses(opContract.sourceResponses),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -12,6 +12,11 @@ const PROVIDER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
|
12
12
|
const TEMPLATES_DIR = path.join(PROVIDER_ROOT, 'templates');
|
|
13
13
|
const MIGRATION_TEMPLATE = path.join(TEMPLATES_DIR, 'migration.sql.tmpl');
|
|
14
14
|
const RESOLVER_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverStub.java.tmpl');
|
|
15
|
+
// D-resolver-policy-split: a resource's live-derived, security-relevant declarations
|
|
16
|
+
// (type/requiredAuthority/requiredAuthorityForPatch/contractRef/featureUid) are generated into
|
|
17
|
+
// this separate, always-safe-to-regenerate companion file rather than the same file as the
|
|
18
|
+
// hand-editable patchField() body -- see the template's own javadoc and DECISIONS.md.
|
|
19
|
+
const RESOLVER_POLICY_TEMPLATE = path.join(TEMPLATES_DIR, 'ResourceResolverPolicyStub.java.tmpl');
|
|
15
20
|
|
|
16
21
|
const INFRA_FILES = [
|
|
17
22
|
{ template: 'HandleCodec.java.tmpl', target: 'global/handle/HandleCodec.java' },
|
|
@@ -146,7 +151,7 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
146
151
|
|
|
147
152
|
const resolverUnits = plan.resources
|
|
148
153
|
.filter((r) => r.willGenerateResolver) // see plan.mjs: no broken imports generated on purpose
|
|
149
|
-
.
|
|
154
|
+
.flatMap((resource) => {
|
|
150
155
|
const patchable = resource.patchable ?? [];
|
|
151
156
|
// A blocked update service (see plan.mjs's updateServiceBlockedReason) means NO field of
|
|
152
157
|
// this resource can be auto-generated regardless of approvals -- ignore any recorded
|
|
@@ -155,21 +160,17 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
155
160
|
const approvedFields = resource.updateServiceBlockedReason ? new Set() : currentlyApprovedFields(patchApprovals, resource.type, patchable);
|
|
156
161
|
const { needsValidation, needsPatchFieldImport } = computeCodegenNeeds(patchable, approvedFields);
|
|
157
162
|
const serviceField = lowerFirst(resource.service.serviceType);
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
163
|
+
const sharedVars = { BASE_PACKAGE: basePackage, MODULE: plan.module, RESOURCE_TYPE: resource.type, FEATURE_ID: featureId };
|
|
164
|
+
// D-resolver-policy-split: the resolver's own vars no longer include the four
|
|
165
|
+
// live-derived/security-relevant tokens -- those move to policyVars below, into a
|
|
166
|
+
// separate generated file, specifically so a hand-edited patchField() (which stales
|
|
167
|
+
// THIS file's own template render) can never block one of those values from updating.
|
|
168
|
+
const resolverVars = {
|
|
169
|
+
...sharedVars,
|
|
162
170
|
SERVICE_IMPORT: `${basePackage}.domain.${plan.module}.application.${resource.service.serviceType}`,
|
|
163
171
|
SERVICE_TYPE: resource.service.serviceType,
|
|
164
172
|
SERVICE_FIELD: serviceField,
|
|
165
173
|
FETCH_METHOD: resource.fetchOperation.method,
|
|
166
|
-
REQUIRED_AUTHORITY: resource.requiredAuthority,
|
|
167
|
-
// O5 (D-resolver-authorization-action-aware): independently derived from the UPDATE
|
|
168
|
-
// endpoint's own @PreAuthorize -- see plan.mjs's own computation.
|
|
169
|
-
REQUIRED_AUTHORITY_PATCH: resource.requiredAuthorityForPatch,
|
|
170
|
-
FEATURE_ID: featureId,
|
|
171
|
-
CONTRACT_REF: contractRef,
|
|
172
|
-
FEATURE_UID: featureUid,
|
|
173
174
|
PATCH_IMPORTS: needsValidation ? buildPatchImports({ basePackage, module: plan.module, dtoTypeName: resource.dtoTypeName, needsPatchFieldImport, jacksonPackage }) : '',
|
|
174
175
|
PATCH_FIELDS: needsValidation ? buildPatchFields() : '',
|
|
175
176
|
PATCH_FIELD_BODY: renderPatchFieldBody({
|
|
@@ -182,27 +183,50 @@ export function emitJavaSpring({ repoRoot, featureId, plan, basePackage, resourc
|
|
|
182
183
|
blockedReason: resource.updateServiceBlockedReason,
|
|
183
184
|
}),
|
|
184
185
|
};
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
...vars,
|
|
194
|
-
FEATURE_ID: ownerId,
|
|
195
|
-
CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
|
|
196
|
-
FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
|
|
197
|
-
}),
|
|
186
|
+
const policyVars = {
|
|
187
|
+
...sharedVars,
|
|
188
|
+
REQUIRED_AUTHORITY: resource.requiredAuthority,
|
|
189
|
+
// O5 (D-resolver-authorization-action-aware): independently derived from the UPDATE
|
|
190
|
+
// endpoint's own @PreAuthorize -- see plan.mjs's own computation.
|
|
191
|
+
REQUIRED_AUTHORITY_PATCH: resource.requiredAuthorityForPatch,
|
|
192
|
+
CONTRACT_REF: contractRef,
|
|
193
|
+
FEATURE_UID: featureUid,
|
|
198
194
|
};
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
id: 'ResourceResolverStub.java.tmpl',
|
|
198
|
+
resourceType: resource.type,
|
|
199
|
+
module: plan.module,
|
|
200
|
+
templatePath: RESOLVER_TEMPLATE,
|
|
201
|
+
targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}Resolver.java`),
|
|
202
|
+
rendered: render(RESOLVER_TEMPLATE, resolverVars),
|
|
203
|
+
// Only FEATURE_ID varies by owner now -- the four moved tokens no longer live here.
|
|
204
|
+
pristineRenderFor: (ownerId) => render(RESOLVER_TEMPLATE, { ...resolverVars, FEATURE_ID: ownerId }),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: 'ResourceResolverPolicyStub.java.tmpl',
|
|
208
|
+
resourceType: resource.type,
|
|
209
|
+
module: plan.module,
|
|
210
|
+
templatePath: RESOLVER_POLICY_TEMPLATE,
|
|
211
|
+
targetAbs: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure', `${resource.type}ResolverPolicy.java`),
|
|
212
|
+
rendered: render(RESOLVER_POLICY_TEMPLATE, policyVars),
|
|
213
|
+
pristineRenderFor: (ownerId) => render(RESOLVER_POLICY_TEMPLATE, {
|
|
214
|
+
...policyVars,
|
|
215
|
+
FEATURE_ID: ownerId,
|
|
216
|
+
CONTRACT_REF: ownerId === featureId ? contractRef : contractRefFor(ownerId),
|
|
217
|
+
FEATURE_UID: ownerId === featureId ? featureUid : featureUidFor(ownerId),
|
|
218
|
+
}),
|
|
219
|
+
},
|
|
220
|
+
];
|
|
199
221
|
});
|
|
200
222
|
|
|
201
223
|
const orphanScan = (!resourceFilter && plan.module) ? {
|
|
202
224
|
dir: path.join(javaSrcRoot, 'domain', plan.module, 'infrastructure'),
|
|
203
225
|
module: plan.module,
|
|
204
|
-
|
|
205
|
-
|
|
226
|
+
// D-resolver-policy-split: recognize both generated suffixes -- longest-first so
|
|
227
|
+
// 'FooResolverPolicy.java' isn't misclassified as a plain '...Policy' resource type.
|
|
228
|
+
matchesFile: (file) => file.endsWith('ResolverPolicy.java') || file.endsWith('Resolver.java'),
|
|
229
|
+
resourceTypeOf: (file, _content) => file.replace(/ResolverPolicy\.java$|Resolver\.java$/, ''),
|
|
206
230
|
} : null;
|
|
207
231
|
|
|
208
232
|
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits, orphanScan, dryRun, computeDiff });
|