backend-skeleton 1.0.0-beta.2 → 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 +12 -2
- package/bin/bskel.mjs +260 -11
- package/contracts/completeness.mjs +27 -2
- package/contracts/emit.mjs +68 -9
- package/contracts/export.mjs +94 -25
- package/contracts/openapi.mjs +307 -40
- 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 +51 -3
- 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/feature-contract.schema.json +10 -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
|
|
@@ -133,8 +138,13 @@ operation, copied byte-for-byte, never reconstructed. `security: []` is emitted
|
|
|
133
138
|
document itself said `[]` (a genuine claim that no authentication is required); it is never
|
|
134
139
|
invented as a default. Where no source document was given, or it said nothing for an operation
|
|
135
140
|
(or a particular field of one), the key is simply omitted, meaning "unspecified." Operation-level
|
|
136
|
-
`description`
|
|
137
|
-
bytes/operation
|
|
141
|
+
`description` is copied too, but **opt-in only** (`contract emit --descriptions`) — measured too
|
|
142
|
+
expensive to copy by default (real average 2,442.7 bytes/operation, larger than every other field
|
|
143
|
+
this projection copies combined). The same flag also copies a schema FIELD's own `description`/
|
|
144
|
+
`example` (a property's own annotation, not the operation's) one level deeper into request-body/
|
|
145
|
+
response/error/parameter/per-status/path-param schemas — `title`, plural `examples`,
|
|
146
|
+
`externalDocs`, `xml`, and `deprecated` stay unconditionally dropped either way (0 real occurrences
|
|
147
|
+
measured against the Team-IZ-Backend oracle).
|
|
138
148
|
|
|
139
149
|
Export refuses a zero-operation contract, refuses when the scan found a global path prefix the
|
|
140
150
|
contract's paths don't reflect (`--allow-unprefixed` overrides), and stamps every document with an
|
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,
|
|
@@ -65,16 +67,18 @@ function usage() {
|
|
|
65
67
|
bskel feature rename <id> --to <new-slug> --reason "..." [--json]
|
|
66
68
|
bskel feature link <keepId> <aliasId> --reason "..." [--json]
|
|
67
69
|
bskel feature archive <id> --reason "..." [--json]
|
|
68
|
-
bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0]
|
|
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
|
|
@@ -844,6 +891,11 @@ function cmdContractEmit(args) {
|
|
|
844
891
|
if (flags['path-prefix'] && !flags['openapi-file']) {
|
|
845
892
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--path-prefix only applies when reconciling against a real OpenAPI document -- pass --openapi-file <path> together with it, or drop --path-prefix (the value has no effect on its own).`);
|
|
846
893
|
}
|
|
894
|
+
// A10: same "would be a silent no-op" reasoning as --path-prefix above -- --descriptions only has
|
|
895
|
+
// any effect inside buildReconciliation(), which only runs when --openapi-file is also given.
|
|
896
|
+
if (flags.descriptions && !flags['openapi-file']) {
|
|
897
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--descriptions only applies when reconciling against a real OpenAPI document -- pass --openapi-file <path> together with it, or drop --descriptions (it has no effect on its own).`);
|
|
898
|
+
}
|
|
847
899
|
|
|
848
900
|
const root = requireRepoRoot();
|
|
849
901
|
requirePreflightPassed(root);
|
|
@@ -878,7 +930,7 @@ function cmdContractEmit(args) {
|
|
|
878
930
|
if (flags['openapi-file']) {
|
|
879
931
|
const targetModule = selectModule(scanReport, flags.module);
|
|
880
932
|
if (targetModule) {
|
|
881
|
-
const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'] });
|
|
933
|
+
const result = buildReconciliation({ filePath: flags['openapi-file'], module: targetModule, pathPrefix: flags['path-prefix'], includeDescriptions: flags.descriptions });
|
|
882
934
|
if (!result.ok) {
|
|
883
935
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', result.error);
|
|
884
936
|
}
|
|
@@ -937,6 +989,7 @@ function cmdContractEmit(args) {
|
|
|
937
989
|
warning_codes: countByCode(contract.warnings),
|
|
938
990
|
waived_count: evaluation.waived.length,
|
|
939
991
|
stale_waivers: evaluation.staleWaivers.length,
|
|
992
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
940
993
|
openapi: reconciliation
|
|
941
994
|
? {
|
|
942
995
|
applied: true,
|
|
@@ -978,10 +1031,20 @@ function cmdContractEmit(args) {
|
|
|
978
1031
|
console.log(`openapi: ${p.parameters_copied} operation(s) with parameters copied (${p.parameters_unresolved} partial/unresolved), ${p.security_copied + p.security_public} with security copied, ${p.summary_copied} summaries + ${p.tags_copied} tag sets copied`);
|
|
979
1032
|
// A8: same "just print the numbers" style.
|
|
980
1033
|
console.log(`openapi: ${p.per_status_copied} operation(s) with per-status responses copied, ${p.request_media_types_copied} with non-JSON request media type(s) copied`);
|
|
1034
|
+
// A10: printed only when --descriptions was actually passed -- unlike A7/A8/A9's
|
|
1035
|
+
// default-on fields, printing "0 copied" unconditionally here would misleadingly
|
|
1036
|
+
// suggest this opt-in field was attempted when it never was.
|
|
1037
|
+
if (flags.descriptions) {
|
|
1038
|
+
console.log(`openapi: ${p.description_copied} operation(s) with description copied, ${p.description_unresolved} unresolved`);
|
|
1039
|
+
}
|
|
981
1040
|
}
|
|
982
1041
|
}
|
|
983
1042
|
for (const w of contract.warnings) console.error(`warning[${w.severity}] ${w.code}${w.subject ? ` (${w.subject})` : ''}: ${w.message}`);
|
|
984
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
|
+
}
|
|
985
1048
|
if (evaluation.staleWaivers.length > 0) {
|
|
986
1049
|
console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current warning (kept as-is, not auto-removed):`);
|
|
987
1050
|
for (const w of evaluation.staleWaivers) console.error(` ${w.code} (${w.subject ?? '*'})`);
|
|
@@ -1186,12 +1249,26 @@ function cmdContractExport(args) {
|
|
|
1186
1249
|
// waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
|
|
1187
1250
|
// individual entries -- a warning that doesn't exist yet (e.g. a new unannotated endpoint added
|
|
1188
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
|
+
|
|
1189
1266
|
function cmdContractWaive(args) {
|
|
1190
1267
|
const flags = parseCommand('contract waive', args);
|
|
1191
1268
|
if (flags.help) { console.log(renderCommandHelp('contract waive')); process.exit(0); }
|
|
1192
1269
|
setContext('contract waive', flags);
|
|
1193
1270
|
const root = requireRepoRoot();
|
|
1194
|
-
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>]';
|
|
1195
1272
|
try {
|
|
1196
1273
|
requireWarningCode(flags.code);
|
|
1197
1274
|
} catch (err) {
|
|
@@ -1203,6 +1280,7 @@ function cmdContractWaive(args) {
|
|
|
1203
1280
|
if (!flags.subject && !flags.all) {
|
|
1204
1281
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', usageText);
|
|
1205
1282
|
}
|
|
1283
|
+
const expiresAt = parseExpiresFlag(flags.expires);
|
|
1206
1284
|
|
|
1207
1285
|
const contract = loadContract(root, flags.feature);
|
|
1208
1286
|
if (contract.completeness.status === 'blocked') {
|
|
@@ -1236,7 +1314,7 @@ function cmdContractWaive(args) {
|
|
|
1236
1314
|
const at = new Date().toISOString();
|
|
1237
1315
|
const entries = toWaive
|
|
1238
1316
|
.filter((w) => !existingKeys.has(warningKey(w)))
|
|
1239
|
-
.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 } : {}) }));
|
|
1240
1318
|
const next = {
|
|
1241
1319
|
schema: 'sbf.contract-resolution/1',
|
|
1242
1320
|
feature_id: flags.feature,
|
|
@@ -1254,6 +1332,7 @@ function cmdContractWaive(args) {
|
|
|
1254
1332
|
warning_codes: countByCode(contract.warnings),
|
|
1255
1333
|
waived_count: evaluation.waived.length,
|
|
1256
1334
|
stale_waivers: evaluation.staleWaivers.length,
|
|
1335
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
1257
1336
|
};
|
|
1258
1337
|
const gateState = evaluation.blocking
|
|
1259
1338
|
? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
|
|
@@ -1263,9 +1342,13 @@ function cmdContractWaive(args) {
|
|
|
1263
1342
|
console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
|
|
1264
1343
|
} else {
|
|
1265
1344
|
if (!flags.quiet) {
|
|
1266
|
-
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}` : ''}`);
|
|
1267
1346
|
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
1268
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
|
+
}
|
|
1269
1352
|
if (evaluation.blocking) {
|
|
1270
1353
|
console.error(`\nstill blocked: ${evaluation.unwaived.length} unresolved warning(s) remain:`);
|
|
1271
1354
|
for (const w of evaluation.unwaived) console.error(` ${w.code} (${w.subject})`);
|
|
@@ -1274,6 +1357,74 @@ function cmdContractWaive(args) {
|
|
|
1274
1357
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1275
1358
|
}
|
|
1276
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
|
+
|
|
1277
1428
|
function cmdContractValidate(args) {
|
|
1278
1429
|
const flags = parseCommand('contract validate', args);
|
|
1279
1430
|
if (flags.help) { console.log(renderCommandHelp('contract validate')); process.exit(0); }
|
|
@@ -1561,7 +1712,8 @@ function renderHandlesPlan(plan, actions) {
|
|
|
1561
1712
|
lines.push(`## ${r.type}${r.willGenerateResolver ? '' : ' (resolver will NOT be generated -- see notes)'}`);
|
|
1562
1713
|
lines.push(`- table: ${r.table ?? '(unknown)'}, PK field: ${r.idField ?? '(unknown)'}`);
|
|
1563
1714
|
lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
|
|
1564
|
-
lines.push(`- requiredAuthority: ${r.requiredAuthority}`);
|
|
1715
|
+
lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
|
|
1716
|
+
if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
|
|
1565
1717
|
lines.push('');
|
|
1566
1718
|
}
|
|
1567
1719
|
if (plan.notes.length > 0) {
|
|
@@ -1722,6 +1874,20 @@ function cmdHandlesEmit(args) {
|
|
|
1722
1874
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
|
|
1723
1875
|
}
|
|
1724
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
|
+
|
|
1725
1891
|
// Handles are only emitted for a feature whose contract has actually been established --
|
|
1726
1892
|
// codegen against a feature nobody has scanned/contracted yet has nothing real to route to.
|
|
1727
1893
|
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
@@ -1755,8 +1921,21 @@ function cmdHandlesEmit(args) {
|
|
|
1755
1921
|
// does, without requiring both flags together.
|
|
1756
1922
|
const dryRun = flags.check || flags.diff;
|
|
1757
1923
|
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = provider.emit({
|
|
1758
|
-
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,
|
|
1759
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
|
+
}
|
|
1760
1939
|
// D4: found live while grounding this against a real fixture -- `written` (pre-existing field,
|
|
1761
1940
|
// unchanged semantics) unconditionally includes a java-spring `outputs.spec` file like
|
|
1762
1941
|
// migration.sql even when its content is byte-identical (P4 already found this: it's never
|
|
@@ -1893,6 +2072,69 @@ function cmdHandlesPatchApprove(args) {
|
|
|
1893
2072
|
process.exit(0);
|
|
1894
2073
|
}
|
|
1895
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
|
+
|
|
1896
2138
|
// S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
|
|
1897
2139
|
// input that actually moved, using the exact reason requireGate()'s explainStaleness() reports.
|
|
1898
2140
|
function describeStale(g) {
|
|
@@ -2071,6 +2313,10 @@ function cmdDoctor(args) {
|
|
|
2071
2313
|
const adapters = showAdapters
|
|
2072
2314
|
? ADAPTERS.map((a) => ({
|
|
2073
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,
|
|
2074
2320
|
// `detect()` itself can return null on a legitimate non-match -- coerce to a real
|
|
2075
2321
|
// boolean here so `null` unambiguously means "not applicable, no root" below, not
|
|
2076
2322
|
// "detect() happened to return a falsy value".
|
|
@@ -2102,7 +2348,7 @@ function cmdDoctor(args) {
|
|
|
2102
2348
|
console.log('Scanner adapters:');
|
|
2103
2349
|
for (const a of adapters) {
|
|
2104
2350
|
const caps = Object.entries(a.capabilities).filter(([, v]) => v).map(([k]) => k).join(', ') || '(none)';
|
|
2105
|
-
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}`;
|
|
2106
2352
|
if (a.detects !== null) line += a.detects ? ' -- DETECTS this repo' : ' -- does not detect this repo';
|
|
2107
2353
|
console.log(line);
|
|
2108
2354
|
for (const d of a.diagnostics) console.log(` [${d.level}] ${d.code}: ${d.message}`);
|
|
@@ -2353,6 +2599,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2353
2599
|
const subArgs = rest.slice(1);
|
|
2354
2600
|
if (sub === 'emit') return cmdContractEmit(subArgs);
|
|
2355
2601
|
if (sub === 'export') return cmdContractExport(subArgs);
|
|
2602
|
+
if (sub === 'history') return cmdContractHistory(subArgs);
|
|
2356
2603
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
2357
2604
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
2358
2605
|
if (sub === 'waive') return cmdContractWaive(subArgs);
|
|
@@ -2376,6 +2623,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2376
2623
|
if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
|
|
2377
2624
|
if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
|
|
2378
2625
|
if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
|
|
2626
|
+
if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
|
|
2379
2627
|
usage();
|
|
2380
2628
|
process.exit(14);
|
|
2381
2629
|
break;
|
|
@@ -2397,6 +2645,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2397
2645
|
if (sub === 'revoke') return cmdGateRevoke(subArgs);
|
|
2398
2646
|
if (sub === 'history') return cmdGateHistory(subArgs);
|
|
2399
2647
|
if (sub === 'show') return cmdGateShow(subArgs);
|
|
2648
|
+
if (sub === 'export') return cmdGateExport(subArgs);
|
|
2400
2649
|
usage();
|
|
2401
2650
|
process.exit(14);
|
|
2402
2651
|
break;
|
|
@@ -99,6 +99,21 @@ export const WARNING_CODES = Object.freeze({
|
|
|
99
99
|
// it reuses CONTRACT_OPENAPI_RESPONSE_SCHEMA_UNRESOLVED/CONTRACT_OPENAPI_ERROR_SCHEMA_UNRESOLVED
|
|
100
100
|
// unchanged -- see D-openapi-per-status.
|
|
101
101
|
CONTRACT_OPENAPI_REQUEST_MEDIA_TYPE_UNRESOLVED: { severity: SEVERITY.WARN, waivable: true },
|
|
102
|
+
// A10: the operation's description exceeded MAX_DESCRIPTION_LENGTH -- independent of every code
|
|
103
|
+
// above (nothing else tracks description length), so it gets its own code rather than reusing
|
|
104
|
+
// one, same reasoning A8 used to justify its own new multipart code. WARN: every other field
|
|
105
|
+
// this operation carries is unaffected, this is a missed (opt-in) enhancement, same severity
|
|
106
|
+
// class as its A7/A8 siblings.
|
|
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 },
|
|
102
117
|
});
|
|
103
118
|
|
|
104
119
|
export const WARNING_CODE_NAMES = Object.freeze(Object.keys(WARNING_CODES));
|
|
@@ -193,10 +208,20 @@ export function saveResolution(root, featureId, resolution) {
|
|
|
193
208
|
// blocks, waived or not). Deliberately no wildcard match: a waiver only cancels the EXACT
|
|
194
209
|
// code+subject pair recorded for it, so a new unmatched endpoint added later is never silently
|
|
195
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.
|
|
196
218
|
export function evaluateResolution(contract, resolution) {
|
|
197
219
|
const status = classifyContract(contract);
|
|
198
220
|
const waivers = resolution.waivers ?? [];
|
|
199
|
-
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));
|
|
200
225
|
|
|
201
226
|
const errorWarnings = contract.warnings.filter((w) => w.severity === SEVERITY.ERROR);
|
|
202
227
|
const unwaived = errorWarnings.filter((w) => !waivedKeys.has(warningKey(w)));
|
|
@@ -207,5 +232,5 @@ export function evaluateResolution(contract, resolution) {
|
|
|
207
232
|
|
|
208
233
|
const blocking = status === COMPLETENESS.BLOCKED || unwaived.length > 0;
|
|
209
234
|
|
|
210
|
-
return { status, blocking, unwaived, waived, staleWaivers };
|
|
235
|
+
return { status, blocking, unwaived, waived, staleWaivers, expiredWaivers };
|
|
211
236
|
}
|