backend-skeleton 1.0.0 → 1.1.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/README.md +66 -4
- package/bin/bskel.mjs +125 -18
- package/contracts/export.mjs +39 -4
- package/contracts/openapi.mjs +292 -27
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +75 -32
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +51 -7
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +24 -3
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +32 -4
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +135 -46
- package/handles/providers/typescript-express/observe.mjs +7 -6
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/cli.mjs +11 -2
- package/lib/exit-codes.mjs +21 -0
- package/lib/verify.mjs +23 -6
- package/package.json +5 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +108 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/typescript-express.mjs +13 -2
- package/schemas/feature-contract.schema.json +3 -3
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.schema.json +58 -0
- package/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +47 -6
package/README.md
CHANGED
|
@@ -23,6 +23,29 @@ of just another thing to double-check by hand.
|
|
|
23
23
|
commits behind the real default branch and never noticed. Every gate in this tool is a regression
|
|
24
24
|
check for a specific failure mode found the same way — see `DECISIONS.md` for the full record.
|
|
25
25
|
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
*A real terminal, a real `bskel` binary, a real fixture repo — not a scripted transcript. Source:
|
|
29
|
+
[`docs/demo.tape`](docs/demo.tape), regenerated with [`docs/record-demo.sh`](docs/record-demo.sh).*
|
|
30
|
+
|
|
31
|
+
## Contents
|
|
32
|
+
|
|
33
|
+
- [Status: 1.0.0](#status-100)
|
|
34
|
+
- [Quickstart](#quickstart)
|
|
35
|
+
- [Starting from nothing (greenfield)](#starting-from-nothing-greenfield)
|
|
36
|
+
- [Publishing a feature's contract as OpenAPI (optional)](#publishing-a-features-contract-as-openapi-optional)
|
|
37
|
+
- [Database schema (optional)](#database-schema-optional)
|
|
38
|
+
- [Applying DDL to a live database (optional)](#applying-ddl-to-a-live-database-optional)
|
|
39
|
+
- [Declaring field-to-field dependencies (optional)](#declaring-field-to-field-dependencies-optional)
|
|
40
|
+
- [Patching a config file (optional)](#patching-a-config-file-optional)
|
|
41
|
+
- [Signed gate attestations (optional)](#signed-gate-attestations-optional)
|
|
42
|
+
- [Compatibility](#compatibility)
|
|
43
|
+
- [Generated-file policy](#generated-file-policy)
|
|
44
|
+
- [Security model](#security-model)
|
|
45
|
+
- [Troubleshooting](#troubleshooting)
|
|
46
|
+
- [What ships in the package](#what-ships-in-the-package)
|
|
47
|
+
- [License](#license)
|
|
48
|
+
|
|
26
49
|
## Status: 1.0.0
|
|
27
50
|
|
|
28
51
|
As of `1.0.0`, this project makes an explicit API-stability promise: **`bskel`'s CLI surface --
|
|
@@ -50,10 +73,13 @@ production -- which still splits the same way it always has:
|
|
|
50
73
|
gaps remain and are explicitly still open, not closed: registry enforcement is opt-in, off by
|
|
51
74
|
default; authorization inference now recognizes both `@PreAuthorize(hasRole(...))` and
|
|
52
75
|
`hasAuthority(...)` (see `DECISIONS.md`'s `D-resolver-authorization-action-aware`), but
|
|
53
|
-
`hasAnyRole`/`hasAnyAuthority` (list-shape), ownership, and tenant policy are still unaddressed
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
76
|
+
`hasAnyRole`/`hasAnyAuthority` (list-shape), ownership, and tenant policy are still unaddressed.
|
|
77
|
+
All three providers (Java/Python/TypeScript) now generate a real `sbf_handle`/
|
|
78
|
+
`sbf_handle_snapshot` schema and support `--enforce-registry`/`recover()` -- TypeScript's own
|
|
79
|
+
registration mechanism is a higher-order wrapper function, not a decorator (no Java-AOP or
|
|
80
|
+
Python-decorator equivalent exists in this ecosystem the templates could safely rely on; see
|
|
81
|
+
`D-typescript-express-registry-parity` in `DECISIONS.md`). Treat `handles emit`'s output as a
|
|
82
|
+
scaffold to finish by hand, not a production-ready subsystem, until a real deployment happens.
|
|
57
83
|
|
|
58
84
|
## Quickstart
|
|
59
85
|
|
|
@@ -86,6 +112,36 @@ bskel verify --feature 001-organization-management --build
|
|
|
86
112
|
# target repo's own build wrapper (gradlew/mvnw/npm), if present
|
|
87
113
|
```
|
|
88
114
|
|
|
115
|
+
`bskel status`/`bskel next` are what you actually run over and over — real output, captured against
|
|
116
|
+
a fixture repo partway through the flow above, not written by hand:
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
$ bskel status --feature 001-organization-management
|
|
120
|
+
# Status: 001-organization-management
|
|
121
|
+
|
|
122
|
+
## Gates
|
|
123
|
+
- [PASS] preflight
|
|
124
|
+
- [PASS] scan
|
|
125
|
+
- [(not_run)] cross_feature (required-when-present, feature-scoped)
|
|
126
|
+
- [BLOCKING] contract
|
|
127
|
+
- [(not_run)] dependencies (required-when-present, feature-scoped)
|
|
128
|
+
- [(not_run)] handles (required-when-present, feature-scoped)
|
|
129
|
+
- [(not_run)] stack (required-when-present, repo-scoped)
|
|
130
|
+
- [(not_run)] patch_transactions (required-when-present, feature-scoped)
|
|
131
|
+
- [(not_run)] conformance (required-when-present, feature-scoped)
|
|
132
|
+
|
|
133
|
+
## Artifacts
|
|
134
|
+
- [OK] contract: specs/001-organization-management/contracts/001-organization-management.schema.json
|
|
135
|
+
|
|
136
|
+
## Next
|
|
137
|
+
- bskel contract waive --feature 001-organization-management --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature 001-organization-management --reason "..." if intentional # contract gate is awaiting disposition
|
|
138
|
+
|
|
139
|
+
## Optional, not yet run: handles, stack
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Every gate line is a real, disk-verified check — the `Next` line is always the exact command to
|
|
143
|
+
unblock whatever's currently `BLOCKING`, so there's no separate doc to cross-reference mid-workflow.
|
|
144
|
+
|
|
89
145
|
### Starting from nothing (greenfield)
|
|
90
146
|
|
|
91
147
|
Every command above assumes an existing Spring Boot or FastAPI repo. If you don't have one yet:
|
|
@@ -246,6 +302,12 @@ between the two — and, like every other mutating command in this project, both
|
|
|
246
302
|
not query parameters. `GET`/`HEAD` responses carry `Access-Control-Allow-Origin: *`; the mutating
|
|
247
303
|
routes never do, so only same-origin requests (the bundled UI itself) can write.
|
|
248
304
|
|
|
305
|
+

|
|
306
|
+
|
|
307
|
+
*The page's own header describes it honestly: "Not a redesign of the original Fieldwire mockup --
|
|
308
|
+
this exists to prove the API actually works, nothing more." It's a minimal read-only check page, not
|
|
309
|
+
a polished dashboard — every table on it comes straight from `GET /api/graph`.*
|
|
310
|
+
|
|
249
311
|
### Patching a config file (optional)
|
|
250
312
|
|
|
251
313
|
`bskel stack apply`'s `config_check` sometimes reports `needs-manual-patch` — a target file exists
|
package/bin/bskel.mjs
CHANGED
|
@@ -9,10 +9,10 @@ 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, sha256File } from '../lib/fsutil.mjs';
|
|
12
|
+
import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
|
|
13
13
|
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
14
|
import { withLockSync } from '../lib/lock.mjs';
|
|
15
|
-
import { specDir, specPath } from '../lib/paths.mjs';
|
|
15
|
+
import { specDir, specPath, sbfPath } from '../lib/paths.mjs';
|
|
16
16
|
import { requireValidFeatureId, requireValidSlug, requireValidFeatureOrRepoId, slugWords, nextFeatureNumber } from '../lib/featureid.mjs';
|
|
17
17
|
import {
|
|
18
18
|
loadFeatureFile, saveFeatureFile, loadFeatureIndex, saveFeatureIndex,
|
|
@@ -56,6 +56,7 @@ import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../s
|
|
|
56
56
|
import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
|
|
57
57
|
import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
58
58
|
import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
|
|
59
|
+
import { hasSpringAopDependency, springAopArtifactName } from '../handles/providers/java-spring/emit.mjs';
|
|
59
60
|
import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
|
|
60
61
|
import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
|
|
61
62
|
import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
|
|
@@ -96,12 +97,12 @@ function usage() {
|
|
|
96
97
|
bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
|
|
97
98
|
bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
|
|
98
99
|
bskel dependency list --feature <id> [--json]
|
|
99
|
-
bskel stack apply --choice <id> [--apply] [--port N] [--json]
|
|
100
|
+
bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]
|
|
100
101
|
bskel catalog lint [<choice>] [--json]
|
|
101
102
|
bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
|
|
102
103
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
103
104
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
104
|
-
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
|
|
105
|
+
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
|
|
105
106
|
bskel patch propose --feature <id> [--kind config-apply|ddl-apply] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> [--json]
|
|
106
107
|
bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
|
|
107
108
|
bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
|
|
@@ -1867,14 +1868,29 @@ function cmdContractToolSchema(args) {
|
|
|
1867
1868
|
}
|
|
1868
1869
|
|
|
1869
1870
|
// Anthropic tool-use `input_schema` is a JSON Schema subset -- the operation's payload
|
|
1870
|
-
// schema
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
1873
|
-
//
|
|
1871
|
+
// schema is directly usable as-is UNLESS it's recursive. Confirmed against Anthropic's own
|
|
1872
|
+
// documented JSON Schema limitations (platform.claude.com/docs/en/build-with-claude/
|
|
1873
|
+
// structured-outputs): internal (non-external-URL) $ref/$defs ARE supported, but "recursive
|
|
1874
|
+
// schemas" are explicitly listed as unsupported and return a real 400 error at the API. A2:
|
|
1875
|
+
// when `op` carries a projected `requestBodySchema`, it flows through here for free -- this
|
|
1876
|
+
// function changed not at all. D-openapi-cyclic-refs: contracts/openapi.mjs's inlineSchema()
|
|
1877
|
+
// now emits `$ref`/`$defs` for a genuinely cyclic component (e.g. a real recursive
|
|
1878
|
+
// filter-group tree) rather than failing the whole projection closed -- that's real progress
|
|
1879
|
+
// for `contract validate`/`contract export`, but this ONE consumer genuinely cannot accept
|
|
1880
|
+
// it: refuse explicitly here, citing the real reason, rather than emitting a schema that
|
|
1881
|
+
// would only fail later at the actual Anthropic API call site.
|
|
1882
|
+
const inputSchema = operationPayloadSchema(op);
|
|
1883
|
+
if (inputSchema && Object.hasOwn(inputSchema, '$defs')) {
|
|
1884
|
+
fail(
|
|
1885
|
+
EXIT_CODES.NOT_PASSED,
|
|
1886
|
+
'RECURSIVE_SCHEMA_UNSUPPORTED',
|
|
1887
|
+
`operation "${flags.operation}"'s payload schema is recursive (a genuinely self-referential real shape, e.g. a nested filter-group tree) -- Anthropic tool-use input_schema does not support recursive schemas (see platform.claude.com/docs/en/build-with-claude/structured-outputs), so no tool-use schema can be generated for this operation`,
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1874
1890
|
const toolSchema = {
|
|
1875
1891
|
name: flags.operation,
|
|
1876
1892
|
description: `${op.verb} ${op.path} (feature ${flags.feature})`,
|
|
1877
|
-
input_schema:
|
|
1893
|
+
input_schema: inputSchema,
|
|
1878
1894
|
};
|
|
1879
1895
|
console.log(JSON.stringify(toolSchema, null, 2));
|
|
1880
1896
|
process.exit(0);
|
|
@@ -1904,7 +1920,13 @@ function cmdStackApply(args) {
|
|
|
1904
1920
|
const root = requireRepoRoot();
|
|
1905
1921
|
requirePreflightPassed(root);
|
|
1906
1922
|
if (!flags.choice) {
|
|
1907
|
-
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `usage: bskel stack apply --choice <id> [--apply] [--port N] (known choices: ${listCatalogChoices().join(', ') || '(none)'})`);
|
|
1923
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `usage: bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] (known choices: ${listCatalogChoices().join(', ') || '(none)'})`);
|
|
1924
|
+
}
|
|
1925
|
+
// D-write-safety-phase0 (item 2): mirrors handles emit's own --force/--reason validation --
|
|
1926
|
+
// every overwrite of a file that diverged from what `stack apply` itself last wrote must be
|
|
1927
|
+
// auditable.
|
|
1928
|
+
if (flags.force && (!flags.reason || !flags.reason.trim())) {
|
|
1929
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel stack apply --force requires --reason "..." -- every overwrite of a diverged generated file must be auditable');
|
|
1908
1930
|
}
|
|
1909
1931
|
|
|
1910
1932
|
let entry;
|
|
@@ -1928,12 +1950,21 @@ function cmdStackApply(args) {
|
|
|
1928
1950
|
process.exit(0);
|
|
1929
1951
|
}
|
|
1930
1952
|
|
|
1931
|
-
let written;
|
|
1953
|
+
let written, conflicts, fileHashes;
|
|
1932
1954
|
try {
|
|
1933
|
-
written = applyPlan(root, plan);
|
|
1955
|
+
({ written, conflicts, fileHashes } = applyPlan(root, plan, { force: flags.force }));
|
|
1934
1956
|
} catch (err) {
|
|
1935
1957
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
1936
1958
|
}
|
|
1959
|
+
// D-write-safety-phase0 (item 2): a file that diverged from what `stack apply` itself last
|
|
1960
|
+
// wrote is refused outright without --force -- mirrors handles emit's own conflict-refusal
|
|
1961
|
+
// exactly, including the exit code family (a new, dedicated STACK_CONFLICT rather than reusing
|
|
1962
|
+
// HANDLES_CONFLICT, since this is a different write surface).
|
|
1963
|
+
if (conflicts.length > 0) {
|
|
1964
|
+
console.error(`refusing to overwrite ${conflicts.length} file(s) that diverged from what \`bskel stack apply\` last generated${flags.force ? '' : ' -- pass --force --reason "..." to overwrite (only if the divergence is git-recoverable)'}:`);
|
|
1965
|
+
for (const c of conflicts) console.error(` ${c.path}\n ${c.reason}`);
|
|
1966
|
+
process.exit(EXIT_CODES.STACK_CONFLICT);
|
|
1967
|
+
}
|
|
1937
1968
|
// S2: `applied_files` must be this choice's FULL file set in this repo (its desired state),
|
|
1938
1969
|
// not just whatever `applyPlan()` happened to write THIS run -- applyPlan() skips files whose
|
|
1939
1970
|
// action is 'unchanged', so a second, idempotent `--apply` used to overwrite this with `[]`,
|
|
@@ -1944,17 +1975,22 @@ function cmdStackApply(args) {
|
|
|
1944
1975
|
...plan.files.map((f) => f.path),
|
|
1945
1976
|
...(plan.envExampleActions.length > 0 ? ['.env.example'] : []),
|
|
1946
1977
|
])].sort();
|
|
1978
|
+
// D-write-safety-phase0 (item 2): `fileHashes` only covers files applyPlan() actually wrote
|
|
1979
|
+
// THIS run -- an unchanged file isn't in it, so this merges onto the PRIOR record's file_hashes
|
|
1980
|
+
// (now a genuine read boundary -- planApply() reads this same record to classify files, see
|
|
1981
|
+
// stack/apply.mjs) rather than replacing it wholesale, or an unchanged file's provenance would
|
|
1982
|
+
// be lost on every apply after the first.
|
|
1983
|
+
const priorRecord = readJsonIfExists(sbfPath(root, 'stack.json'));
|
|
1947
1984
|
const stackRecord = {
|
|
1948
1985
|
schema: 'sbf.stack/1', choice: flags.choice, applied_files: appliedFiles,
|
|
1949
1986
|
env_example_keys: plan.envExampleActions.map((e) => e.key), at: new Date().toISOString(),
|
|
1987
|
+
file_hashes: { ...(priorRecord?.file_hashes ?? {}), ...fileHashes },
|
|
1950
1988
|
};
|
|
1951
1989
|
// S5 (D-persistence-integrity): schemas/stack-record.schema.json is new -- this record had NO
|
|
1952
1990
|
// schema at all before (not the same file as stack-choice.schema.json, which validates a
|
|
1953
1991
|
// stack/catalog/<id>.yml CATALOG ENTRY, a completely different persistence boundary). Validated
|
|
1954
1992
|
// before it touches disk, same "fail loud here" reasoning as every other write site this item
|
|
1955
|
-
// touched.
|
|
1956
|
-
// (confirmed by grep before adding this), so there's no read boundary to close yet; adding an
|
|
1957
|
-
// unused loadStackRecord() export would just be dead code.
|
|
1993
|
+
// touched.
|
|
1958
1994
|
{
|
|
1959
1995
|
const { ok, errors } = validateAgainstSchema('stack-record.schema.json', stackRecord);
|
|
1960
1996
|
if (!ok) {
|
|
@@ -2078,8 +2114,10 @@ function writeScanReportOrExit(reportPath, report) {
|
|
|
2078
2114
|
}
|
|
2079
2115
|
|
|
2080
2116
|
// D4 (D-handles-dryrun): the marker vocabulary a human report uses for classifyFile()'s 6
|
|
2081
|
-
// possible actions (+ the
|
|
2082
|
-
//
|
|
2117
|
+
// possible actions (+ the 'spec' kind every observe.mjs provider uses for its always-regenerated
|
|
2118
|
+
// observed-schema.json, which reuses the same 3 labels since it's classified the same 3-way
|
|
2119
|
+
// create/unchanged/update, just outside classifyFile() itself -- migration.sql used to be the
|
|
2120
|
+
// other 'spec' user too, until D-write-safety-phase0 moved it onto real manifest tracking).
|
|
2083
2121
|
const ACTION_MARKERS = { create: '+', unchanged: '=', update: '~', 'adopt-unchanged': '=', 'adopt-update': '~', conflict: '!' };
|
|
2084
2122
|
|
|
2085
2123
|
// D4: shared between `handles plan`'s preview and `handles emit --check`'s report -- both show
|
|
@@ -2327,6 +2365,21 @@ function cmdHandlesEmit(args) {
|
|
|
2327
2365
|
requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
|
|
2328
2366
|
const provider = selectProviderOrExit(scanReport);
|
|
2329
2367
|
requireProviderCapabilitiesOrExit(scanReport, provider, 'handles emit', { featureId: flags.feature, scanReportPath });
|
|
2368
|
+
|
|
2369
|
+
// D-write-safety-phase1 (item 1): HandleAspect.java cannot intercept anything without
|
|
2370
|
+
// spring-boot-starter-aop on the target's own classpath -- refusing here, before any code is
|
|
2371
|
+
// written, rather than letting the operator discover it only after `--enforce-registry on`
|
|
2372
|
+
// silently produces resolvers that will 404 every fetch/patch. java-spring only: python-fastapi's
|
|
2373
|
+
// @record_snapshot decorator needs no extra dependency (see python-fastapi/emit.mjs's own note).
|
|
2374
|
+
if (enforceRegistry && scanReport.adapter === 'java-spring' && !hasSpringAopDependency(root)) {
|
|
2375
|
+
// D-handles-pilot-cohort: the correct artifact name is version-dependent (spring-boot-starter-aop
|
|
2376
|
+
// before Spring Boot 4, spring-boot-starter-aspectj from Spring Boot 4 on -- confirmed against
|
|
2377
|
+
// a real Spring Boot 4.1.0 target, the old artifact is a genuine 404 on Maven Central for it).
|
|
2378
|
+
// springAopArtifactName(root) names the one THIS repo's own detected Boot version actually needs.
|
|
2379
|
+
const artifactName = springAopArtifactName(root);
|
|
2380
|
+
fail(EXIT_CODES.HANDLES_MISSING_DEPENDENCY, 'HANDLES_MISSING_DEPENDENCY', `bskel handles emit --enforce-registry on requires ${artifactName} on this repo's own build.gradle/build.gradle.kts/pom.xml classpath -- HandleAspect.java (the class that actually intercepts @RecordHandleSnapshot-annotated methods) does nothing without it, and no other Spring starter enables AOP. Add the dependency to your build file, then re-run.`);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2330
2383
|
const resourceFilter = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
|
|
2331
2384
|
|
|
2332
2385
|
let plan;
|
|
@@ -2339,7 +2392,7 @@ function cmdHandlesEmit(args) {
|
|
|
2339
2392
|
// a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
|
|
2340
2393
|
// does, without requiring both flags together.
|
|
2341
2394
|
const dryRun = flags.check || flags.diff;
|
|
2342
|
-
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
|
|
2395
|
+
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [] } = provider.emit({
|
|
2343
2396
|
repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
|
|
2344
2397
|
});
|
|
2345
2398
|
|
|
@@ -2400,6 +2453,25 @@ function cmdHandlesEmit(args) {
|
|
|
2400
2453
|
process.exit(EXIT_CODES.HANDLES_CONFLICT);
|
|
2401
2454
|
}
|
|
2402
2455
|
|
|
2456
|
+
// D-write-safety-phase1 (item 2): a registration gap is checked separately from `blocked`
|
|
2457
|
+
// above (conflicts are already handled and exited by this point) -- semantically different
|
|
2458
|
+
// reason (a hand-written file bskel never touches lacking an annotation it cannot add itself,
|
|
2459
|
+
// not a generated file diverging), so its own dedicated exit code and message. Unlike a
|
|
2460
|
+
// conflict, the resolver files ARE still written either way (there is nothing wrong with their
|
|
2461
|
+
// content) -- only the overall command's reported success, and the `handles` gate passing, are
|
|
2462
|
+
// gated on acknowledging the gap with --force --reason.
|
|
2463
|
+
if (enforceRegistry && registrationGaps.length > 0 && !flags.force) {
|
|
2464
|
+
if (flags.json) {
|
|
2465
|
+
console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, registrationGaps, blocked: true, gate: null, check: dryRun }, null, 2));
|
|
2466
|
+
} else {
|
|
2467
|
+
const verb = dryRun ? 'would refuse to report success' : 'refusing to report success';
|
|
2468
|
+
console.error(`${verb}: ${registrationGaps.length} resource(s) have --enforce-registry on but no static registration path found:`);
|
|
2469
|
+
for (const g of registrationGaps) console.error(` ${g.resourceType} (${g.file})\n ${g.note}`);
|
|
2470
|
+
if (!dryRun) console.error(`\nthe resolver file(s) above were still written -- nothing about their content is wrong. Fix the registration gap and re-run, or acknowledge and proceed with: bskel handles emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''}${flags.resource ? ` --resource ${flags.resource}` : ''} --enforce-registry on --force --reason "..."`);
|
|
2471
|
+
}
|
|
2472
|
+
process.exit(EXIT_CODES.HANDLES_REGISTRATION_GAP);
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2403
2475
|
// D4: dryRun never marks the gate passed -- nothing real happened this run.
|
|
2404
2476
|
const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
|
|
2405
2477
|
|
|
@@ -2718,6 +2790,35 @@ async function cmdHandlesAudit(args) {
|
|
|
2718
2790
|
// D-openapi-extraction-hint's own precedent for "the CLI itself carries this warning, not
|
|
2719
2791
|
// just documentation").
|
|
2720
2792
|
const caveat = 'this reports what the target application chose to record via @RecordHandleSnapshot / record_snapshot -- it is NOT, and cannot be, a security control on its own (see O3/O5 in CATALOG.md for revocation enforcement and authorization contracts). Absence of a snapshot does not mean a handle was never used, only that recording was never opted into for that call path.';
|
|
2793
|
+
|
|
2794
|
+
// D-write-safety-phase1 (item 3): the live-database closure of D-handle-registry-enforcement's
|
|
2795
|
+
// own named EXIT gap ("an already-empty registry... would need live target-app database
|
|
2796
|
+
// access, a larger scope than this item's own"). Opt-in: resolves the CURRENT plan the same
|
|
2797
|
+
// way `cmdHandlesPlan` does, then cross-references the rows already fetched above against each
|
|
2798
|
+
// resource type the plan will actually generate a resolver for -- a real answer to "will
|
|
2799
|
+
// --enforce-registry on 404 on its very first fetch for this resource", checked against the
|
|
2800
|
+
// live database rather than a static regex proxy.
|
|
2801
|
+
let registryCoverage = null;
|
|
2802
|
+
if (flags['check-registry-coverage']) {
|
|
2803
|
+
const scanReport = loadScanReportOrExit(root, flags.feature);
|
|
2804
|
+
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2805
|
+
requireCapabilitiesOrExit(scanReport, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
|
|
2806
|
+
const provider = selectProviderOrExit(scanReport);
|
|
2807
|
+
requireProviderCapabilitiesOrExit(scanReport, provider, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
|
|
2808
|
+
let plan;
|
|
2809
|
+
try {
|
|
2810
|
+
plan = provider.plan({ repoRoot: root, scanReport, module: flags.module, resourceFilter: resourceTypes });
|
|
2811
|
+
} catch (err) {
|
|
2812
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2813
|
+
}
|
|
2814
|
+
registryCoverage = plan.resources
|
|
2815
|
+
.filter((r) => r.willGenerateResolver)
|
|
2816
|
+
.map((r) => ({
|
|
2817
|
+
resourceType: r.type,
|
|
2818
|
+
covered: rows.some((row) => row.resource_type === r.type && row.kind === 'r' && row.revoked_at === null),
|
|
2819
|
+
}));
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2721
2822
|
const report = {
|
|
2722
2823
|
schema: 'sbf.handle-audit/1',
|
|
2723
2824
|
feature_id: flags.feature,
|
|
@@ -2725,6 +2826,7 @@ async function cmdHandlesAudit(args) {
|
|
|
2725
2826
|
generated_at: new Date().toISOString(),
|
|
2726
2827
|
summary,
|
|
2727
2828
|
handles: rows,
|
|
2829
|
+
registry_coverage: registryCoverage,
|
|
2728
2830
|
caveat,
|
|
2729
2831
|
};
|
|
2730
2832
|
|
|
@@ -2738,6 +2840,11 @@ async function cmdHandlesAudit(args) {
|
|
|
2738
2840
|
const pointerNote = h.pointer ? `#${h.pointer}` : '';
|
|
2739
2841
|
console.log(` ${h.kind} ${h.resource_type}/${h.resource_uid}${pointerNote} -- ${h.snapshot_count} snapshot(s), last ${h.last_recorded_at ?? 'never'}${revokedNote}`);
|
|
2740
2842
|
}
|
|
2843
|
+
if (registryCoverage) {
|
|
2844
|
+
console.log('\nregistry coverage (would --enforce-registry on 404 on the first fetch for this resource?):');
|
|
2845
|
+
for (const c of registryCoverage) console.log(` ${c.resourceType}: ${c.covered ? 'covered' : 'NOT COVERED -- no non-revoked kind=r row exists yet'}`);
|
|
2846
|
+
if (registryCoverage.length === 0) console.log(' (no resources in this plan will generate a resolver)');
|
|
2847
|
+
}
|
|
2741
2848
|
console.error(`\nnote: ${caveat}`);
|
|
2742
2849
|
}
|
|
2743
2850
|
process.exit(0);
|
package/contracts/export.mjs
CHANGED
|
@@ -82,8 +82,18 @@ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union
|
|
|
82
82
|
// meaning moves from "never built" to "content-AND-flag-conditional"), while `title`/`examples`
|
|
83
83
|
// (plural)/`externalDocs`/`xml`/`deprecated` move to a new, narrower structural entry
|
|
84
84
|
// (`field-metadata`) -- measured 0 real occurrences each against the Team-IZ-Backend oracle, so
|
|
85
|
-
// they
|
|
86
|
-
//
|
|
85
|
+
// they stayed unbuilt on the same "don't build for zero real cases" grounds as the two A8 entries
|
|
86
|
+
// below, not this item's scope.
|
|
87
|
+
// A14 (D-openapi-field-metadata-passthrough): D-oracle-corpus-openapi-remeasurement (ROADMAP
|
|
88
|
+
// Phase 5c) found `title`/plural `examples`/`deprecated` DO occur for real against a much larger
|
|
89
|
+
// second corpus (polarsource/polar, 1046 component schemas vs 308) -- those 3 of the original 5
|
|
90
|
+
// `field-metadata` keywords are now conditionally copied (contracts/openapi.mjs's
|
|
91
|
+
// DOCUMENTATION_KEYWORDS), same "gated on --descriptions" doctrine as A11's own description/
|
|
92
|
+
// example. `field-metadata` migrates from a STRUCTURAL (always-present) omission to an ANY-based
|
|
93
|
+
// one below -- the exact same migration A10 made for `operation-descriptions` when it left the
|
|
94
|
+
// original single `descriptions` structural entry. `externalDocs`/`xml` remain 0 real occurrences
|
|
95
|
+
// even at Polar's scale, so THOSE two keep the narrower structural entry
|
|
96
|
+
// `external-docs-and-xml-metadata`. What else stays structural: `vendor-extensions` (x-*
|
|
87
97
|
// keys on an operation are never copied -- excluded in principle, not by cap or failure, since
|
|
88
98
|
// their semantics are tool-specific), and two A8 additions: `non-json-response-schemas` (a non-JSON
|
|
89
99
|
// response media type's NAME is copied via a per-status entry's `mediaTypes`, but its SHAPE is never
|
|
@@ -92,7 +102,7 @@ const ERROR_RESPONSE_DESCRIPTION = 'Error. The source contract records the union
|
|
|
92
102
|
// -- 0/694 real occurrences, a genuinely visible gap only now that per-status responses look
|
|
93
103
|
// complete).
|
|
94
104
|
const STRUCTURAL_OMISSIONS = Object.freeze([
|
|
95
|
-
'
|
|
105
|
+
'external-docs-and-xml-metadata',
|
|
96
106
|
'non-json-response-schemas',
|
|
97
107
|
'response-headers',
|
|
98
108
|
'vendor-extensions',
|
|
@@ -102,7 +112,8 @@ const OMISSION_PROSE = Object.freeze({
|
|
|
102
112
|
'cookie-parameters': 'cookie parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
|
|
103
113
|
'error-schemas': 'a JSON error-body schema for at least one operation',
|
|
104
114
|
'field-descriptions': 'a schema field\'s own `description`/`example` (a property\'s own annotation, distinct from the operation-level `description` field -- see `operation-descriptions` below), for at least one field in the request-body/response/error schema of at least one operation -- copied only when `contract emit --descriptions` was used (the same flag as operation-level description) AND the source declared one for that exact field AND it did not exceed the length/size cap; otherwise the field carries no `description`/`example` key, never synthesized. Not tracked separately for per-status responses, non-JSON request media types, or path-parameter schemas -- those may carry field docs when the flag is on, but their presence is not reflected in this specific omission entry',
|
|
105
|
-
'field-metadata': 'a schema field\'s `title`, plural `examples`,
|
|
115
|
+
'field-metadata': 'a schema field\'s `title`, plural `examples`, or `deprecated` keyword, for at least one field in the request-body/response/error schema of at least one operation -- copied only when `--descriptions` is passed (contracts/openapi.mjs\'s DOCUMENTATION_KEYWORDS, A14/D-openapi-field-metadata-passthrough), same doctrine as `field-descriptions`. Present whenever the flag was not used at all, none of this operation\'s projected schemas carry any of the three, or the value exceeded its cap (MAX_TITLE_LENGTH/MAX_EXAMPLES_ARRAY_LENGTH/MAX_EXAMPLE_LENGTH)',
|
|
116
|
+
'external-docs-and-xml-metadata': 'a schema field\'s `externalDocs` or `xml` keyword -- dropped unconditionally while inlining a schema (contracts/openapi.mjs\'s DROPPED_KEYWORDS), regardless of `--descriptions`. Permanently unbuilt: 0 real occurrences of either keyword measured against either real corpus (the original Team-IZ-Backend oracle or the larger polarsource/polar re-measurement) -- see D-oracle-corpus-openapi-remeasurement in DECISIONS.md',
|
|
106
117
|
'header-parameters': 'header parameters, for at least one operation that does not carry a fully-copied set (never emitted at all when --openapi-file was not given, or the source document declared none)',
|
|
107
118
|
'non-json-request-media-types': 'the media type of the request body, for at least one operation that takes one -- a non-application/json request media type is emitted only when a real source document declared one for that exact operation, copied byte-for-byte; otherwise this document shows a JSON media-type entry because that is all the contract knows, never because the real body is known to be JSON',
|
|
108
119
|
'non-json-response-schemas': 'a JSON Schema for any response body in a media type other than application/json -- the media type is named where a source document declared one for that status, but its shape is never projected',
|
|
@@ -145,6 +156,26 @@ function schemaHasFieldDocs(node, seen = new Set()) {
|
|
|
145
156
|
return false;
|
|
146
157
|
}
|
|
147
158
|
|
|
159
|
+
// A14 (D-openapi-field-metadata-passthrough): the exact same recursive shape as schemaHasFieldDocs
|
|
160
|
+
// above, checking the OTHER three DOCUMENTATION_KEYWORDS (title/examples/deprecated) instead of
|
|
161
|
+
// description/example -- kept as a separate function rather than merged into schemaHasFieldDocs
|
|
162
|
+
// so the two disclosure keys (`field-descriptions` vs `field-metadata`) stay independently
|
|
163
|
+
// derived from what's ACTUALLY in the projected schema, not conflated into one flag a caller
|
|
164
|
+
// can't tell apart.
|
|
165
|
+
function schemaHasFieldMetadata(node, seen = new Set()) {
|
|
166
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return false;
|
|
167
|
+
seen.add(node);
|
|
168
|
+
if (typeof node.title === 'string' || Object.hasOwn(node, 'examples') || Object.hasOwn(node, 'deprecated')) return true;
|
|
169
|
+
if (node.properties && typeof node.properties === 'object' && !Array.isArray(node.properties)) {
|
|
170
|
+
for (const propSchema of Object.values(node.properties)) {
|
|
171
|
+
if (schemaHasFieldMetadata(propSchema, seen)) return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (node.items && typeof node.items === 'object' && schemaHasFieldMetadata(node.items, seen)) return true;
|
|
175
|
+
if (node.additionalProperties && typeof node.additionalProperties === 'object' && schemaHasFieldMetadata(node.additionalProperties, seen)) return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
148
179
|
// Derived from the contract's ACTUAL content, not hardcoded -- an operation that takes a body but
|
|
149
180
|
// has no projected schema, or has no response/error schema, each add their own entry, so the list
|
|
150
181
|
// says what is missing from THIS document rather than reciting a fixed disclaimer.
|
|
@@ -196,6 +227,10 @@ export function collectOmissions(contract) {
|
|
|
196
227
|
// not gated on whether a schema exists first.
|
|
197
228
|
const fieldSchemas = [op.requestBodySchema, op.responseSchema, op.errorSchema].filter(Boolean);
|
|
198
229
|
if (!fieldSchemas.some((s) => schemaHasFieldDocs(s))) omissions.add('field-descriptions');
|
|
230
|
+
// A14: same ANY-based doctrine as field-descriptions immediately above -- added whenever
|
|
231
|
+
// NONE of this operation's projected schemas carry a field-level title/examples/deprecated,
|
|
232
|
+
// including the case where the operation has no projected schema at all.
|
|
233
|
+
if (!fieldSchemas.some((s) => schemaHasFieldMetadata(s))) omissions.add('field-metadata');
|
|
199
234
|
}
|
|
200
235
|
return [...omissions].sort();
|
|
201
236
|
}
|