backend-skeleton 1.0.0-beta.3 → 1.0.0-beta.4
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 +5 -0
- package/bin/bskel.mjs +247 -9
- package/contracts/completeness.mjs +21 -2
- package/contracts/emit.mjs +11 -0
- package/contracts/openapi.mjs +95 -0
- package/handles/audit.mjs +83 -0
- package/handles/codec.mjs +11 -5
- package/handles/providers/java-spring/emit.mjs +9 -2
- package/handles/providers/java-spring/plan.mjs +20 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +13 -5
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +54 -20
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +17 -1
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +5 -0
- package/handles/providers/java-spring.mjs +2 -2
- package/handles/providers/python-fastapi/emit.mjs +5 -1
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +9 -1
- package/handles/providers/python-fastapi/templates/router.py.tmpl +43 -19
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +10 -1
- package/lib/cli.mjs +45 -2
- package/lib/handles-manifest.mjs +10 -4
- package/lib/repo.mjs +56 -0
- package/package.json +1 -1
- package/scanners/adapters/_express-shared.mjs +17 -12
- package/scanners/adapters/generic-grep.mjs +5 -1
- package/scanners/adapters/java-spring.mjs +60 -14
- package/scanners/adapters/javascript-express.mjs +5 -1
- package/scanners/adapters/python-fastapi.mjs +17 -14
- package/scanners/adapters/typescript-express.mjs +6 -1
- package/scanners/registry.mjs +5 -2
- package/scanners/text-util.mjs +25 -0
- package/schemas/adapter.schema.json +6 -2
- package/schemas/contract-resolution.schema.json +6 -1
- package/schemas/handles-plan.schema.json +1 -0
- package/stack/bootstrap/db-up.sh +52 -0
- package/stack/bootstrap/docker-compose.postgres.yml +18 -0
- package/stack/catalog/postgres-dev-db.yml +55 -0
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# backend-skeleton
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/backend-skeleton)
|
|
4
|
+
[](https://github.com/popixoxipop-collab/backend-skeleton/blob/main/LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/backend-skeleton)
|
|
6
|
+
[](https://github.com/popixoxipop-collab/backend-skeleton/releases)
|
|
7
|
+
|
|
3
8
|
Spec-driven backend scaffolding for brownfield (and greenfield) Java/Spring Boot, Python/FastAPI,
|
|
4
9
|
and TypeScript/JavaScript Express repos: a brownfield-collision gate before any spec/plan step,
|
|
5
10
|
feature_id-scoped machine-readable contracts, UUID-addressable field handles, and stack-choice
|
package/bin/bskel.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import os from 'node:os';
|
|
8
|
-
import { repoRoot, localDefaultBranch } from '../lib/repo.mjs';
|
|
8
|
+
import { repoRoot, localDefaultBranch, fileHistory, showFileAtRevision, headSha, currentBranch, isDirty } from '../lib/repo.mjs';
|
|
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';
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { runScan } from '../scanners/index.mjs';
|
|
23
23
|
import { scanMigrations } from '../scanners/db/migrations.mjs';
|
|
24
24
|
import { introspectSchema, describeConnectionError } from '../scanners/db/introspect.mjs';
|
|
25
|
+
import { auditHandles, summarizeAudit, isMissingHandleTables } from '../handles/audit.mjs';
|
|
25
26
|
import { renderScanMarkdown, renderPlanConstraints, renderScanExplain } from '../scanners/render.mjs';
|
|
26
27
|
import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
|
|
27
28
|
import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
|
|
@@ -29,6 +30,7 @@ import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contrac
|
|
|
29
30
|
import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
|
|
30
31
|
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
|
|
31
32
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
33
|
+
import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
|
|
32
34
|
import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
|
|
33
35
|
import {
|
|
34
36
|
requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
|
|
@@ -67,14 +69,16 @@ function usage() {
|
|
|
67
69
|
bskel feature archive <id> --reason "..." [--json]
|
|
68
70
|
bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]
|
|
69
71
|
bskel contract export --feature <id> [--out <path>] [--json] [--allow-unprefixed] [--status-codes range|literal]
|
|
72
|
+
bskel contract history --feature <id> [--json]
|
|
70
73
|
bskel contract validate --feature <id> --file <envelope.json>
|
|
71
74
|
bskel contract tool-schema --feature <id> --operation <operationId>
|
|
72
|
-
bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..."
|
|
75
|
+
bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
|
|
73
76
|
bskel stack apply --choice <id> [--apply] [--port N] [--json]
|
|
74
77
|
bskel catalog lint [<choice>] [--json]
|
|
75
78
|
bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
|
|
76
|
-
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff]
|
|
79
|
+
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
77
80
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
81
|
+
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
|
|
78
82
|
bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
|
|
79
83
|
bskel status [--feature <id>] [--json]
|
|
80
84
|
bskel next [--feature <id>] [--json]
|
|
@@ -83,6 +87,7 @@ function usage() {
|
|
|
83
87
|
bskel gate revoke <name> --reason "..." [--feature <id>]
|
|
84
88
|
bskel gate history <name> [--feature <id>] [--json]
|
|
85
89
|
bskel gate show [<name>] [--feature <id>]
|
|
90
|
+
bskel gate export --feature <id> [--out <path>] [--json]
|
|
86
91
|
bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
|
|
87
92
|
`);
|
|
88
93
|
}
|
|
@@ -360,6 +365,48 @@ function cmdGateShow(args) {
|
|
|
360
365
|
process.exit(0);
|
|
361
366
|
}
|
|
362
367
|
|
|
368
|
+
// D-gate-export (S7's own sibling item): a standalone, human- and machine-readable report of
|
|
369
|
+
// exactly what THIS repo's own `.sbf/*.history.jsonl` shows -- current state + full history for
|
|
370
|
+
// every gate, plus enough git provenance (branch/HEAD/dirty) to say when it was captured. Built to
|
|
371
|
+
// answer "what did this PR actually get verified against" *independent of whether CI ran at all* --
|
|
372
|
+
// the concrete, real mitigation for the exact GitHub-Actions-billing outage this repo itself has
|
|
373
|
+
// been running under (see feedback_backend_skeleton_ci_gate_suspended_billing in project memory).
|
|
374
|
+
// Pure reader -- never mutates a gate, never requires one to currently pass.
|
|
375
|
+
function cmdGateExport(args) {
|
|
376
|
+
const flags = parseCommand('gate export', args);
|
|
377
|
+
if (flags.help) { console.log(renderCommandHelp('gate export')); process.exit(0); }
|
|
378
|
+
setContext('gate export', flags);
|
|
379
|
+
const root = requireRepoRoot();
|
|
380
|
+
requireValidFeatureId(flags.feature);
|
|
381
|
+
|
|
382
|
+
const gates = {};
|
|
383
|
+
for (const name of GATE_NAMES) {
|
|
384
|
+
const scopeId = gateScopeId(name, flags.feature);
|
|
385
|
+
gates[name] = { scope: scopeId, current: getGate(root, scopeId, name), history: readGateHistory(root, scopeId, name) };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const report = {
|
|
389
|
+
schema: 'sbf.gate-export/1',
|
|
390
|
+
feature_id: flags.feature,
|
|
391
|
+
generated_at: new Date().toISOString(),
|
|
392
|
+
git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
|
|
393
|
+
gates,
|
|
394
|
+
};
|
|
395
|
+
const rendered = `${JSON.stringify(report, null, 2)}\n`;
|
|
396
|
+
|
|
397
|
+
if (flags.out) {
|
|
398
|
+
const outPath = path.resolve(process.cwd(), flags.out);
|
|
399
|
+
writeFileAtomic(outPath, rendered);
|
|
400
|
+
if (!flags.quiet) {
|
|
401
|
+
const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
|
|
402
|
+
console.log(`wrote ${flags.out} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${report.git.dirty ? ' (dirty)' : ''}`);
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
console.log(rendered);
|
|
406
|
+
}
|
|
407
|
+
process.exit(0);
|
|
408
|
+
}
|
|
409
|
+
|
|
363
410
|
// Structural enforcement of "preflight blocks everything below it" (see the workflow table in
|
|
364
411
|
// SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
|
|
365
412
|
// Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
|
|
@@ -942,6 +989,7 @@ function cmdContractEmit(args) {
|
|
|
942
989
|
warning_codes: countByCode(contract.warnings),
|
|
943
990
|
waived_count: evaluation.waived.length,
|
|
944
991
|
stale_waivers: evaluation.staleWaivers.length,
|
|
992
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
945
993
|
openapi: reconciliation
|
|
946
994
|
? {
|
|
947
995
|
applied: true,
|
|
@@ -993,6 +1041,10 @@ function cmdContractEmit(args) {
|
|
|
993
1041
|
}
|
|
994
1042
|
for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
|
|
995
1043
|
if (!flags.quiet) console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
1044
|
+
if (evaluation.expiredWaivers.length > 0) {
|
|
1045
|
+
console.error(`\nnote: ${evaluation.expiredWaivers.length} recorded waiver(s) have expired and no longer cover their warning (re-waive with --expires if still needed):`);
|
|
1046
|
+
for (const w of evaluation.expiredWaivers) console.error(` ${w.code} (${w.subject ?? '*'}) expired ${w.expires_at}`);
|
|
1047
|
+
}
|
|
996
1048
|
if (evaluation.staleWaivers.length > 0) {
|
|
997
1049
|
console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
|
|
998
1050
|
for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
|
|
@@ -1197,12 +1249,26 @@ function cmdContractExport(args) {
|
|
|
1197
1249
|
// waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
|
|
1198
1250
|
// individual entries -- a warning that doesn't exist yet (e.g. a new unannotated endpoint added
|
|
1199
1251
|
// later) is never covered by an old waive. See D-contract-completeness in DECISIONS.md.
|
|
1252
|
+
// D-waiver-expiry: only `<N>d` (whole days) -- the realistic common case for "look at this
|
|
1253
|
+
// again later," not a general ISO-8601 duration parser nobody asked for. `N` must be a positive
|
|
1254
|
+
// integer; `0d`/negative would either be a no-op waiver (already expired the moment it's written)
|
|
1255
|
+
// or nonsensical, and silently accepting either would be more confusing than refusing.
|
|
1256
|
+
function parseExpiresFlag(raw) {
|
|
1257
|
+
if (raw == null) return null;
|
|
1258
|
+
const match = /^([1-9][0-9]*)d$/.exec(raw);
|
|
1259
|
+
if (!match) {
|
|
1260
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--expires must look like "<N>d" (whole days, N >= 1), got "${raw}"`);
|
|
1261
|
+
}
|
|
1262
|
+
const days = Number(match[1]);
|
|
1263
|
+
return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1200
1266
|
function cmdContractWaive(args) {
|
|
1201
1267
|
const flags = parseCommand('contract waive', args);
|
|
1202
1268
|
if (flags.help) { console.log(renderCommandHelp('contract waive')); process.exit(0); }
|
|
1203
1269
|
setContext('contract waive', flags);
|
|
1204
1270
|
const root = requireRepoRoot();
|
|
1205
|
-
const usageText = 'usage: bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..."';
|
|
1271
|
+
const usageText = 'usage: bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path" | --all) --reason "..." [--expires <Nd>]';
|
|
1206
1272
|
try {
|
|
1207
1273
|
requireWarningCode(flags.code);
|
|
1208
1274
|
} catch (err) {
|
|
@@ -1214,6 +1280,7 @@ function cmdContractWaive(args) {
|
|
|
1214
1280
|
if (!flags.subject && !flags.all) {
|
|
1215
1281
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', usageText);
|
|
1216
1282
|
}
|
|
1283
|
+
const expiresAt = parseExpiresFlag(flags.expires);
|
|
1217
1284
|
|
|
1218
1285
|
const contract = loadContract(root, flags.feature);
|
|
1219
1286
|
if (contract.completeness.status === 'blocked') {
|
|
@@ -1247,7 +1314,7 @@ function cmdContractWaive(args) {
|
|
|
1247
1314
|
const at = new Date().toISOString();
|
|
1248
1315
|
const entries = toWaive
|
|
1249
1316
|
.filter((w) => !existingKeys.has(warningKey(w)))
|
|
1250
|
-
.map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at }));
|
|
1317
|
+
.map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at, ...(expiresAt ? { expires_at: expiresAt } : {}) }));
|
|
1251
1318
|
const next = {
|
|
1252
1319
|
schema: 'sbf.contract-resolution/1',
|
|
1253
1320
|
feature_id: flags.feature,
|
|
@@ -1265,6 +1332,7 @@ function cmdContractWaive(args) {
|
|
|
1265
1332
|
warning_codes: countByCode(contract.warnings),
|
|
1266
1333
|
waived_count: evaluation.waived.length,
|
|
1267
1334
|
stale_waivers: evaluation.staleWaivers.length,
|
|
1335
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
1268
1336
|
};
|
|
1269
1337
|
const gateState = evaluation.blocking
|
|
1270
1338
|
? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
|
|
@@ -1274,9 +1342,13 @@ function cmdContractWaive(args) {
|
|
|
1274
1342
|
console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
|
|
1275
1343
|
} else {
|
|
1276
1344
|
if (!flags.quiet) {
|
|
1277
|
-
console.log(`waived ${newEntries.length} new warning(s)${newEntries.length < toWaive.length ? ` (${toWaive.length - newEntries.length} already waived)` : ''}`);
|
|
1345
|
+
console.log(`waived ${newEntries.length} new warning(s)${newEntries.length < toWaive.length ? ` (${toWaive.length - newEntries.length} already waived)` : ''}${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
1278
1346
|
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
1279
1347
|
}
|
|
1348
|
+
if (evaluation.expiredWaivers.length > 0) {
|
|
1349
|
+
console.error(`\nnote: ${evaluation.expiredWaivers.length} recorded waiver(s) have expired and no longer cover their warning (re-waive with --expires if still needed):`);
|
|
1350
|
+
for (const w of evaluation.expiredWaivers) console.error(` ${w.code} (${w.subject ?? '*'}) expired ${w.expires_at}`);
|
|
1351
|
+
}
|
|
1280
1352
|
if (evaluation.blocking) {
|
|
1281
1353
|
console.error(`\nstill blocked: ${evaluation.unwaived.length} unresolved warning(s) remain:`);
|
|
1282
1354
|
for (const w of evaluation.unwaived) console.error(` ${w.code} (${w.subject})`);
|
|
@@ -1285,6 +1357,74 @@ function cmdContractWaive(args) {
|
|
|
1285
1357
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1286
1358
|
}
|
|
1287
1359
|
|
|
1360
|
+
// D-contract-history: a derived VIEW over the contract file's own git history in whatever repo
|
|
1361
|
+
// bskel is invoked in -- reads, never writes. Deliberately does NOT try to correlate a commit to
|
|
1362
|
+
// a specific `.sbf/<feature>.history.jsonl` gate-pass event: that file is per-machine, gitignored,
|
|
1363
|
+
// ephemeral state (see .gitignore's own comment on `.sbf/`), while a commit is shared -- the two
|
|
1364
|
+
// have no reliable 1:1 relationship, so this only reports what git itself can prove. `bskel gate
|
|
1365
|
+
// export` (a separate, later item) is the tool for "what did THIS machine's gate history record."
|
|
1366
|
+
function cmdContractHistory(args) {
|
|
1367
|
+
const flags = parseCommand('contract history', args);
|
|
1368
|
+
if (flags.help) { console.log(renderCommandHelp('contract history')); process.exit(0); }
|
|
1369
|
+
setContext('contract history', flags);
|
|
1370
|
+
const root = requireRepoRoot();
|
|
1371
|
+
requireValidFeatureId(flags.feature);
|
|
1372
|
+
|
|
1373
|
+
const contractPath = specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`);
|
|
1374
|
+
const relPath = path.relative(root, contractPath);
|
|
1375
|
+
const commits = fileHistory(root, relPath);
|
|
1376
|
+
|
|
1377
|
+
if (commits.length === 0) {
|
|
1378
|
+
if (flags.json) {
|
|
1379
|
+
console.log(JSON.stringify({ feature_id: flags.feature, path: relPath, revisions: [] }, null, 2));
|
|
1380
|
+
} else {
|
|
1381
|
+
console.log(`no git history for ${relPath} -- either this feature's contract was never committed, or specs/ isn't tracked in this repo. bskel does not require specs/ to be committed; if you want a history view, commit the contract as part of your normal workflow.`);
|
|
1382
|
+
}
|
|
1383
|
+
process.exit(0);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
let prevOperationNames = new Set();
|
|
1387
|
+
const revisions = commits.map(({ sha, date, subject }) => {
|
|
1388
|
+
const raw = showFileAtRevision(root, sha, relPath);
|
|
1389
|
+
let parsed = null;
|
|
1390
|
+
if (raw !== null) {
|
|
1391
|
+
try { parsed = JSON.parse(raw); } catch { parsed = null; }
|
|
1392
|
+
}
|
|
1393
|
+
if (parsed === null) {
|
|
1394
|
+
return { sha: sha.slice(0, 12), date, subject, parse_error: true };
|
|
1395
|
+
}
|
|
1396
|
+
const operationNames = new Set(Object.keys(parsed.operations ?? {}));
|
|
1397
|
+
const added = [...operationNames].filter((n) => !prevOperationNames.has(n)).sort();
|
|
1398
|
+
const removed = [...prevOperationNames].filter((n) => !operationNames.has(n)).sort();
|
|
1399
|
+
prevOperationNames = operationNames;
|
|
1400
|
+
return {
|
|
1401
|
+
sha: sha.slice(0, 12), date, subject,
|
|
1402
|
+
sbf_contract: parsed.sbf_contract ?? null,
|
|
1403
|
+
completeness_status: parsed.completeness?.status ?? null,
|
|
1404
|
+
operation_count: parsed.completeness?.operation_count ?? operationNames.size,
|
|
1405
|
+
operations_added: added,
|
|
1406
|
+
operations_removed: removed,
|
|
1407
|
+
};
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
if (flags.json) {
|
|
1411
|
+
console.log(JSON.stringify({ feature_id: flags.feature, path: relPath, revisions }, null, 2));
|
|
1412
|
+
} else {
|
|
1413
|
+
console.log(`${relPath} -- ${revisions.length} revision(s), oldest first:\n`);
|
|
1414
|
+
for (const r of revisions) {
|
|
1415
|
+
if (r.parse_error) {
|
|
1416
|
+
console.log(`${r.date} ${r.sha} (unparseable at this revision -- pre-JSON format or corrupted)`);
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
const delta = [];
|
|
1420
|
+
if (r.operations_added.length > 0) delta.push(`+${r.operations_added.join(',+')}`);
|
|
1421
|
+
if (r.operations_removed.length > 0) delta.push(`-${r.operations_removed.join(',-')}`);
|
|
1422
|
+
console.log(`${r.date} ${r.sha} sbf_contract=${r.sbf_contract} completeness=${r.completeness_status} operations=${r.operation_count}${delta.length > 0 ? ` (${delta.join(' ')})` : ''}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
process.exit(0);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1288
1428
|
function cmdContractValidate(args) {
|
|
1289
1429
|
const flags = parseCommand('contract validate', args);
|
|
1290
1430
|
if (flags.help) { console.log(renderCommandHelp('contract validate')); process.exit(0); }
|
|
@@ -1572,7 +1712,8 @@ function renderHandlesPlan(plan, actions) {
|
|
|
1572
1712
|
lines.push(`## ${r.type}${r.willGenerateResolver ? '' : ' (resolver will NOT be generated -- see notes)'}`);
|
|
1573
1713
|
lines.push(`- table: ${r.table ?? '(unknown)'}, PK field: ${r.idField ?? '(unknown)'}`);
|
|
1574
1714
|
lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
|
|
1575
|
-
lines.push(`- requiredAuthority: ${r.requiredAuthority}`);
|
|
1715
|
+
lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
|
|
1716
|
+
if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
|
|
1576
1717
|
lines.push('');
|
|
1577
1718
|
}
|
|
1578
1719
|
if (plan.notes.length > 0) {
|
|
@@ -1733,6 +1874,20 @@ function cmdHandlesEmit(args) {
|
|
|
1733
1874
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
|
|
1734
1875
|
}
|
|
1735
1876
|
|
|
1877
|
+
// O3 (D-handle-registry-enforcement): repo-wide, singleton state -- read BEFORE provider.emit()
|
|
1878
|
+
// (which loads its own, separate in-memory copy for `files` tracking) so an omitted flag
|
|
1879
|
+
// reuses whatever this repo's own manifest last recorded, rather than silently defaulting to
|
|
1880
|
+
// off. --enforce-registry off requires --reason ONLY when it's a REAL downgrade (currently on)
|
|
1881
|
+
// -- reaffirming an already-off value, or turning it on, never needs one.
|
|
1882
|
+
const priorManifest = loadManifest(root);
|
|
1883
|
+
if (flags['enforce-registry'] !== null && flags['enforce-registry'] !== 'on' && flags['enforce-registry'] !== 'off') {
|
|
1884
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--enforce-registry must be "on" or "off" (got "${flags['enforce-registry']}")`);
|
|
1885
|
+
}
|
|
1886
|
+
const enforceRegistry = flags['enforce-registry'] === null ? priorManifest.enforceRegistry : flags['enforce-registry'] === 'on';
|
|
1887
|
+
if (flags['enforce-registry'] === 'off' && priorManifest.enforceRegistry === true && (!flags.reason || !flags.reason.trim())) {
|
|
1888
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --enforce-registry off requires --reason "..." when registry enforcement was previously on -- every downgrade of a security posture must be auditable');
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1736
1891
|
// Handles are only emitted for a feature whose contract has actually been established --
|
|
1737
1892
|
// codegen against a feature nobody has scanned/contracted yet has nothing real to route to.
|
|
1738
1893
|
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
@@ -1766,8 +1921,21 @@ function cmdHandlesEmit(args) {
|
|
|
1766
1921
|
// does, without requiring both flags together.
|
|
1767
1922
|
const dryRun = flags.check || flags.diff;
|
|
1768
1923
|
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
|
|
1769
|
-
repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff,
|
|
1924
|
+
repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
|
|
1770
1925
|
});
|
|
1926
|
+
|
|
1927
|
+
// O3 (D-handle-registry-enforcement): persisted whenever the effective value actually changed
|
|
1928
|
+
// on a real (non-dryRun) run -- deliberately NOT gated on `blocked` below: O2's own "infra is
|
|
1929
|
+
// one all-or-nothing unit" rule means global/handle/* (including HandleController.java.tmpl/
|
|
1930
|
+
// router.py.tmpl) either all wrote together or none did, independent of a SEPARATE resolver
|
|
1931
|
+
// file conflicting -- the manifest should track what ACTUALLY landed on disk, not the overall
|
|
1932
|
+
// command's exit code. Re-reads the manifest fresh rather than reusing `priorManifest`, since
|
|
1933
|
+
// provider.emit() above may have just updated its own `files` tracking via a separate
|
|
1934
|
+
// loadManifest()/saveManifest() pair inside handles/_engine.mjs.
|
|
1935
|
+
if (!dryRun && enforceRegistry !== priorManifest.enforceRegistry) {
|
|
1936
|
+
const freshManifest = loadManifest(root);
|
|
1937
|
+
saveManifest(root, { ...freshManifest, enforceRegistry });
|
|
1938
|
+
}
|
|
1771
1939
|
// D4: found live while grounding this against a real fixture -- `written` (pre-existing field,
|
|
1772
1940
|
// unchanged semantics) unconditionally includes a java-spring `outputs.spec` file like
|
|
1773
1941
|
// migration.sql even when its content is byte-identical (P4 already found this: it's never
|
|
@@ -1904,6 +2072,69 @@ function cmdHandlesPatchApprove(args) {
|
|
|
1904
2072
|
process.exit(0);
|
|
1905
2073
|
}
|
|
1906
2074
|
|
|
2075
|
+
// O7 (D-handle-audit-report): a pure reader, deliberately gate-independent -- matches
|
|
2076
|
+
// D-contract-history/D-gate-export's own posture, not `handles plan`/`handles emit`'s capability
|
|
2077
|
+
// gating. It never touches adapter-specific codegen (the query is over `feature_uid` alone, the
|
|
2078
|
+
// same regardless of which provider backed this feature), so it works even before a scan report
|
|
2079
|
+
// exists, as long as `specs/<id>/feature.json` does.
|
|
2080
|
+
async function cmdHandlesAudit(args) {
|
|
2081
|
+
const flags = parseCommand('handles audit', args);
|
|
2082
|
+
if (flags.help) { console.log(renderCommandHelp('handles audit')); process.exit(0); }
|
|
2083
|
+
setContext('handles audit', flags);
|
|
2084
|
+
const root = requireRepoRoot();
|
|
2085
|
+
requireValidFeatureId(flags.feature);
|
|
2086
|
+
const featureRecord = loadFeatureRecord(root, flags.feature);
|
|
2087
|
+
|
|
2088
|
+
// Same "never read from .env directly, name an already-exported env var" convention as A4's
|
|
2089
|
+
// --database-url-env (D-db-schema-plane) -- reused unchanged, not reinvented.
|
|
2090
|
+
const connectionString = process.env[flags['database-url-env']];
|
|
2091
|
+
if (!connectionString) {
|
|
2092
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--database-url-env ${flags['database-url-env']} names an environment variable that isn't set -- export it first (never read from .env directly; see D-db-schema-plane in DECISIONS.md)`);
|
|
2093
|
+
}
|
|
2094
|
+
const resourceTypes = flags.resource ? flags.resource.split(',').map((s) => s.trim()).filter(Boolean) : null;
|
|
2095
|
+
|
|
2096
|
+
let rows;
|
|
2097
|
+
try {
|
|
2098
|
+
rows = await auditHandles({ connectionString, featureUid: featureRecord.feature_uid, resourceTypes });
|
|
2099
|
+
} catch (err) {
|
|
2100
|
+
if (isMissingHandleTables(err)) {
|
|
2101
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `sbf_handle/sbf_handle_snapshot don't exist in this database -- the generated migration.sql (see \`bskel handles emit\`'s own output) was never applied here. bskel never applies a migration automatically (see D-migration-scope in DECISIONS.md).`);
|
|
2102
|
+
}
|
|
2103
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query handle audit data: ${describeConnectionError(err)}`);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const summary = summarizeAudit(rows);
|
|
2107
|
+
// Printed in EVERY mode, not just as a doc comment -- this command's whole value is genuinely
|
|
2108
|
+
// capped until O3 (revocation enforcement)/O5 (authorization contracts) close, and that must
|
|
2109
|
+
// not be discoverable only by someone who already read DECISIONS.md prose (see
|
|
2110
|
+
// D-openapi-extraction-hint's own precedent for "the CLI itself carries this warning, not
|
|
2111
|
+
// just documentation").
|
|
2112
|
+
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.';
|
|
2113
|
+
const report = {
|
|
2114
|
+
schema: 'sbf.handle-audit/1',
|
|
2115
|
+
feature_id: flags.feature,
|
|
2116
|
+
feature_uid: featureRecord.feature_uid,
|
|
2117
|
+
generated_at: new Date().toISOString(),
|
|
2118
|
+
summary,
|
|
2119
|
+
handles: rows,
|
|
2120
|
+
caveat,
|
|
2121
|
+
};
|
|
2122
|
+
|
|
2123
|
+
if (flags.json) {
|
|
2124
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2125
|
+
} else {
|
|
2126
|
+
console.log(`handle audit -- feature ${flags.feature} (${featureRecord.feature_uid})`);
|
|
2127
|
+
console.log(` ${summary.total_handles} handle(s), ${summary.revoked_handles} revoked, ${summary.never_snapshotted} never snapshotted, ${summary.total_snapshots} snapshot(s) total`);
|
|
2128
|
+
for (const h of rows) {
|
|
2129
|
+
const revokedNote = h.revoked_at ? ` -- REVOKED (${h.revoked_reason ?? 'no reason recorded'})` : '';
|
|
2130
|
+
const pointerNote = h.pointer ? `#${h.pointer}` : '';
|
|
2131
|
+
console.log(` ${h.kind} ${h.resource_type}/${h.resource_uid}${pointerNote} -- ${h.snapshot_count} snapshot(s), last ${h.last_recorded_at ?? 'never'}${revokedNote}`);
|
|
2132
|
+
}
|
|
2133
|
+
console.error(`\nnote: ${caveat}`);
|
|
2134
|
+
}
|
|
2135
|
+
process.exit(0);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
1907
2138
|
// S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
|
|
1908
2139
|
// input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
|
|
1909
2140
|
function describeStale(g) {
|
|
@@ -2082,6 +2313,10 @@ function cmdDoctor(args) {
|
|
|
2082
2313
|
const adapters = showAdapters
|
|
2083
2314
|
? ADAPTERS.map((a) => ({
|
|
2084
2315
|
id: a.id, specificity: a.specificity, confidence: a.confidence, capabilities: a.capabilities,
|
|
2316
|
+
// D-adapter-verification-basis: a DIFFERENT axis from confidence (schemas/adapter.
|
|
2317
|
+
// schema.json's own description has the full explanation) -- how well this adapter's
|
|
2318
|
+
// codegen was ever checked against real code, not how sure detect() is about this repo.
|
|
2319
|
+
verificationBasis: a.verificationBasis,
|
|
2085
2320
|
// `detect()` itself can return null on a legitimate non-match -- coerce to a real
|
|
2086
2321
|
// boolean here so `null` unambiguously means "not applicable, no root" below, not
|
|
2087
2322
|
// "detect() happened to return a falsy value".
|
|
@@ -2113,7 +2348,7 @@ function cmdDoctor(args) {
|
|
|
2113
2348
|
console.log('Scanner adapters:');
|
|
2114
2349
|
for (const a of adapters) {
|
|
2115
2350
|
const caps = Object.entries(a.capabilities).filter(([, v]) => v).map(([k]) => k).join(', ') || '(none)';
|
|
2116
|
-
let line = ` ${a.id} (specificity ${a.specificity}, confidence ${a.confidence}) -- capabilities: ${caps}`;
|
|
2351
|
+
let line = ` ${a.id} (specificity ${a.specificity}, confidence ${a.confidence}, verified: ${a.verificationBasis}) -- capabilities: ${caps}`;
|
|
2117
2352
|
if (a.detects !== null) line += a.detects ? ' -- DETECTS this repo' : ' -- does not detect this repo';
|
|
2118
2353
|
console.log(line);
|
|
2119
2354
|
for (const d of a.diagnostics) console.log(` [${d.level}] ${d.code}: ${d.message}`);
|
|
@@ -2364,6 +2599,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2364
2599
|
const subArgs = rest.slice(1);
|
|
2365
2600
|
if (sub === 'emit') return cmdContractEmit(subArgs);
|
|
2366
2601
|
if (sub === 'export') return cmdContractExport(subArgs);
|
|
2602
|
+
if (sub === 'history') return cmdContractHistory(subArgs);
|
|
2367
2603
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
2368
2604
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
2369
2605
|
if (sub === 'waive') return cmdContractWaive(subArgs);
|
|
@@ -2387,6 +2623,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2387
2623
|
if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
|
|
2388
2624
|
if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
|
|
2389
2625
|
if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
|
|
2626
|
+
if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
|
|
2390
2627
|
usage();
|
|
2391
2628
|
process.exit(14);
|
|
2392
2629
|
break;
|
|
@@ -2408,6 +2645,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2408
2645
|
if (sub === 'revoke') return cmdGateRevoke(subArgs);
|
|
2409
2646
|
if (sub === 'history') return cmdGateHistory(subArgs);
|
|
2410
2647
|
if (sub === 'show') return cmdGateShow(subArgs);
|
|
2648
|
+
if (sub === 'export') return cmdGateExport(subArgs);
|
|
2411
2649
|
usage();
|
|
2412
2650
|
process.exit(14);
|
|
2413
2651
|
break;
|
|
@@ -105,6 +105,15 @@ export const WARNING_CODES = Object.freeze({
|
|
|
105
105
|
// this operation carries is unaffected, this is a missed (opt-in) enhancement, same severity
|
|
106
106
|
// class as its A7/A8 siblings.
|
|
107
107
|
CONTRACT_OPENAPI_DESCRIPTION_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
108
|
+
// D-unsupported-annotation-warning: the source document uses a schema keyword (title/plural
|
|
109
|
+
// examples/externalDocs/xml/deprecated) this whole module unconditionally drops -- 0 real
|
|
110
|
+
// occurrences were ever measured on the one oracle these caps/keyword sets were built against,
|
|
111
|
+
// but a genuinely different real document can still use any of them. Module-wide, not
|
|
112
|
+
// per-operation (subject is the keyword NAME, not an operationId) -- contracts/openapi.mjs's
|
|
113
|
+
// findUnsupportedAnnotations() computes this once per document. WARN: nothing this projection
|
|
114
|
+
// already copies is affected, this is purely a disclosure that something in the source is
|
|
115
|
+
// silently unrepresented.
|
|
116
|
+
CONTRACT_OPENAPI_UNSUPPORTED_ANNOTATION_PRESENT: { severity: SEVERITY.WARN, waivable: true },
|
|
108
117
|
});
|
|
109
118
|
|
|
110
119
|
export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
|
|
@@ -199,10 +208,20 @@ export function saveResolution(root, featureId, resolution) {
|
|
|
199
208
|
// blocks, waived or not). Deliberately no wildcard match: a waiver only cancels the EXACT
|
|
200
209
|
// code+subject pair recorded for it, so a new unmatched endpoint added later is never silently
|
|
201
210
|
// covered by an old "--all" waive -- see the "waiver invalidation" test in test/contract-cli.test.mjs.
|
|
211
|
+
// D-waiver-expiry: `expires_at` is a genuinely different axis from `staleWaivers` below --
|
|
212
|
+
// staleness means "the warning this waiver covered no longer exists at all" (the underlying
|
|
213
|
+
// problem was fixed), expiry means "the waiver covered a warning that's STILL there, but the
|
|
214
|
+
// grace period the person who filed it granted has run out" -- an expired waiver stops covering
|
|
215
|
+
// its warning, so `unwaived`/`blocking` treat it exactly as if it had never been recorded. A
|
|
216
|
+
// waiver can be BOTH stale and expired at once (nothing prevents that combination); the two
|
|
217
|
+
// lists are independent, not mutually exclusive.
|
|
202
218
|
export function evaluateResolution(contract, resolution) {
|
|
203
219
|
const status = classifyContract(contract);
|
|
204
220
|
const waivers = resolution.waivers ?? [];
|
|
205
|
-
const
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const expiredWaivers = waivers.filter((w) => typeof w.expires_at === 'string' && Date.parse(w.expires_at) <= now);
|
|
223
|
+
const expiredKeys = new Set(expiredWaivers.map(warningKey));
|
|
224
|
+
const waivedKeys = new Set(waivers.filter((w) => !expiredKeys.has(warningKey(w))).map(warningKey));
|
|
206
225
|
|
|
207
226
|
const errorWarnings = contract.warnings.filter((w) => w.severity === SEVERITY.ERROR);
|
|
208
227
|
const unwaived = errorWarnings.filter((w) => !waivedKeys.has(warningKey(w)));
|
|
@@ -213,5 +232,5 @@ export function evaluateResolution(contract, resolution) {
|
|
|
213
232
|
|
|
214
233
|
const blocking = status === COMPLETENESS.BLOCKED || unwaived.length > 0;
|
|
215
234
|
|
|
216
|
-
return { status, blocking, unwaived, waived, staleWaivers };
|
|
235
|
+
return { status, blocking, unwaived, waived, staleWaivers, expiredWaivers };
|
|
217
236
|
}
|
package/contracts/emit.mjs
CHANGED
|
@@ -103,6 +103,17 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
103
103
|
const warnings = [];
|
|
104
104
|
let endpointCount = 0;
|
|
105
105
|
|
|
106
|
+
// D-unsupported-annotation-warning: module-wide, computed once, independent of targetModule/
|
|
107
|
+
// endpoint iteration below -- the source document either uses one of the 5 permanently-dropped
|
|
108
|
+
// schema keywords or it doesn't, a fact about the WHOLE document, not any one operation.
|
|
109
|
+
for (const keyword of openapi?.unsupportedAnnotations ?? []) {
|
|
110
|
+
warnings.push(makeWarning('CONTRACT_OPENAPI_UNSUPPORTED_ANNOTATION_PRESENT', {
|
|
111
|
+
subject: keyword,
|
|
112
|
+
message: `the source OpenAPI document uses the schema keyword "${keyword}" at least once -- this projection unconditionally drops it (0 real occurrences were measured against the reference document this behavior was built against, but this document has at least one), so it is never represented in any operation's projected schema`,
|
|
113
|
+
detail: { keyword },
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
if (!targetModule) {
|
|
107
118
|
warnings.push(makeWarning('CONTRACT_NO_MODULE', {
|
|
108
119
|
message: 'no related module in the scan report -- emitting an empty operation set. Pass --module, or re-run `bskel scan` with terms that actually match the intended feature.',
|
package/contracts/openapi.mjs
CHANGED
|
@@ -179,6 +179,97 @@ const COPIED_KEYWORDS = Object.freeze(new Set([
|
|
|
179
179
|
// `response-headers`.
|
|
180
180
|
const DOCUMENTATION_KEYWORDS = Object.freeze(new Set(['description', 'example']));
|
|
181
181
|
const DROPPED_KEYWORDS = Object.freeze(new Set(['title', 'examples', 'externalDocs', 'xml', 'deprecated']));
|
|
182
|
+
|
|
183
|
+
// D-unsupported-annotation-warning: 0 real occurrences on the ONE oracle this whole module's
|
|
184
|
+
// caps/keyword sets were measured against does not mean 0 occurrences everywhere -- a genuinely
|
|
185
|
+
// different real-world document could use any of DROPPED_KEYWORDS, and silently dropping them
|
|
186
|
+
// with no signal at all is a real honesty gap (see D-contract-history/D-gate-export's own backlog
|
|
187
|
+
// for the broader "self-identified weaknesses" context this closes one instance of).
|
|
188
|
+
//
|
|
189
|
+
// Deliberately walks ONLY genuine Schema Object structure (via RECURSED_KEYWORDS, the exact same
|
|
190
|
+
// `properties`/`items`/`additionalProperties`/`oneOf`/`anyOf`/`allOf` set inlineSchema() itself
|
|
191
|
+
// recurses through) -- NOT a blanket "every key anywhere in the document" scan. That distinction
|
|
192
|
+
// is load-bearing, not cosmetic: several of DROPPED_KEYWORDS' names collide with REAL, unrelated
|
|
193
|
+
// OpenAPI concepts that live outside a Schema Object entirely -- an Operation Object's own
|
|
194
|
+
// `deprecated` (marks a whole ENDPOINT deprecated) and a Parameter Object's own `deprecated`, both
|
|
195
|
+
// legitimate 3.1 fields with nothing to do with inlineSchema()'s keyword handling. A blanket walk
|
|
196
|
+
// would misreport those as "an unsupported schema keyword found," which is false. This function
|
|
197
|
+
// only descends from confirmed schema roots (a `$ref`-or-inline `schema` under `content.<media>`
|
|
198
|
+
// or `parameters[].schema`, or a named entry in `components.schemas`), the same roots
|
|
199
|
+
// inlineSchema() itself is ever called on.
|
|
200
|
+
//
|
|
201
|
+
// NOT routed through walkSchemaNode()'s fail-closed machinery -- it must never throw on a shape
|
|
202
|
+
// inlineSchema() itself would reject, since its only job is presence detection, not validation.
|
|
203
|
+
// Bounded for free by loadOpenApiDocument()'s own MAX_DOCUMENT_BYTES check upstream.
|
|
204
|
+
function collectUnsupportedAnnotationKeys(node, found, seen) {
|
|
205
|
+
if (node === null || typeof node !== 'object' || Array.isArray(node) || seen.has(node)) return;
|
|
206
|
+
seen.add(node);
|
|
207
|
+
for (const key of Object.keys(node)) {
|
|
208
|
+
if (DROPPED_KEYWORDS.has(key)) found.add(key);
|
|
209
|
+
if (RECURSED_KEYWORDS.has(key)) {
|
|
210
|
+
const value = node[key];
|
|
211
|
+
// `properties` is a field-NAME -> schema map (its VALUES are schemas, its keys are not
|
|
212
|
+
// schema keywords at all) -- a real bug caught live before merge: recursing into the
|
|
213
|
+
// map object itself, instead of `Object.values(value)`, silently walked past every
|
|
214
|
+
// property's actual schema and found nothing beneath `properties` ever. `items`/
|
|
215
|
+
// `additionalProperties` ARE schemas directly; `oneOf`/`anyOf`/`allOf` are arrays of
|
|
216
|
+
// schemas, already handled by the branch below.
|
|
217
|
+
if (key === 'properties' && value && typeof value === 'object' && !Array.isArray(value)) {
|
|
218
|
+
for (const propSchema of Object.values(value)) collectUnsupportedAnnotationKeys(propSchema, found, seen);
|
|
219
|
+
} else if (Array.isArray(value)) {
|
|
220
|
+
for (const item of value) collectUnsupportedAnnotationKeys(item, found, seen);
|
|
221
|
+
} else {
|
|
222
|
+
collectUnsupportedAnnotationKeys(value, found, seen);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function collectSchemaRootsFromMediaTypes(content, roots) {
|
|
229
|
+
if (!content || typeof content !== 'object' || Array.isArray(content)) return;
|
|
230
|
+
for (const mediaEntry of Object.values(content)) {
|
|
231
|
+
const schema = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
|
|
232
|
+
if (schema && typeof schema === 'object' && !Array.isArray(schema)) roots.push(schema);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Every real schema root a document can offer `inlineSchema()`: named `components.schemas`
|
|
237
|
+
// entries, every operation's requestBody/response content schemas, and every operation's
|
|
238
|
+
// parameter schemas -- deliberately NOT `info`/`servers`/`tags`/security schemes, none of which
|
|
239
|
+
// are ever schema-shaped.
|
|
240
|
+
export function findUnsupportedAnnotations(doc) {
|
|
241
|
+
const roots = [];
|
|
242
|
+
const schemas = doc.components?.schemas;
|
|
243
|
+
if (schemas && typeof schemas === 'object' && !Array.isArray(schemas)) roots.push(...Object.values(schemas));
|
|
244
|
+
|
|
245
|
+
const paths = doc.paths;
|
|
246
|
+
if (paths && typeof paths === 'object' && !Array.isArray(paths)) {
|
|
247
|
+
for (const item of Object.values(paths)) {
|
|
248
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
|
249
|
+
for (const [verbOrKey, operation] of Object.entries(item)) {
|
|
250
|
+
if (!HTTP_METHODS.has(verbOrKey) || !operation || typeof operation !== 'object' || Array.isArray(operation)) continue;
|
|
251
|
+
collectSchemaRootsFromMediaTypes(operation.requestBody?.content, roots);
|
|
252
|
+
if (operation.responses && typeof operation.responses === 'object' && !Array.isArray(operation.responses)) {
|
|
253
|
+
for (const resp of Object.values(operation.responses)) {
|
|
254
|
+
collectSchemaRootsFromMediaTypes(resp?.content, roots);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(operation.parameters)) {
|
|
258
|
+
for (const p of operation.parameters) {
|
|
259
|
+
if (p && typeof p === 'object' && !Array.isArray(p) && p.schema && typeof p.schema === 'object' && !Array.isArray(p.schema)) {
|
|
260
|
+
roots.push(p.schema);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const found = new Set();
|
|
269
|
+
const seen = new Set();
|
|
270
|
+
for (const root of roots) collectUnsupportedAnnotationKeys(root, found, seen);
|
|
271
|
+
return [...found].sort();
|
|
272
|
+
}
|
|
182
273
|
// Real Team-IZ-Backend format-value histogram (request-body-reachable schemas only): uuid(20),
|
|
183
274
|
// int32(10), email(7), date(10), date-time(3), int64(2). `uuid` is handled separately (rewritten
|
|
184
275
|
// to BARE_UUID_PATTERN, see inlineSchema) -- not in this set, since it never survives as `format`.
|
|
@@ -1481,6 +1572,10 @@ export function buildReconciliation({ filePath, module, pathPrefix = null, inclu
|
|
|
1481
1572
|
// A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
|
|
1482
1573
|
// contracts/emit.mjs attaches this at the contract root when non-empty.
|
|
1483
1574
|
sourceSecuritySchemes: recon.sourceSecuritySchemes,
|
|
1575
|
+
// D-unsupported-annotation-warning: computed once per document, not per-operation -- a
|
|
1576
|
+
// module-wide presence signal, not a per-operation fact, so contracts/emit.mjs pushes at
|
|
1577
|
+
// most one warning per keyword name for the whole module, not one per operation.
|
|
1578
|
+
unsupportedAnnotations: findUnsupportedAnnotations(loaded.doc),
|
|
1484
1579
|
};
|
|
1485
1580
|
}
|
|
1486
1581
|
|