backend-skeleton 1.5.0 → 1.7.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 +113 -8
- package/bin/bskel.mjs +549 -63
- package/contracts/completeness.mjs +12 -1
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +220 -74
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +125 -7
- package/lib/decision-log.mjs +58 -0
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +250 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +334 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +606 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- package/schemas/patch-transaction.schema.json +87 -2
package/bin/bskel.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { repoRoot, localDefaultBranch, fileHistory, showFileAtRevision, headSha,
|
|
|
9
9
|
import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, awaitNamedGateDisposition, EXIT } from '../lib/gates.mjs';
|
|
10
10
|
import { REPO_GATE_ID, GATE_NAMES, gateScopeId, requireGateDefinition } from '../lib/gate-definitions.mjs';
|
|
11
11
|
import { getGate, loadState, historyPath } from '../lib/state.mjs';
|
|
12
|
+
import { buildGateExportReport, readGateHistory } from '../lib/gate-export.mjs';
|
|
12
13
|
import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
|
|
13
14
|
import { hydrateScanReportFilePaths, dehydrateScanReportFilePaths } from '../lib/scan-report-paths.mjs';
|
|
14
15
|
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
@@ -29,17 +30,23 @@ import { ADAPTERS, LOAD_ERRORS, adapterById } from '../scanners/registry.mjs';
|
|
|
29
30
|
import { COMMAND_CAPABILITIES, CAPABILITY_SATISFIERS, explainMissingCapability } from '../scanners/capabilities.mjs';
|
|
30
31
|
import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contracts/emit.mjs';
|
|
31
32
|
import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
|
|
32
|
-
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
|
|
33
|
+
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode, isWaiverExpired } from '../contracts/completeness.mjs';
|
|
33
34
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
35
|
+
import { appendDecisionEvent, readDecisionLog } from '../lib/decision-log.mjs';
|
|
34
36
|
import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
|
|
35
37
|
import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
|
|
36
|
-
import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
|
|
38
|
+
import { generateKeypair, signPayload, verifyPayload, publicKeyIdFromPrivate, publicKeyIdFromPublic } from '../lib/attest.mjs';
|
|
37
39
|
import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
|
|
38
40
|
import { createHttpServer } from '../lib/http-server.mjs';
|
|
39
41
|
import {
|
|
40
42
|
resolveClassFile, listDownstreamDependents, DependencyOperationError,
|
|
41
43
|
declareDependency, removeDependency, buildDependencyListReport,
|
|
42
44
|
} from '../lib/field-dependencies.mjs';
|
|
45
|
+
import {
|
|
46
|
+
ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound, withdrawDisposition,
|
|
47
|
+
} from '../lib/impact.mjs';
|
|
48
|
+
import { buildImpactGraph } from '../lib/impact-graph.mjs';
|
|
49
|
+
import { toGraphifyExtraction, toMermaid } from '../lib/impact-export-graphify.mjs';
|
|
43
50
|
import {
|
|
44
51
|
findCollisions, evaluateCrossFeatureFindings, waiverKey,
|
|
45
52
|
crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
|
|
@@ -97,6 +104,7 @@ function usage() {
|
|
|
97
104
|
bskel scan repair --feature <id> [--json]
|
|
98
105
|
bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
|
|
99
106
|
bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
|
|
107
|
+
bskel scan cross-feature-unwaive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..." [--json]
|
|
100
108
|
bskel feature init --slug <name>
|
|
101
109
|
bskel feature list [--all] [--json]
|
|
102
110
|
bskel feature show <id> [--json]
|
|
@@ -111,9 +119,15 @@ function usage() {
|
|
|
111
119
|
bskel contract validate --feature <id> --file <envelope.json>
|
|
112
120
|
bskel contract tool-schema --feature <id> --operation <operationId>
|
|
113
121
|
bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
|
|
122
|
+
bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]
|
|
114
123
|
bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
|
|
115
124
|
bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
|
|
116
125
|
bskel dependency list --feature <id> [--json]
|
|
126
|
+
bskel impact check --feature <id> [--all] [--json]
|
|
127
|
+
bskel impact accept --feature <id> [--json]
|
|
128
|
+
bskel impact disposition --feature <id> --change <change_key> --downstream <id> (--mode <compatible|migrate|waive> [--tracked-by "..."] [--expires-days <N>] | --withdraw) --reason "..." [--json]
|
|
129
|
+
bskel impact ack --feature <id> --from <id> --change <change_key> --reason "..." [--json]
|
|
130
|
+
bskel impact export --format <graphify|json|mermaid> [--out <path>] [--focus <id>] [--rings <N>]
|
|
117
131
|
bskel rules check --feature <id> [--init] [--json]
|
|
118
132
|
bskel rules list --feature <id> [--json]
|
|
119
133
|
bskel rules explain --feature <id> --rule <id> [--json]
|
|
@@ -123,8 +137,9 @@ function usage() {
|
|
|
123
137
|
bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
|
|
124
138
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
125
139
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
140
|
+
bskel handles patch unapprove --feature <id> --resource <Type> --field <name> --reason "..." [--json]
|
|
126
141
|
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--module <name>] [--check-registry-coverage] [--json]
|
|
127
|
-
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]
|
|
142
|
+
bskel patch propose --feature <id> [--kind config-apply|ddl-apply|java-source-splice] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> --splice-file <path> [--json]
|
|
128
143
|
bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
|
|
129
144
|
bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
|
|
130
145
|
bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
|
|
@@ -139,9 +154,9 @@ function usage() {
|
|
|
139
154
|
bskel gate revoke <name> --reason "..." [--feature <id>]
|
|
140
155
|
bskel gate history <name> [--feature <id>] [--json]
|
|
141
156
|
bskel gate show [<name>] [--feature <id>]
|
|
142
|
-
bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
|
|
157
|
+
bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath> [--allow-dirty]] [--json]
|
|
143
158
|
bskel attest keygen --out <dir> [--force] [--json]
|
|
144
|
-
bskel attest verify --file <path> --pubkey <path> [--json]
|
|
159
|
+
bskel attest verify --file <path> --pubkey <path> [--expect-head <sha>] [--max-age-minutes N] [--reject-dirty] [--json]
|
|
145
160
|
bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
|
|
146
161
|
bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
|
|
147
162
|
`);
|
|
@@ -352,32 +367,6 @@ function cmdGateRevoke(args) {
|
|
|
352
367
|
process.exit(EXIT_CODES.NOT_PASSED);
|
|
353
368
|
}
|
|
354
369
|
|
|
355
|
-
// S4 (D-gate-history): reads the append-only .sbf/<feature>.history.jsonl -- a corrupt/invalid
|
|
356
|
-
// line is skipped with a warning, not a hard failure, matching JSONL's own resilience rationale
|
|
357
|
-
// (see lib/state.mjs's appendGateEvent).
|
|
358
|
-
function readGateHistory(root, featureId, gateName) {
|
|
359
|
-
const file = historyPath(root, featureId);
|
|
360
|
-
if (!fs.existsSync(file)) return [];
|
|
361
|
-
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
362
|
-
const events = [];
|
|
363
|
-
for (const [i, line] of lines.entries()) {
|
|
364
|
-
let parsed;
|
|
365
|
-
try {
|
|
366
|
-
parsed = JSON.parse(line);
|
|
367
|
-
} catch {
|
|
368
|
-
console.error(`warning: ${file}:${i + 1}: not valid JSON, skipped`);
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
const { ok, errors } = validateAgainstSchema('gate-event.schema.json', parsed);
|
|
372
|
-
if (!ok) {
|
|
373
|
-
console.error(`warning: ${file}:${i + 1}: does not match schemas/gate-event.schema.json, skipped:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
374
|
-
continue;
|
|
375
|
-
}
|
|
376
|
-
if (parsed.gate === gateName) events.push(parsed);
|
|
377
|
-
}
|
|
378
|
-
return events;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
370
|
function cmdGateHistory(args) {
|
|
382
371
|
const flags = parseCommand('gate history', args);
|
|
383
372
|
if (flags.help) { console.log(renderCommandHelp('gate history')); process.exit(0); }
|
|
@@ -386,7 +375,11 @@ function cmdGateHistory(args) {
|
|
|
386
375
|
const gateName = flags._[0];
|
|
387
376
|
if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate history <name> [--feature <id>] [--json]');
|
|
388
377
|
resolveGateArg(gateName, flags.feature);
|
|
389
|
-
|
|
378
|
+
// S4 (D-gate-history): a corrupt/invalid history line is skipped with a warning, not a hard
|
|
379
|
+
// failure, matching JSONL's own resilience rationale (see lib/state.mjs's appendGateEvent).
|
|
380
|
+
const events = readGateHistory(root, flags.feature, gateName, {
|
|
381
|
+
onWarning: (msg, errors) => console.error(`warning: ${msg}${errors ? `:\n${formatSchemaErrors(errors).join('\n')}` : ''}`),
|
|
382
|
+
});
|
|
390
383
|
if (flags.json) {
|
|
391
384
|
console.log(JSON.stringify(events, null, 2));
|
|
392
385
|
} else if (events.length === 0) {
|
|
@@ -439,20 +432,15 @@ function cmdGateExport(args) {
|
|
|
439
432
|
if (flags.key && !flags.sign) {
|
|
440
433
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
|
|
441
434
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
435
|
+
// D-attestation-payload-completeness (K4): --allow-dirty only means something alongside --sign
|
|
436
|
+
// -- an unsigned export never refuses a dirty tree (unchanged behavior), so a bare --allow-dirty
|
|
437
|
+
// would otherwise be a silently-ignored flag, the exact failure mode D-cli-contract already
|
|
438
|
+
// refuses everywhere else.
|
|
439
|
+
if (flags['allow-dirty'] && !flags.sign) {
|
|
440
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--allow-dirty only has an effect together with --sign -- an unsigned gate export never refuses a dirty tree');
|
|
447
441
|
}
|
|
448
442
|
|
|
449
|
-
const report = {
|
|
450
|
-
schema: 'sbf.gate-export/1',
|
|
451
|
-
feature_id: flags.feature,
|
|
452
|
-
generated_at: new Date().toISOString(),
|
|
453
|
-
git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
|
|
454
|
-
gates,
|
|
455
|
-
};
|
|
443
|
+
const report = buildGateExportReport(root, flags.feature, { dirtyAcknowledged: Boolean(flags['allow-dirty']) });
|
|
456
444
|
// D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
|
|
457
445
|
// be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
|
|
458
446
|
{
|
|
@@ -464,6 +452,20 @@ function cmdGateExport(args) {
|
|
|
464
452
|
|
|
465
453
|
let payload = report;
|
|
466
454
|
if (flags.sign) {
|
|
455
|
+
// D-attestation-payload-completeness (K4): refuses to sign over a dirty working tree unless
|
|
456
|
+
// explicitly acknowledged -- direct reuse of scripts/preflight-base-ref.sh's own already-
|
|
457
|
+
// shipped --allow-dirty convention (same flag name, same DIRTY exit code, same reasoning: "so
|
|
458
|
+
// evidence can honestly distinguish 'clean tree, passed' from 'dirty tree, --allow-dirty
|
|
459
|
+
// overrode it'"). Signing is a strictly stronger claim than preflighting, so it cannot be
|
|
460
|
+
// laxer. `report.git.dirty === true` is checked specifically, not truthiness -- isDirty()
|
|
461
|
+
// (and therefore this field) can be `null` when git itself failed, and "we could not
|
|
462
|
+
// determine dirtiness" must not silently pass this refusal.
|
|
463
|
+
if (report.git.dirty === null) {
|
|
464
|
+
fail(EXIT_CODES.DIRTY, 'DIRTY', 'refusing to sign an attestation: could not determine whether the working tree is dirty (git status failed) -- fix the git error, or pass --allow-dirty to sign anyway.');
|
|
465
|
+
}
|
|
466
|
+
if (report.git.dirty === true && !flags['allow-dirty']) {
|
|
467
|
+
fail(EXIT_CODES.DIRTY, 'DIRTY', `refusing to sign an attestation over a dirty working tree (${report.git.dirty_file_count} uncommitted change(s)) -- commit/stash them, or pass --allow-dirty to sign anyway (the acknowledgement is recorded inside the signed payload). The same refusal \`bskel preflight\` already makes.`);
|
|
468
|
+
}
|
|
467
469
|
let privateKeyPem;
|
|
468
470
|
try {
|
|
469
471
|
privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
|
|
@@ -471,12 +473,14 @@ function cmdGateExport(args) {
|
|
|
471
473
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
|
|
472
474
|
}
|
|
473
475
|
let signatureValue;
|
|
476
|
+
let keyId;
|
|
474
477
|
try {
|
|
475
478
|
signatureValue = signPayload(report, privateKeyPem);
|
|
479
|
+
keyId = publicKeyIdFromPrivate(privateKeyPem);
|
|
476
480
|
} catch (err) {
|
|
477
481
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
|
|
478
482
|
}
|
|
479
|
-
const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
|
|
483
|
+
const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue, key_id: keyId } };
|
|
480
484
|
const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
|
|
481
485
|
if (!ok) {
|
|
482
486
|
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
|
|
@@ -489,9 +493,11 @@ function cmdGateExport(args) {
|
|
|
489
493
|
const outPath = path.resolve(process.cwd(), flags.out);
|
|
490
494
|
writeFileAtomic(outPath, rendered);
|
|
491
495
|
if (!flags.quiet) {
|
|
492
|
-
const passCount =
|
|
496
|
+
const passCount = report.verdict.passing;
|
|
493
497
|
const signedNote = flags.sign ? ' (signed)' : '';
|
|
494
|
-
|
|
498
|
+
const dirtyNote = report.git.dirty ? (report.git.dirty_acknowledged ? ', dirty (acknowledged)' : ', dirty') : '';
|
|
499
|
+
const blockingNote = report.verdict.blocking_gates.length > 0 ? ` -- blocking: ${report.verdict.blocking_gates.join(', ')}` : '';
|
|
500
|
+
console.log(`wrote ${flags.out}${signedNote} -- ${passCount}/${GATE_NAMES.length} gate(s) currently passing, ${report.git.branch}@${report.git.head_sha?.slice(0, 12) ?? '(unknown)'}${dirtyNote}${blockingNote}`);
|
|
495
501
|
}
|
|
496
502
|
} else {
|
|
497
503
|
console.log(rendered);
|
|
@@ -562,13 +568,64 @@ function cmdAttestVerify(args) {
|
|
|
562
568
|
const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
|
|
563
569
|
const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
|
|
564
570
|
|
|
571
|
+
// D-attestation-payload-completeness (K5): three OPT-IN, default-off assertions, evaluated
|
|
572
|
+
// purely against fields already inside the report (so verification stays fully offline and
|
|
573
|
+
// repo-independent). They exist for both sbf.gate-export/1 and /2 reports -- head_sha/
|
|
574
|
+
// generated_at/dirty are unchanged fields from /1, nothing here needed a /2-only field.
|
|
575
|
+
const assertions = [];
|
|
576
|
+
if (flags['expect-head']) {
|
|
577
|
+
const actual = attestation.report.git?.head_sha ?? null;
|
|
578
|
+
const ok = actual === flags['expect-head'];
|
|
579
|
+
assertions.push({ name: 'expect-head', ok, detail: ok ? `head_sha matches ${flags['expect-head']}` : `report's head_sha is "${actual}", expected "${flags['expect-head']}"` });
|
|
580
|
+
}
|
|
581
|
+
const maxAgeMinutes = flags['max-age-minutes'] != null ? Number(flags['max-age-minutes']) : 0;
|
|
582
|
+
if (maxAgeMinutes > 0) {
|
|
583
|
+
const generatedAtMs = Date.parse(attestation.report.generated_at);
|
|
584
|
+
const ageSeconds = Number.isFinite(generatedAtMs) ? Math.max(0, Math.round((Date.now() - generatedAtMs) / 1000)) : null;
|
|
585
|
+
const ok = ageSeconds !== null && ageSeconds <= maxAgeMinutes * 60;
|
|
586
|
+
assertions.push({ name: 'max-age-minutes', ok, detail: ageSeconds === null ? `report's generated_at ("${attestation.report.generated_at}") is not a parseable timestamp` : `age ${ageSeconds}s, limit ${maxAgeMinutes * 60}s` });
|
|
587
|
+
}
|
|
588
|
+
if (flags['reject-dirty']) {
|
|
589
|
+
const dirty = attestation.report.git?.dirty ?? null;
|
|
590
|
+
const ok = dirty !== true;
|
|
591
|
+
assertions.push({ name: 'reject-dirty', ok, detail: ok ? 'report is not dirty' : 'report.git.dirty === true' });
|
|
592
|
+
}
|
|
593
|
+
const assertionsOk = assertions.every((a) => a.ok);
|
|
594
|
+
|
|
595
|
+
// D-attestation-payload-completeness (K6): key_id is a SELECTION HINT only -- it sits OUTSIDE
|
|
596
|
+
// the signed bytes (only `report` is signed), so it cannot change whether `valid` is true. It
|
|
597
|
+
// exists purely to make a wrong---pubkey mistake legible instead of an unexplained INVALID.
|
|
598
|
+
let keyIdNote = null;
|
|
599
|
+
if (attestation.signature.key_id) {
|
|
600
|
+
try {
|
|
601
|
+
const actualKeyId = publicKeyIdFromPublic(publicKeyPem);
|
|
602
|
+
if (attestation.signature.key_id !== actualKeyId) {
|
|
603
|
+
keyIdNote = `this attestation declares key_id ${attestation.signature.key_id}, but --pubkey is ${actualKeyId} -- you may have the wrong public key`;
|
|
604
|
+
}
|
|
605
|
+
} catch {
|
|
606
|
+
// A --pubkey that isn't even a parseable key already drives verifyPayload() to false
|
|
607
|
+
// above; nothing more useful to say here.
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const legacyReport = attestation.report.schema === 'sbf.gate-export/1';
|
|
611
|
+
|
|
565
612
|
if (flags.json) {
|
|
566
|
-
console.log(JSON.stringify({
|
|
613
|
+
console.log(JSON.stringify({
|
|
614
|
+
valid,
|
|
615
|
+
report_format: attestation.report.schema ?? 'sbf.gate-export/1',
|
|
616
|
+
report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` },
|
|
617
|
+
assertions,
|
|
618
|
+
key_id_note: keyIdNote,
|
|
619
|
+
}, null, 2));
|
|
567
620
|
} else if (!flags.quiet) {
|
|
568
621
|
console.log(valid ? 'VALID: this attestation was genuinely signed by the holder of the matching private key' : 'INVALID: signature does not match this report + public key');
|
|
622
|
+
if (legacyReport) console.log('report format: sbf.gate-export/1 (pre-D-attestation-payload-completeness -- no tool version, no live gate verdict)');
|
|
623
|
+
if (keyIdNote) console.log(`note: ${keyIdNote}`);
|
|
569
624
|
console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
|
|
625
|
+
for (const a of assertions) console.log(`assertion ${a.name}: ${a.ok ? 'ok' : 'FAILED'} -- ${a.detail}`);
|
|
570
626
|
}
|
|
571
|
-
process.exit(
|
|
627
|
+
if (!valid) process.exit(EXIT_CODES.CHECK_FAILED);
|
|
628
|
+
process.exit(assertionsOk ? EXIT_CODES.OK : EXIT_CODES.ATTESTATION_ASSERTION_FAILED);
|
|
572
629
|
}
|
|
573
630
|
|
|
574
631
|
// Structural enforcement of "preflight blocks everything below it" (see the workflow table in
|
|
@@ -972,6 +1029,10 @@ function cmdScanCrossFeatureWaive(args) {
|
|
|
972
1029
|
waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
|
|
973
1030
|
};
|
|
974
1031
|
saveCrossFeatureResolution(root, flags.feature, next);
|
|
1032
|
+
appendDecisionEvent(root, flags.feature, {
|
|
1033
|
+
kind: 'cross_feature_waiver', action: 'record', at: entry.at, reason: entry.reason, feature_id: flags.feature,
|
|
1034
|
+
subject: { signal: entry.signal, identifier: entry.identifier, other_feature: entry.other_feature },
|
|
1035
|
+
});
|
|
975
1036
|
return next;
|
|
976
1037
|
});
|
|
977
1038
|
|
|
@@ -995,6 +1056,64 @@ function cmdScanCrossFeatureWaive(args) {
|
|
|
995
1056
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
996
1057
|
}
|
|
997
1058
|
|
|
1059
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
1060
|
+
// waiver entry and appends a `withdraw` decision event. Never a snapshot restore.
|
|
1061
|
+
function cmdScanCrossFeatureUnwaive(args) {
|
|
1062
|
+
const flags = parseCommand('scan cross-feature-unwaive', args);
|
|
1063
|
+
if (flags.help) { console.log(renderCommandHelp('scan cross-feature-unwaive')); process.exit(0); }
|
|
1064
|
+
setContext('scan cross-feature-unwaive', flags);
|
|
1065
|
+
const root = requireRepoRoot();
|
|
1066
|
+
requireValidFeatureId(flags.feature);
|
|
1067
|
+
requireValidFeatureId(flags['other-feature']);
|
|
1068
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
1069
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-unwaive requires --reason "..." -- every withdrawal must be auditable');
|
|
1070
|
+
}
|
|
1071
|
+
const report = loadCrossFeatureReport(root, flags.feature);
|
|
1072
|
+
if (!report) {
|
|
1073
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no cross-feature-report.json for feature "${flags.feature}"`);
|
|
1074
|
+
}
|
|
1075
|
+
const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
|
|
1076
|
+
|
|
1077
|
+
const updated = withLockSync(root, 'state', () => {
|
|
1078
|
+
const resolution = loadCrossFeatureResolution(root, flags.feature);
|
|
1079
|
+
const match = resolution.waivers.find((w) => waiverKey(w) === key);
|
|
1080
|
+
if (!match) {
|
|
1081
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no waiver recorded for --signal ${flags.signal} --identifier "${flags.identifier}" --other-feature ${flags['other-feature']} -- nothing to unwaive`);
|
|
1082
|
+
}
|
|
1083
|
+
const at = new Date().toISOString();
|
|
1084
|
+
const next = {
|
|
1085
|
+
schema: 'sbf.cross-feature-resolution/1',
|
|
1086
|
+
feature_id: flags.feature,
|
|
1087
|
+
waivers: resolution.waivers.filter((w) => waiverKey(w) !== key),
|
|
1088
|
+
};
|
|
1089
|
+
saveCrossFeatureResolution(root, flags.feature, next);
|
|
1090
|
+
appendDecisionEvent(root, flags.feature, {
|
|
1091
|
+
kind: 'cross_feature_waiver', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
1092
|
+
subject: { signal: match.signal, identifier: match.identifier, other_feature: match.other_feature },
|
|
1093
|
+
});
|
|
1094
|
+
return next;
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
|
|
1098
|
+
const evidence = {
|
|
1099
|
+
finding_count: report.findings.length,
|
|
1100
|
+
high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
|
|
1101
|
+
waived_count: evaluation.waived.length,
|
|
1102
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
1103
|
+
};
|
|
1104
|
+
const gateState = evaluation.blocking
|
|
1105
|
+
? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
|
|
1106
|
+
: passNamedGate(root, 'cross_feature', flags.feature, evidence);
|
|
1107
|
+
|
|
1108
|
+
if (flags.json) {
|
|
1109
|
+
console.log(JSON.stringify({ withdrawn: true, gate: gateState.gates.cross_feature }, null, 2));
|
|
1110
|
+
} else if (!flags.quiet) {
|
|
1111
|
+
console.log(`unwaived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
|
|
1112
|
+
console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
|
|
1113
|
+
}
|
|
1114
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
998
1117
|
// D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
|
|
999
1118
|
// load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
|
|
1000
1119
|
// during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
|
|
@@ -1860,20 +1979,38 @@ function cmdContractWaive(args) {
|
|
|
1860
1979
|
// entries). Locking only the final write (inside saveResolution()) would NOT close this race --
|
|
1861
1980
|
// the window is between this function's own loadResolution() read and its save, not inside the
|
|
1862
1981
|
// write call itself.
|
|
1863
|
-
|
|
1982
|
+
// D-waiver-renewal: `existingKeys` -- the "already waived, do nothing" set -- must only contain
|
|
1983
|
+
// keys of waivers that are still LIVE. Before this fix it was built from every stored waiver
|
|
1984
|
+
// regardless of expires_at, so an expired waiver's key permanently "existed" and every renewal
|
|
1985
|
+
// attempt matched it and produced zero new entries (see contracts/completeness.mjs's
|
|
1986
|
+
// isWaiverExpired() for the shared predicate this now shares with evaluateResolution() -- the
|
|
1987
|
+
// two were previously two unsynchronized inline checks). A renewal replaces the expired entry
|
|
1988
|
+
// (same key never appears twice) rather than appending alongside it, which would otherwise leave
|
|
1989
|
+
// evaluateResolution()'s expiredKeys/waivedKeys sets computing over two same-key entries.
|
|
1990
|
+
const { resolution: updatedResolution, newEntries, renewedEntries } = withLockSync(root, 'state', () => {
|
|
1864
1991
|
const resolution = loadResolution(root, flags.feature);
|
|
1865
|
-
const
|
|
1992
|
+
const now = Date.now();
|
|
1993
|
+
const liveWaivers = (resolution.waivers ?? []).filter((w) => !isWaiverExpired(w, now));
|
|
1994
|
+
const expiredKeys = new Set((resolution.waivers ?? []).filter((w) => isWaiverExpired(w, now)).map(warningKey));
|
|
1995
|
+
const liveKeys = new Set(liveWaivers.map(warningKey));
|
|
1866
1996
|
const at = new Date().toISOString();
|
|
1867
|
-
const
|
|
1868
|
-
|
|
1869
|
-
|
|
1997
|
+
const toRecord = toWaive.filter((w) => !liveKeys.has(warningKey(w)));
|
|
1998
|
+
const entries = toRecord.map((w) => ({ code: w.code, subject: w.subject, reason: flags.reason, at, ...(expiresAt ? { expires_at: expiresAt } : {}) }));
|
|
1999
|
+
const newEntriesOut = entries.filter((e) => !expiredKeys.has(warningKey(e)));
|
|
2000
|
+
const renewedEntriesOut = entries.filter((e) => expiredKeys.has(warningKey(e)));
|
|
1870
2001
|
const next = {
|
|
1871
2002
|
schema: 'sbf.contract-resolution/1',
|
|
1872
2003
|
feature_id: flags.feature,
|
|
1873
|
-
waivers: [...
|
|
2004
|
+
waivers: [...liveWaivers, ...entries],
|
|
1874
2005
|
};
|
|
1875
2006
|
saveResolution(root, flags.feature, next);
|
|
1876
|
-
|
|
2007
|
+
for (const e of newEntriesOut) {
|
|
2008
|
+
appendDecisionEvent(root, flags.feature, { kind: 'contract_waiver', action: 'record', at: e.at, reason: e.reason, feature_id: flags.feature, subject: { code: e.code, subject: e.subject ?? null }, expires_at: e.expires_at ?? null });
|
|
2009
|
+
}
|
|
2010
|
+
for (const e of renewedEntriesOut) {
|
|
2011
|
+
appendDecisionEvent(root, flags.feature, { kind: 'contract_waiver', action: 'renew', at: e.at, reason: e.reason, feature_id: flags.feature, subject: { code: e.code, subject: e.subject ?? null }, expires_at: e.expires_at ?? null });
|
|
2012
|
+
}
|
|
2013
|
+
return { resolution: next, newEntries: newEntriesOut, renewedEntries: renewedEntriesOut };
|
|
1877
2014
|
});
|
|
1878
2015
|
|
|
1879
2016
|
const evaluation = evaluateResolution(contract, updatedResolution);
|
|
@@ -1891,10 +2028,14 @@ function cmdContractWaive(args) {
|
|
|
1891
2028
|
: passNamedGate(root, 'contract', flags.feature, evidence);
|
|
1892
2029
|
|
|
1893
2030
|
if (flags.json) {
|
|
1894
|
-
console.log(JSON.stringify({ waived: newEntries, gate: gateState.gates.contract }, null, 2));
|
|
2031
|
+
console.log(JSON.stringify({ waived: newEntries, renewed: renewedEntries, gate: gateState.gates.contract }, null, 2));
|
|
1895
2032
|
} else {
|
|
1896
2033
|
if (!flags.quiet) {
|
|
1897
|
-
|
|
2034
|
+
const alreadyWaived = toWaive.length - newEntries.length - renewedEntries.length;
|
|
2035
|
+
console.log(`waived ${newEntries.length} new warning(s)${alreadyWaived > 0 ? ` (${alreadyWaived} already waived)` : ''}${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
2036
|
+
if (renewedEntries.length > 0) {
|
|
2037
|
+
console.log(`renewed ${renewedEntries.length} expired waiver(s)${expiresAt ? `, expiring ${expiresAt}` : ''}`);
|
|
2038
|
+
}
|
|
1898
2039
|
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
1899
2040
|
}
|
|
1900
2041
|
if (evaluation.expiredWaivers.length > 0) {
|
|
@@ -1909,6 +2050,65 @@ function cmdContractWaive(args) {
|
|
|
1909
2050
|
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
1910
2051
|
}
|
|
1911
2052
|
|
|
2053
|
+
// D-decision-event-log (D6): forward-only retraction of exactly one {code, subject} waiver --
|
|
2054
|
+
// mirrors `gate revoke`'s own precedent (the entry's absence IS the state, not a snapshot restore
|
|
2055
|
+
// of its prior reason/expiry, which stays recoverable only from the decision log). Re-evaluates
|
|
2056
|
+
// and re-sets the gate the same way `cmdContractWaive` does, since removing a waiver can turn a
|
|
2057
|
+
// passing gate back into an awaiting_disposition one.
|
|
2058
|
+
function cmdContractUnwaive(args) {
|
|
2059
|
+
const flags = parseCommand('contract unwaive', args);
|
|
2060
|
+
if (flags.help) { console.log(renderCommandHelp('contract unwaive')); process.exit(0); }
|
|
2061
|
+
setContext('contract unwaive', flags);
|
|
2062
|
+
const root = requireRepoRoot();
|
|
2063
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
2064
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel contract unwaive requires --reason "..." -- every withdrawal must be auditable');
|
|
2065
|
+
}
|
|
2066
|
+
const contract = loadContract(root, flags.feature);
|
|
2067
|
+
const key = warningKey({ code: flags.code, subject: flags.subject });
|
|
2068
|
+
|
|
2069
|
+
const updatedResolution = withLockSync(root, 'state', () => {
|
|
2070
|
+
const resolution = loadResolution(root, flags.feature);
|
|
2071
|
+
const match = (resolution.waivers ?? []).find((w) => warningKey(w) === key);
|
|
2072
|
+
if (!match) {
|
|
2073
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no waiver recorded for "${flags.code}" (${flags.subject}) on "${flags.feature}" -- nothing to unwaive`);
|
|
2074
|
+
}
|
|
2075
|
+
const at = new Date().toISOString();
|
|
2076
|
+
const next = {
|
|
2077
|
+
schema: 'sbf.contract-resolution/1',
|
|
2078
|
+
feature_id: flags.feature,
|
|
2079
|
+
waivers: (resolution.waivers ?? []).filter((w) => warningKey(w) !== key),
|
|
2080
|
+
};
|
|
2081
|
+
saveResolution(root, flags.feature, next);
|
|
2082
|
+
appendDecisionEvent(root, flags.feature, {
|
|
2083
|
+
kind: 'contract_waiver', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
2084
|
+
subject: { code: match.code, subject: match.subject ?? null }, expires_at: match.expires_at ?? null,
|
|
2085
|
+
});
|
|
2086
|
+
return next;
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
const evaluation = evaluateResolution(contract, updatedResolution);
|
|
2090
|
+
const evidence = {
|
|
2091
|
+
operation_count: contract.completeness.operation_count,
|
|
2092
|
+
endpoint_count: contract.completeness.endpoint_count,
|
|
2093
|
+
completeness: evaluation.status,
|
|
2094
|
+
warning_codes: countByCode(contract.warnings),
|
|
2095
|
+
waived_count: evaluation.waived.length,
|
|
2096
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
2097
|
+
expired_waivers: evaluation.expiredWaivers.length,
|
|
2098
|
+
};
|
|
2099
|
+
const gateState = evaluation.blocking
|
|
2100
|
+
? awaitNamedGateDisposition(root, 'contract', flags.feature, { ...evidence, unwaived: evaluation.unwaived.map(({ code, subject }) => ({ code, subject })) })
|
|
2101
|
+
: passNamedGate(root, 'contract', flags.feature, evidence);
|
|
2102
|
+
|
|
2103
|
+
if (flags.json) {
|
|
2104
|
+
console.log(JSON.stringify({ withdrawn: { code: flags.code, subject: flags.subject }, gate: gateState.gates.contract }, null, 2));
|
|
2105
|
+
} else {
|
|
2106
|
+
console.log(`unwaived: ${flags.code} (${flags.subject})`);
|
|
2107
|
+
console.log(`gate: contract -> ${gateState.gates.contract.status}`);
|
|
2108
|
+
}
|
|
2109
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
1912
2112
|
// D-dependency-propagation-notice: called from cmdContractEmit/cmdHandlesEmit to warn a SOURCE
|
|
1913
2113
|
// feature, at the moment its own generated artifacts are refreshed, that other features declared a
|
|
1914
2114
|
// dependency on one of its fields. Only surfaces a note when the dependent's OWN `dependencies` gate
|
|
@@ -2026,6 +2226,162 @@ function cmdDependencyList(args) {
|
|
|
2026
2226
|
process.exit(0);
|
|
2027
2227
|
}
|
|
2028
2228
|
|
|
2229
|
+
// D-cross-feature-impact-graph: `bskel impact check` -- read-mostly, never advances the baseline.
|
|
2230
|
+
// `--all` sweeps every active feature; this is the only thing that closes the `impact` gate's own
|
|
2231
|
+
// counterparty-narrowing limitation (a brand-new downstream feature is otherwise only caught at the
|
|
2232
|
+
// next explicit check, see D4/D7 in DECISIONS.md) -- the recommended CI invocation.
|
|
2233
|
+
function cmdImpactCheck(args) {
|
|
2234
|
+
const flags = parseCommand('impact check', args);
|
|
2235
|
+
if (flags.help) { console.log(renderCommandHelp('impact check')); process.exit(0); }
|
|
2236
|
+
setContext('impact check', flags);
|
|
2237
|
+
const root = requireRepoRoot();
|
|
2238
|
+
if (!flags.all && !flags.feature) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel impact check requires --feature <id> or --all');
|
|
2239
|
+
const featureIds = flags.all ? listFeatures(root).map((r) => r.feature_id) : [flags.feature];
|
|
2240
|
+
|
|
2241
|
+
const results = [];
|
|
2242
|
+
for (const featureId of featureIds) {
|
|
2243
|
+
let outcome;
|
|
2244
|
+
try {
|
|
2245
|
+
outcome = checkImpact(root, featureId);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2248
|
+
throw err;
|
|
2249
|
+
}
|
|
2250
|
+
results.push({ feature_id: featureId, ...outcome });
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
if (flags.json) {
|
|
2254
|
+
console.log(JSON.stringify(flags.all ? results : results[0], null, 2));
|
|
2255
|
+
} else {
|
|
2256
|
+
for (const r of results) {
|
|
2257
|
+
console.log(`impact check -- ${r.feature_id}`);
|
|
2258
|
+
console.log(` changes: ${r.report.changes.length}, outbound: ${r.report.outbound.length}, inbound: ${r.report.inbound.length}`);
|
|
2259
|
+
console.log(` gate: ${r.evaluation.blocking ? 'awaiting_disposition' : 'pass'}`);
|
|
2260
|
+
for (const o of r.evaluation.blockingOutbound) console.log(` BLOCKING: ${o.change_key} -> ${o.downstream_feature} (${o.via}, ${o.confidence})`);
|
|
2261
|
+
for (const i of r.evaluation.blockingInbound) console.log(` BLOCKING (inbound): ${i.change_key} from ${i.upstream_feature}, unacknowledged`);
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
const anyBlocking = results.some((r) => r.evaluation.blocking);
|
|
2265
|
+
process.exit(anyBlocking ? EXIT_CODES.AWAITING_DISPOSITION : EXIT.PASS);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// D-cross-feature-impact-graph: the DECIDE half -- refuses (exit 3) if anything proven is
|
|
2269
|
+
// undisposed, otherwise atomically rewrites impact-baseline.json from the CURRENT surface.
|
|
2270
|
+
function cmdImpactAccept(args) {
|
|
2271
|
+
const flags = parseCommand('impact accept', args);
|
|
2272
|
+
if (flags.help) { console.log(renderCommandHelp('impact accept')); process.exit(0); }
|
|
2273
|
+
setContext('impact accept', flags);
|
|
2274
|
+
const root = requireRepoRoot();
|
|
2275
|
+
let result;
|
|
2276
|
+
try {
|
|
2277
|
+
result = acceptImpact(root, flags.feature);
|
|
2278
|
+
} catch (err) {
|
|
2279
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2280
|
+
throw err;
|
|
2281
|
+
}
|
|
2282
|
+
const gateState = passNamedGate(root, 'impact', flags.feature);
|
|
2283
|
+
if (flags.json) {
|
|
2284
|
+
console.log(JSON.stringify({ ...result, gate: gateState.gates.impact }, null, 2));
|
|
2285
|
+
} else {
|
|
2286
|
+
console.log(`accepted: ${flags.feature} -- baseline captured at ${result.baseline.captured_at}`);
|
|
2287
|
+
console.log(`gate: impact -> ${gateState.gates.impact.status}`);
|
|
2288
|
+
}
|
|
2289
|
+
process.exit(EXIT.PASS);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// D-decision-event-log (D6): --withdraw is a flag variant of this same command, not a separate
|
|
2293
|
+
// subcommand -- withdrawal needs no --mode (there is nothing left to mode-classify once the
|
|
2294
|
+
// disposition is gone), so branching inside cmdImpactDisposition keeps the option set honest
|
|
2295
|
+
// rather than requiring --mode on a withdrawal that doesn't use it.
|
|
2296
|
+
function cmdImpactDisposition(args) {
|
|
2297
|
+
const flags = parseCommand('impact disposition', args);
|
|
2298
|
+
if (flags.help) { console.log(renderCommandHelp('impact disposition')); process.exit(0); }
|
|
2299
|
+
setContext('impact disposition', flags);
|
|
2300
|
+
const root = requireRepoRoot();
|
|
2301
|
+
if (flags.withdraw) {
|
|
2302
|
+
let result;
|
|
2303
|
+
try {
|
|
2304
|
+
result = withdrawDisposition(root, { feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream, reason: flags.reason });
|
|
2305
|
+
} catch (err) {
|
|
2306
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2307
|
+
throw err;
|
|
2308
|
+
}
|
|
2309
|
+
if (flags.json) {
|
|
2310
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2311
|
+
} else {
|
|
2312
|
+
console.log(`disposition withdrawn: ${flags.change} -> ${flags.downstream}`);
|
|
2313
|
+
}
|
|
2314
|
+
process.exit(EXIT.PASS);
|
|
2315
|
+
}
|
|
2316
|
+
let result;
|
|
2317
|
+
try {
|
|
2318
|
+
result = recordDisposition(root, {
|
|
2319
|
+
feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream,
|
|
2320
|
+
mode: flags.mode, reason: flags.reason, trackedBy: flags['tracked-by'],
|
|
2321
|
+
expiresDays: flags['expires-days'] != null ? Number(flags['expires-days']) : null,
|
|
2322
|
+
});
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2325
|
+
throw err;
|
|
2326
|
+
}
|
|
2327
|
+
if (flags.json) {
|
|
2328
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2329
|
+
} else {
|
|
2330
|
+
console.log(`disposition recorded: ${flags.change} -> ${flags.downstream} (${flags.mode})`);
|
|
2331
|
+
}
|
|
2332
|
+
process.exit(EXIT.PASS);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function cmdImpactAck(args) {
|
|
2336
|
+
const flags = parseCommand('impact ack', args);
|
|
2337
|
+
if (flags.help) { console.log(renderCommandHelp('impact ack')); process.exit(0); }
|
|
2338
|
+
setContext('impact ack', flags);
|
|
2339
|
+
const root = requireRepoRoot();
|
|
2340
|
+
let result;
|
|
2341
|
+
try {
|
|
2342
|
+
result = acknowledgeInbound(root, { feature: flags.feature, from: flags.from, changeKey: flags.change, reason: flags.reason });
|
|
2343
|
+
} catch (err) {
|
|
2344
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2345
|
+
throw err;
|
|
2346
|
+
}
|
|
2347
|
+
if (flags.json) {
|
|
2348
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2349
|
+
} else {
|
|
2350
|
+
console.log(`acknowledged: ${flags.change} from ${flags.from}`);
|
|
2351
|
+
}
|
|
2352
|
+
process.exit(EXIT.PASS);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
// D-cross-feature-impact-graph (IG8/IG9): the one seam to the LLM-driven exploration layer -- writes
|
|
2356
|
+
// data only, spawns nothing, never touches a gate. `--format graphify` writes graphify's own native
|
|
2357
|
+
// extraction shape directly (see lib/impact-export-graphify.mjs's header for why this bypasses its
|
|
2358
|
+
// own Steps 1-3 entirely).
|
|
2359
|
+
function cmdImpactExport(args) {
|
|
2360
|
+
const flags = parseCommand('impact export', args);
|
|
2361
|
+
if (flags.help) { console.log(renderCommandHelp('impact export')); process.exit(0); }
|
|
2362
|
+
setContext('impact export', flags);
|
|
2363
|
+
const root = requireRepoRoot();
|
|
2364
|
+
if (!['graphify', 'json', 'mermaid'].includes(flags.format)) {
|
|
2365
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--format must be one of graphify|json|mermaid (got "${flags.format}")`);
|
|
2366
|
+
}
|
|
2367
|
+
const graph = buildImpactGraph(root);
|
|
2368
|
+
const rings = flags.rings != null ? Number(flags.rings) : null;
|
|
2369
|
+
if (rings != null && !flags.focus) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--rings only has an effect together with --focus');
|
|
2370
|
+
|
|
2371
|
+
let output;
|
|
2372
|
+
if (flags.format === 'json') output = `${JSON.stringify(graph, null, 2)}\n`;
|
|
2373
|
+
else if (flags.format === 'mermaid') output = `${toMermaid(graph)}\n`;
|
|
2374
|
+
else output = `${JSON.stringify(toGraphifyExtraction(graph, { focus: flags.focus, rings }), null, 2)}\n`;
|
|
2375
|
+
|
|
2376
|
+
if (flags.out) {
|
|
2377
|
+
writeFileAtomic(flags.out, output);
|
|
2378
|
+
console.log(`wrote ${flags.out}`);
|
|
2379
|
+
} else {
|
|
2380
|
+
process.stdout.write(output);
|
|
2381
|
+
}
|
|
2382
|
+
process.exit(EXIT.PASS);
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2029
2385
|
// D-business-rules (R6): compiles specs/<id>/rules.yaml (optional) plus the feature's own contract
|
|
2030
2386
|
// into specs/<id>/rules/<id>.rules.json, and establishes the `rules` gate.
|
|
2031
2387
|
//
|
|
@@ -2719,6 +3075,15 @@ function renderHandlesPlan(plan, actions) {
|
|
|
2719
3075
|
lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
|
|
2720
3076
|
lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
|
|
2721
3077
|
if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
|
|
3078
|
+
// D-resolver-policy-contract (PC2): one line per policies[] record -- java-spring only
|
|
3079
|
+
// (the field is absent on python-fastapi/typescript-express plans).
|
|
3080
|
+
for (const p of r.policies ?? []) {
|
|
3081
|
+
if (p.status === 'materialized') {
|
|
3082
|
+
lines.push(`- policy[${p.action}]: materialized (${p.mode}) ${p.authority} -- ${p.evidence.kind}${p.evidence.file ? ` @ ${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : ''}`);
|
|
3083
|
+
} else {
|
|
3084
|
+
lines.push(`- policy[${p.action}]: UNRESOLVED (${p.mode}) -- ${p.evidence.kind}${p.evidence.file ? ` @ ${p.evidence.file}${p.evidence.line ? `:${p.evidence.line}` : ''}` : ''} -- ${p.reason}`);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
2722
3087
|
lines.push('');
|
|
2723
3088
|
}
|
|
2724
3089
|
if (plan.notes.length > 0) {
|
|
@@ -2959,7 +3324,7 @@ function cmdHandlesEmit(args) {
|
|
|
2959
3324
|
// a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
|
|
2960
3325
|
// does, without requiring both flags together.
|
|
2961
3326
|
const dryRun = flags.check || flags.diff;
|
|
2962
|
-
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [] } = provider.emit({
|
|
3327
|
+
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [], unresolvedPolicies = [] } = provider.emit({
|
|
2963
3328
|
repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
|
|
2964
3329
|
});
|
|
2965
3330
|
|
|
@@ -3039,6 +3404,24 @@ function cmdHandlesEmit(args) {
|
|
|
3039
3404
|
process.exit(EXIT_CODES.HANDLES_REGISTRATION_GAP);
|
|
3040
3405
|
}
|
|
3041
3406
|
|
|
3407
|
+
// D-resolver-policy-contract (PC9): unconditional -- NOT gated on enforceRegistry (orthogonal
|
|
3408
|
+
// axis: that one is about revocation/lifecycle and produces 404s; this one is about
|
|
3409
|
+
// authorization and produces 403s/boot failures). Same acknowledgement mechanism as
|
|
3410
|
+
// registrationGaps above (--force --reason, no new flag) -- the AuthorizationPolicy.java
|
|
3411
|
+
// file(s) ARE still written either way; only the overall command's reported success, and the
|
|
3412
|
+
// `handles` gate passing, are gated on acknowledging the gap.
|
|
3413
|
+
if (unresolvedPolicies.length > 0 && !flags.force) {
|
|
3414
|
+
if (flags.json) {
|
|
3415
|
+
console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: true, gate: null, check: dryRun }, null, 2));
|
|
3416
|
+
} else {
|
|
3417
|
+
const verb = dryRun ? 'would refuse to report success' : 'refusing to report success';
|
|
3418
|
+
console.error(`${verb}: ${unresolvedPolicies.length} resource action(s) could not have their authorization safely auto-derived:`);
|
|
3419
|
+
for (const u of unresolvedPolicies) console.error(` ${u.resourceType} [${u.action}] (${u.kind}${u.file ? ` @ ${u.file}${u.line ? `:${u.line}` : ''}` : ''})\n ${u.note}`);
|
|
3420
|
+
if (!dryRun) console.error(`\nthe resolver/AuthorizationPolicy file(s) above were still written -- nothing about their content is wrong. Implement the generated AuthorizationPolicy interface(s) by hand and re-run, or acknowledge and proceed with: bskel handles emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''}${flags.resource ? ` --resource ${flags.resource}` : ''} --force --reason "..."`);
|
|
3421
|
+
}
|
|
3422
|
+
process.exit(EXIT_CODES.HANDLES_UNRESOLVED_POLICY);
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3042
3425
|
// D4: dryRun never marks the gate passed -- nothing real happened this run.
|
|
3043
3426
|
const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
|
|
3044
3427
|
|
|
@@ -3048,7 +3431,7 @@ function cmdHandlesEmit(args) {
|
|
|
3048
3431
|
postEmitNotes.push(...describeDownstreamImpact(root, flags.feature));
|
|
3049
3432
|
|
|
3050
3433
|
if (flags.json) {
|
|
3051
|
-
console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
|
|
3434
|
+
console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: false, gate: gateState?.gates.handles ?? null, check: dryRun, postEmitNotes }, null, 2));
|
|
3052
3435
|
} else if (!flags.quiet) {
|
|
3053
3436
|
console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
|
|
3054
3437
|
for (const w of written) console.log(` ${w}`);
|
|
@@ -3129,6 +3512,10 @@ function cmdHandlesPatchApprove(args) {
|
|
|
3129
3512
|
const withoutExisting = (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key);
|
|
3130
3513
|
const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: [...withoutExisting, entry] };
|
|
3131
3514
|
savePatchApprovals(root, flags.feature, next);
|
|
3515
|
+
appendDecisionEvent(root, flags.feature, {
|
|
3516
|
+
kind: 'patch_approval', action: 'record', at, reason: flags.reason, feature_id: flags.feature,
|
|
3517
|
+
subject: { resource: flags.resource, field: flags.field }, strategy: flags.strategy,
|
|
3518
|
+
});
|
|
3132
3519
|
return next;
|
|
3133
3520
|
});
|
|
3134
3521
|
|
|
@@ -3136,6 +3523,41 @@ function cmdHandlesPatchApprove(args) {
|
|
|
3136
3523
|
process.exit(0);
|
|
3137
3524
|
}
|
|
3138
3525
|
|
|
3526
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke` -- removes the
|
|
3527
|
+
// approval entry and appends a `withdraw` decision event. patch-approvals.json is not itself a
|
|
3528
|
+
// gate input (F3's fix is binding it into signed attestations, not making it a gate -- see
|
|
3529
|
+
// D-decision-event-log's own EXIT), so unlike the other three withdraw commands there is no gate
|
|
3530
|
+
// to re-evaluate here.
|
|
3531
|
+
function cmdHandlesPatchUnapprove(args) {
|
|
3532
|
+
const flags = parseCommand('handles patch unapprove', args);
|
|
3533
|
+
if (flags.help) { console.log(renderCommandHelp('handles patch unapprove')); process.exit(0); }
|
|
3534
|
+
setContext('handles patch unapprove', flags);
|
|
3535
|
+
const root = requireRepoRoot();
|
|
3536
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
3537
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch unapprove requires --reason "..." -- every withdrawal must be auditable');
|
|
3538
|
+
}
|
|
3539
|
+
const key = approvalKey(flags.resource, flags.field);
|
|
3540
|
+
|
|
3541
|
+
const updated = withLockSync(root, 'state', () => {
|
|
3542
|
+
const current = loadPatchApprovals(root, flags.feature);
|
|
3543
|
+
const match = (current.approvals ?? []).find((a) => approvalKey(a.resource, a.field) === key);
|
|
3544
|
+
if (!match) {
|
|
3545
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no approval recorded for "${flags.resource}.${flags.field}" on "${flags.feature}" -- nothing to unapprove`);
|
|
3546
|
+
}
|
|
3547
|
+
const at = new Date().toISOString();
|
|
3548
|
+
const next = { schema: 'sbf.patch-approvals/1', feature_id: flags.feature, approvals: (current.approvals ?? []).filter((a) => approvalKey(a.resource, a.field) !== key) };
|
|
3549
|
+
savePatchApprovals(root, flags.feature, next);
|
|
3550
|
+
appendDecisionEvent(root, flags.feature, {
|
|
3551
|
+
kind: 'patch_approval', action: 'withdraw', at, reason: flags.reason, feature_id: flags.feature,
|
|
3552
|
+
subject: { resource: flags.resource, field: flags.field }, strategy: match.strategy,
|
|
3553
|
+
});
|
|
3554
|
+
return next;
|
|
3555
|
+
});
|
|
3556
|
+
|
|
3557
|
+
console.log(flags.json ? JSON.stringify(updated, null, 2) : `unapproved: ${flags.resource}.${flags.field}`);
|
|
3558
|
+
process.exit(0);
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3139
3561
|
// D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
|
|
3140
3562
|
// specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
|
|
3141
3563
|
// file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
|
|
@@ -3164,7 +3586,7 @@ async function cmdPatchPropose(args) {
|
|
|
3164
3586
|
}
|
|
3165
3587
|
params = { choice: flags.choice, target: flags.target };
|
|
3166
3588
|
source = { choice: flags.choice };
|
|
3167
|
-
} else {
|
|
3589
|
+
} else if (kind === 'ddl-apply') {
|
|
3168
3590
|
if (!flags['database-url-env'] || !flags['sql-file']) {
|
|
3169
3591
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
|
|
3170
3592
|
}
|
|
@@ -3176,6 +3598,23 @@ async function cmdPatchPropose(args) {
|
|
|
3176
3598
|
}
|
|
3177
3599
|
params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
|
|
3178
3600
|
source = { database_url_env: flags['database-url-env'], schema: flags.schema };
|
|
3601
|
+
} else {
|
|
3602
|
+
// java-source-splice
|
|
3603
|
+
if (!flags['splice-file']) {
|
|
3604
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind java-source-splice requires --splice-file <path>');
|
|
3605
|
+
}
|
|
3606
|
+
let spliceDoc;
|
|
3607
|
+
try {
|
|
3608
|
+
spliceDoc = JSON.parse(fs.readFileSync(flags['splice-file'], 'utf8'));
|
|
3609
|
+
} catch (err) {
|
|
3610
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --splice-file "${flags['splice-file']}": ${err.message}`);
|
|
3611
|
+
}
|
|
3612
|
+
const { ok: spliceOk, errors: spliceErrors } = validateAgainstSchema('java-source-splice.schema.json', spliceDoc);
|
|
3613
|
+
if (!spliceOk) {
|
|
3614
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags['splice-file']}" does not match schemas/java-source-splice.schema.json:\n${formatSchemaErrors(spliceErrors).join('\n')}`);
|
|
3615
|
+
}
|
|
3616
|
+
params = { file: spliceDoc.file, edits: spliceDoc.edits };
|
|
3617
|
+
source = { request_file: flags['splice-file'] };
|
|
3179
3618
|
}
|
|
3180
3619
|
|
|
3181
3620
|
let plan;
|
|
@@ -3202,6 +3641,12 @@ function describePatchTransaction(txn) {
|
|
|
3202
3641
|
if (txn.kind === 'config-apply') {
|
|
3203
3642
|
return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
|
|
3204
3643
|
}
|
|
3644
|
+
if (txn.kind === 'java-source-splice') {
|
|
3645
|
+
const members = txn.target.edits
|
|
3646
|
+
.map((e) => (e.locator ? `${e.op}:${e.locator.member_name}` : `add-import:${e.imports?.[0]}`))
|
|
3647
|
+
.join(', ');
|
|
3648
|
+
return `[java-source-splice] ${txn.target.file}: ${members}`;
|
|
3649
|
+
}
|
|
3205
3650
|
const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
|
|
3206
3651
|
return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
|
|
3207
3652
|
}
|
|
@@ -3231,12 +3676,33 @@ async function cmdPatchApprove(args) {
|
|
|
3231
3676
|
} catch (err) {
|
|
3232
3677
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
3233
3678
|
}
|
|
3234
|
-
|
|
3679
|
+
let updated;
|
|
3680
|
+
try {
|
|
3681
|
+
updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
|
|
3682
|
+
} catch (err) {
|
|
3683
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
|
|
3684
|
+
}
|
|
3235
3685
|
|
|
3236
3686
|
console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
|
|
3237
3687
|
process.exit(0);
|
|
3238
3688
|
}
|
|
3239
3689
|
|
|
3690
|
+
// D-java-source-splice: enriches a StaleTransactionError with the kind's own optional
|
|
3691
|
+
// describeStaleness() hook (only java-source-splice defines one today) -- never changes WHETHER
|
|
3692
|
+
// the transaction is rejected, only the diagnostic text. Checked by `.name`, not `instanceof`
|
|
3693
|
+
// (StaleTransactionError is intentionally not exported from lib/patch-transactions.mjs -- this is
|
|
3694
|
+
// its only consumer, and importing the class just to narrow a catch is unnecessary coupling).
|
|
3695
|
+
function describeStaleTransactionError(err, root, txn, freshPlan) {
|
|
3696
|
+
if (err.name !== 'StaleTransactionError') return err.message;
|
|
3697
|
+
const describe = getPatchKind(txn.kind).describeStaleness;
|
|
3698
|
+
if (!describe) return err.message;
|
|
3699
|
+
try {
|
|
3700
|
+
return describe(root, txn, freshPlan);
|
|
3701
|
+
} catch {
|
|
3702
|
+
return err.message;
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3240
3706
|
// D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
|
|
3241
3707
|
// than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
|
|
3242
3708
|
// is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
|
|
@@ -3262,7 +3728,12 @@ async function cmdPatchApply(args) {
|
|
|
3262
3728
|
} catch (err) {
|
|
3263
3729
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
3264
3730
|
}
|
|
3265
|
-
|
|
3731
|
+
let updated;
|
|
3732
|
+
try {
|
|
3733
|
+
updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
|
|
3734
|
+
} catch (err) {
|
|
3735
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
|
|
3736
|
+
}
|
|
3266
3737
|
|
|
3267
3738
|
const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
|
|
3268
3739
|
const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
|
|
@@ -4415,6 +4886,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4415
4886
|
if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
|
|
4416
4887
|
if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
|
|
4417
4888
|
if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
|
|
4889
|
+
if (rest[0] === 'cross-feature-unwaive') return cmdScanCrossFeatureUnwaive(rest.slice(1));
|
|
4418
4890
|
await cmdScan(rest);
|
|
4419
4891
|
break;
|
|
4420
4892
|
}
|
|
@@ -4439,6 +4911,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4439
4911
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
4440
4912
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
4441
4913
|
if (sub === 'waive') return cmdContractWaive(subArgs);
|
|
4914
|
+
if (sub === 'unwaive') return cmdContractUnwaive(subArgs);
|
|
4442
4915
|
usage();
|
|
4443
4916
|
process.exit(14);
|
|
4444
4917
|
break;
|
|
@@ -4453,6 +4926,18 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4453
4926
|
process.exit(14);
|
|
4454
4927
|
break;
|
|
4455
4928
|
}
|
|
4929
|
+
case 'impact': {
|
|
4930
|
+
const sub = rest[0];
|
|
4931
|
+
const subArgs = rest.slice(1);
|
|
4932
|
+
if (sub === 'check') return cmdImpactCheck(subArgs);
|
|
4933
|
+
if (sub === 'accept') return cmdImpactAccept(subArgs);
|
|
4934
|
+
if (sub === 'disposition') return cmdImpactDisposition(subArgs);
|
|
4935
|
+
if (sub === 'ack') return cmdImpactAck(subArgs);
|
|
4936
|
+
if (sub === 'export') return cmdImpactExport(subArgs);
|
|
4937
|
+
usage();
|
|
4938
|
+
process.exit(14);
|
|
4939
|
+
break;
|
|
4940
|
+
}
|
|
4456
4941
|
case 'rules': {
|
|
4457
4942
|
const sub = rest[0];
|
|
4458
4943
|
const subArgs = rest.slice(1);
|
|
@@ -4490,6 +4975,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4490
4975
|
if (rest[0] === 'plan') return cmdHandlesPlan(rest.slice(1));
|
|
4491
4976
|
if (rest[0] === 'emit') return cmdHandlesEmit(rest.slice(1));
|
|
4492
4977
|
if (rest[0] === 'patch' && rest[1] === 'approve') return cmdHandlesPatchApprove(rest.slice(2));
|
|
4978
|
+
if (rest[0] === 'patch' && rest[1] === 'unapprove') return cmdHandlesPatchUnapprove(rest.slice(2));
|
|
4493
4979
|
if (rest[0] === 'audit') return await cmdHandlesAudit(rest.slice(1));
|
|
4494
4980
|
usage();
|
|
4495
4981
|
process.exit(14);
|