backend-skeleton 1.4.0 → 1.6.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 +331 -53
- package/contracts/emit.mjs +22 -6
- 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 +244 -77
- 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/handles/providers/typescript-express/plan.mjs +15 -2
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +79 -7
- 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 +199 -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 +286 -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 +55 -0
- package/scanners/adapters/java-spring.mjs +154 -3
- package/scanners/index.mjs +10 -0
- package/schemas/feature-contract.schema.json +12 -1
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +530 -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';
|
|
@@ -33,13 +34,18 @@ import { evaluateResolution, loadResolution, saveResolution, requireWarningCode,
|
|
|
33
34
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
34
35
|
import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
|
|
35
36
|
import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
|
|
36
|
-
import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
|
|
37
|
+
import { generateKeypair, signPayload, verifyPayload, publicKeyIdFromPrivate, publicKeyIdFromPublic } from '../lib/attest.mjs';
|
|
37
38
|
import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
|
|
38
39
|
import { createHttpServer } from '../lib/http-server.mjs';
|
|
39
40
|
import {
|
|
40
41
|
resolveClassFile, listDownstreamDependents, DependencyOperationError,
|
|
41
42
|
declareDependency, removeDependency, buildDependencyListReport,
|
|
42
43
|
} from '../lib/field-dependencies.mjs';
|
|
44
|
+
import {
|
|
45
|
+
ImpactOperationError, checkImpact, acceptImpact, recordDisposition, acknowledgeInbound,
|
|
46
|
+
} from '../lib/impact.mjs';
|
|
47
|
+
import { buildImpactGraph } from '../lib/impact-graph.mjs';
|
|
48
|
+
import { toGraphifyExtraction, toMermaid } from '../lib/impact-export-graphify.mjs';
|
|
43
49
|
import {
|
|
44
50
|
findCollisions, evaluateCrossFeatureFindings, waiverKey,
|
|
45
51
|
crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
|
|
@@ -114,6 +120,11 @@ function usage() {
|
|
|
114
120
|
bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
|
|
115
121
|
bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
|
|
116
122
|
bskel dependency list --feature <id> [--json]
|
|
123
|
+
bskel impact check --feature <id> [--all] [--json]
|
|
124
|
+
bskel impact accept --feature <id> [--json]
|
|
125
|
+
bskel impact disposition --feature <id> --change <change_key> --downstream <id> --mode <compatible|migrate|waive> --reason "..." [--tracked-by "..."] [--expires-days <N>] [--json]
|
|
126
|
+
bskel impact ack --feature <id> --from <id> --change <change_key> --reason "..." [--json]
|
|
127
|
+
bskel impact export --format <graphify|json|mermaid> [--out <path>] [--focus <id>] [--rings <N>]
|
|
117
128
|
bskel rules check --feature <id> [--init] [--json]
|
|
118
129
|
bskel rules list --feature <id> [--json]
|
|
119
130
|
bskel rules explain --feature <id> --rule <id> [--json]
|
|
@@ -124,7 +135,7 @@ function usage() {
|
|
|
124
135
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
125
136
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
126
137
|
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]
|
|
138
|
+
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
139
|
bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
|
|
129
140
|
bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
|
|
130
141
|
bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
|
|
@@ -139,9 +150,9 @@ function usage() {
|
|
|
139
150
|
bskel gate revoke <name> --reason "..." [--feature <id>]
|
|
140
151
|
bskel gate history <name> [--feature <id>] [--json]
|
|
141
152
|
bskel gate show [<name>] [--feature <id>]
|
|
142
|
-
bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
|
|
153
|
+
bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath> [--allow-dirty]] [--json]
|
|
143
154
|
bskel attest keygen --out <dir> [--force] [--json]
|
|
144
|
-
bskel attest verify --file <path> --pubkey <path> [--json]
|
|
155
|
+
bskel attest verify --file <path> --pubkey <path> [--expect-head <sha>] [--max-age-minutes N] [--reject-dirty] [--json]
|
|
145
156
|
bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
|
|
146
157
|
bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
|
|
147
158
|
`);
|
|
@@ -352,32 +363,6 @@ function cmdGateRevoke(args) {
|
|
|
352
363
|
process.exit(EXIT_CODES.NOT_PASSED);
|
|
353
364
|
}
|
|
354
365
|
|
|
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
366
|
function cmdGateHistory(args) {
|
|
382
367
|
const flags = parseCommand('gate history', args);
|
|
383
368
|
if (flags.help) { console.log(renderCommandHelp('gate history')); process.exit(0); }
|
|
@@ -386,7 +371,11 @@ function cmdGateHistory(args) {
|
|
|
386
371
|
const gateName = flags._[0];
|
|
387
372
|
if (!gateName) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel gate history <name> [--feature <id>] [--json]');
|
|
388
373
|
resolveGateArg(gateName, flags.feature);
|
|
389
|
-
|
|
374
|
+
// S4 (D-gate-history): a corrupt/invalid history line is skipped with a warning, not a hard
|
|
375
|
+
// failure, matching JSONL's own resilience rationale (see lib/state.mjs's appendGateEvent).
|
|
376
|
+
const events = readGateHistory(root, flags.feature, gateName, {
|
|
377
|
+
onWarning: (msg, errors) => console.error(`warning: ${msg}${errors ? `:\n${formatSchemaErrors(errors).join('\n')}` : ''}`),
|
|
378
|
+
});
|
|
390
379
|
if (flags.json) {
|
|
391
380
|
console.log(JSON.stringify(events, null, 2));
|
|
392
381
|
} else if (events.length === 0) {
|
|
@@ -439,20 +428,15 @@ function cmdGateExport(args) {
|
|
|
439
428
|
if (flags.key && !flags.sign) {
|
|
440
429
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
|
|
441
430
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
431
|
+
// D-attestation-payload-completeness (K4): --allow-dirty only means something alongside --sign
|
|
432
|
+
// -- an unsigned export never refuses a dirty tree (unchanged behavior), so a bare --allow-dirty
|
|
433
|
+
// would otherwise be a silently-ignored flag, the exact failure mode D-cli-contract already
|
|
434
|
+
// refuses everywhere else.
|
|
435
|
+
if (flags['allow-dirty'] && !flags.sign) {
|
|
436
|
+
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
437
|
}
|
|
448
438
|
|
|
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
|
-
};
|
|
439
|
+
const report = buildGateExportReport(root, flags.feature, { dirtyAcknowledged: Boolean(flags['allow-dirty']) });
|
|
456
440
|
// D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
|
|
457
441
|
// be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
|
|
458
442
|
{
|
|
@@ -464,6 +448,20 @@ function cmdGateExport(args) {
|
|
|
464
448
|
|
|
465
449
|
let payload = report;
|
|
466
450
|
if (flags.sign) {
|
|
451
|
+
// D-attestation-payload-completeness (K4): refuses to sign over a dirty working tree unless
|
|
452
|
+
// explicitly acknowledged -- direct reuse of scripts/preflight-base-ref.sh's own already-
|
|
453
|
+
// shipped --allow-dirty convention (same flag name, same DIRTY exit code, same reasoning: "so
|
|
454
|
+
// evidence can honestly distinguish 'clean tree, passed' from 'dirty tree, --allow-dirty
|
|
455
|
+
// overrode it'"). Signing is a strictly stronger claim than preflighting, so it cannot be
|
|
456
|
+
// laxer. `report.git.dirty === true` is checked specifically, not truthiness -- isDirty()
|
|
457
|
+
// (and therefore this field) can be `null` when git itself failed, and "we could not
|
|
458
|
+
// determine dirtiness" must not silently pass this refusal.
|
|
459
|
+
if (report.git.dirty === null) {
|
|
460
|
+
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.');
|
|
461
|
+
}
|
|
462
|
+
if (report.git.dirty === true && !flags['allow-dirty']) {
|
|
463
|
+
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.`);
|
|
464
|
+
}
|
|
467
465
|
let privateKeyPem;
|
|
468
466
|
try {
|
|
469
467
|
privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
|
|
@@ -471,12 +469,14 @@ function cmdGateExport(args) {
|
|
|
471
469
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
|
|
472
470
|
}
|
|
473
471
|
let signatureValue;
|
|
472
|
+
let keyId;
|
|
474
473
|
try {
|
|
475
474
|
signatureValue = signPayload(report, privateKeyPem);
|
|
475
|
+
keyId = publicKeyIdFromPrivate(privateKeyPem);
|
|
476
476
|
} catch (err) {
|
|
477
477
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
|
|
478
478
|
}
|
|
479
|
-
const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
|
|
479
|
+
const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue, key_id: keyId } };
|
|
480
480
|
const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
|
|
481
481
|
if (!ok) {
|
|
482
482
|
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
|
|
@@ -489,9 +489,11 @@ function cmdGateExport(args) {
|
|
|
489
489
|
const outPath = path.resolve(process.cwd(), flags.out);
|
|
490
490
|
writeFileAtomic(outPath, rendered);
|
|
491
491
|
if (!flags.quiet) {
|
|
492
|
-
const passCount =
|
|
492
|
+
const passCount = report.verdict.passing;
|
|
493
493
|
const signedNote = flags.sign ? ' (signed)' : '';
|
|
494
|
-
|
|
494
|
+
const dirtyNote = report.git.dirty ? (report.git.dirty_acknowledged ? ', dirty (acknowledged)' : ', dirty') : '';
|
|
495
|
+
const blockingNote = report.verdict.blocking_gates.length > 0 ? ` -- blocking: ${report.verdict.blocking_gates.join(', ')}` : '';
|
|
496
|
+
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
497
|
}
|
|
496
498
|
} else {
|
|
497
499
|
console.log(rendered);
|
|
@@ -562,13 +564,64 @@ function cmdAttestVerify(args) {
|
|
|
562
564
|
const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
|
|
563
565
|
const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
|
|
564
566
|
|
|
567
|
+
// D-attestation-payload-completeness (K5): three OPT-IN, default-off assertions, evaluated
|
|
568
|
+
// purely against fields already inside the report (so verification stays fully offline and
|
|
569
|
+
// repo-independent). They exist for both sbf.gate-export/1 and /2 reports -- head_sha/
|
|
570
|
+
// generated_at/dirty are unchanged fields from /1, nothing here needed a /2-only field.
|
|
571
|
+
const assertions = [];
|
|
572
|
+
if (flags['expect-head']) {
|
|
573
|
+
const actual = attestation.report.git?.head_sha ?? null;
|
|
574
|
+
const ok = actual === flags['expect-head'];
|
|
575
|
+
assertions.push({ name: 'expect-head', ok, detail: ok ? `head_sha matches ${flags['expect-head']}` : `report's head_sha is "${actual}", expected "${flags['expect-head']}"` });
|
|
576
|
+
}
|
|
577
|
+
const maxAgeMinutes = flags['max-age-minutes'] != null ? Number(flags['max-age-minutes']) : 0;
|
|
578
|
+
if (maxAgeMinutes > 0) {
|
|
579
|
+
const generatedAtMs = Date.parse(attestation.report.generated_at);
|
|
580
|
+
const ageSeconds = Number.isFinite(generatedAtMs) ? Math.max(0, Math.round((Date.now() - generatedAtMs) / 1000)) : null;
|
|
581
|
+
const ok = ageSeconds !== null && ageSeconds <= maxAgeMinutes * 60;
|
|
582
|
+
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` });
|
|
583
|
+
}
|
|
584
|
+
if (flags['reject-dirty']) {
|
|
585
|
+
const dirty = attestation.report.git?.dirty ?? null;
|
|
586
|
+
const ok = dirty !== true;
|
|
587
|
+
assertions.push({ name: 'reject-dirty', ok, detail: ok ? 'report is not dirty' : 'report.git.dirty === true' });
|
|
588
|
+
}
|
|
589
|
+
const assertionsOk = assertions.every((a) => a.ok);
|
|
590
|
+
|
|
591
|
+
// D-attestation-payload-completeness (K6): key_id is a SELECTION HINT only -- it sits OUTSIDE
|
|
592
|
+
// the signed bytes (only `report` is signed), so it cannot change whether `valid` is true. It
|
|
593
|
+
// exists purely to make a wrong---pubkey mistake legible instead of an unexplained INVALID.
|
|
594
|
+
let keyIdNote = null;
|
|
595
|
+
if (attestation.signature.key_id) {
|
|
596
|
+
try {
|
|
597
|
+
const actualKeyId = publicKeyIdFromPublic(publicKeyPem);
|
|
598
|
+
if (attestation.signature.key_id !== actualKeyId) {
|
|
599
|
+
keyIdNote = `this attestation declares key_id ${attestation.signature.key_id}, but --pubkey is ${actualKeyId} -- you may have the wrong public key`;
|
|
600
|
+
}
|
|
601
|
+
} catch {
|
|
602
|
+
// A --pubkey that isn't even a parseable key already drives verifyPayload() to false
|
|
603
|
+
// above; nothing more useful to say here.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const legacyReport = attestation.report.schema === 'sbf.gate-export/1';
|
|
607
|
+
|
|
565
608
|
if (flags.json) {
|
|
566
|
-
console.log(JSON.stringify({
|
|
609
|
+
console.log(JSON.stringify({
|
|
610
|
+
valid,
|
|
611
|
+
report_format: attestation.report.schema ?? 'sbf.gate-export/1',
|
|
612
|
+
report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` },
|
|
613
|
+
assertions,
|
|
614
|
+
key_id_note: keyIdNote,
|
|
615
|
+
}, null, 2));
|
|
567
616
|
} else if (!flags.quiet) {
|
|
568
617
|
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');
|
|
618
|
+
if (legacyReport) console.log('report format: sbf.gate-export/1 (pre-D-attestation-payload-completeness -- no tool version, no live gate verdict)');
|
|
619
|
+
if (keyIdNote) console.log(`note: ${keyIdNote}`);
|
|
569
620
|
console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
|
|
621
|
+
for (const a of assertions) console.log(`assertion ${a.name}: ${a.ok ? 'ok' : 'FAILED'} -- ${a.detail}`);
|
|
570
622
|
}
|
|
571
|
-
process.exit(
|
|
623
|
+
if (!valid) process.exit(EXIT_CODES.CHECK_FAILED);
|
|
624
|
+
process.exit(assertionsOk ? EXIT_CODES.OK : EXIT_CODES.ATTESTATION_ASSERTION_FAILED);
|
|
572
625
|
}
|
|
573
626
|
|
|
574
627
|
// Structural enforcement of "preflight blocks everything below it" (see the workflow table in
|
|
@@ -2026,6 +2079,143 @@ function cmdDependencyList(args) {
|
|
|
2026
2079
|
process.exit(0);
|
|
2027
2080
|
}
|
|
2028
2081
|
|
|
2082
|
+
// D-cross-feature-impact-graph: `bskel impact check` -- read-mostly, never advances the baseline.
|
|
2083
|
+
// `--all` sweeps every active feature; this is the only thing that closes the `impact` gate's own
|
|
2084
|
+
// counterparty-narrowing limitation (a brand-new downstream feature is otherwise only caught at the
|
|
2085
|
+
// next explicit check, see D4/D7 in DECISIONS.md) -- the recommended CI invocation.
|
|
2086
|
+
function cmdImpactCheck(args) {
|
|
2087
|
+
const flags = parseCommand('impact check', args);
|
|
2088
|
+
if (flags.help) { console.log(renderCommandHelp('impact check')); process.exit(0); }
|
|
2089
|
+
setContext('impact check', flags);
|
|
2090
|
+
const root = requireRepoRoot();
|
|
2091
|
+
if (!flags.all && !flags.feature) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel impact check requires --feature <id> or --all');
|
|
2092
|
+
const featureIds = flags.all ? listFeatures(root).map((r) => r.feature_id) : [flags.feature];
|
|
2093
|
+
|
|
2094
|
+
const results = [];
|
|
2095
|
+
for (const featureId of featureIds) {
|
|
2096
|
+
let outcome;
|
|
2097
|
+
try {
|
|
2098
|
+
outcome = checkImpact(root, featureId);
|
|
2099
|
+
} catch (err) {
|
|
2100
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2101
|
+
throw err;
|
|
2102
|
+
}
|
|
2103
|
+
results.push({ feature_id: featureId, ...outcome });
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
if (flags.json) {
|
|
2107
|
+
console.log(JSON.stringify(flags.all ? results : results[0], null, 2));
|
|
2108
|
+
} else {
|
|
2109
|
+
for (const r of results) {
|
|
2110
|
+
console.log(`impact check -- ${r.feature_id}`);
|
|
2111
|
+
console.log(` changes: ${r.report.changes.length}, outbound: ${r.report.outbound.length}, inbound: ${r.report.inbound.length}`);
|
|
2112
|
+
console.log(` gate: ${r.evaluation.blocking ? 'awaiting_disposition' : 'pass'}`);
|
|
2113
|
+
for (const o of r.evaluation.blockingOutbound) console.log(` BLOCKING: ${o.change_key} -> ${o.downstream_feature} (${o.via}, ${o.confidence})`);
|
|
2114
|
+
for (const i of r.evaluation.blockingInbound) console.log(` BLOCKING (inbound): ${i.change_key} from ${i.upstream_feature}, unacknowledged`);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
const anyBlocking = results.some((r) => r.evaluation.blocking);
|
|
2118
|
+
process.exit(anyBlocking ? EXIT_CODES.AWAITING_DISPOSITION : EXIT.PASS);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
// D-cross-feature-impact-graph: the DECIDE half -- refuses (exit 3) if anything proven is
|
|
2122
|
+
// undisposed, otherwise atomically rewrites impact-baseline.json from the CURRENT surface.
|
|
2123
|
+
function cmdImpactAccept(args) {
|
|
2124
|
+
const flags = parseCommand('impact accept', args);
|
|
2125
|
+
if (flags.help) { console.log(renderCommandHelp('impact accept')); process.exit(0); }
|
|
2126
|
+
setContext('impact accept', flags);
|
|
2127
|
+
const root = requireRepoRoot();
|
|
2128
|
+
let result;
|
|
2129
|
+
try {
|
|
2130
|
+
result = acceptImpact(root, flags.feature);
|
|
2131
|
+
} catch (err) {
|
|
2132
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2133
|
+
throw err;
|
|
2134
|
+
}
|
|
2135
|
+
const gateState = passNamedGate(root, 'impact', flags.feature);
|
|
2136
|
+
if (flags.json) {
|
|
2137
|
+
console.log(JSON.stringify({ ...result, gate: gateState.gates.impact }, null, 2));
|
|
2138
|
+
} else {
|
|
2139
|
+
console.log(`accepted: ${flags.feature} -- baseline captured at ${result.baseline.captured_at}`);
|
|
2140
|
+
console.log(`gate: impact -> ${gateState.gates.impact.status}`);
|
|
2141
|
+
}
|
|
2142
|
+
process.exit(EXIT.PASS);
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
function cmdImpactDisposition(args) {
|
|
2146
|
+
const flags = parseCommand('impact disposition', args);
|
|
2147
|
+
if (flags.help) { console.log(renderCommandHelp('impact disposition')); process.exit(0); }
|
|
2148
|
+
setContext('impact disposition', flags);
|
|
2149
|
+
const root = requireRepoRoot();
|
|
2150
|
+
let result;
|
|
2151
|
+
try {
|
|
2152
|
+
result = recordDisposition(root, {
|
|
2153
|
+
feature: flags.feature, changeKey: flags.change, downstreamFeature: flags.downstream,
|
|
2154
|
+
mode: flags.mode, reason: flags.reason, trackedBy: flags['tracked-by'],
|
|
2155
|
+
expiresDays: flags['expires-days'] != null ? Number(flags['expires-days']) : null,
|
|
2156
|
+
});
|
|
2157
|
+
} catch (err) {
|
|
2158
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2159
|
+
throw err;
|
|
2160
|
+
}
|
|
2161
|
+
if (flags.json) {
|
|
2162
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2163
|
+
} else {
|
|
2164
|
+
console.log(`disposition recorded: ${flags.change} -> ${flags.downstream} (${flags.mode})`);
|
|
2165
|
+
}
|
|
2166
|
+
process.exit(EXIT.PASS);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function cmdImpactAck(args) {
|
|
2170
|
+
const flags = parseCommand('impact ack', args);
|
|
2171
|
+
if (flags.help) { console.log(renderCommandHelp('impact ack')); process.exit(0); }
|
|
2172
|
+
setContext('impact ack', flags);
|
|
2173
|
+
const root = requireRepoRoot();
|
|
2174
|
+
let result;
|
|
2175
|
+
try {
|
|
2176
|
+
result = acknowledgeInbound(root, { feature: flags.feature, from: flags.from, changeKey: flags.change, reason: flags.reason });
|
|
2177
|
+
} catch (err) {
|
|
2178
|
+
if (err instanceof ImpactOperationError) fail(err.exitCode, err.reasonCode, err.message);
|
|
2179
|
+
throw err;
|
|
2180
|
+
}
|
|
2181
|
+
if (flags.json) {
|
|
2182
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2183
|
+
} else {
|
|
2184
|
+
console.log(`acknowledged: ${flags.change} from ${flags.from}`);
|
|
2185
|
+
}
|
|
2186
|
+
process.exit(EXIT.PASS);
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// D-cross-feature-impact-graph (IG8/IG9): the one seam to the LLM-driven exploration layer -- writes
|
|
2190
|
+
// data only, spawns nothing, never touches a gate. `--format graphify` writes graphify's own native
|
|
2191
|
+
// extraction shape directly (see lib/impact-export-graphify.mjs's header for why this bypasses its
|
|
2192
|
+
// own Steps 1-3 entirely).
|
|
2193
|
+
function cmdImpactExport(args) {
|
|
2194
|
+
const flags = parseCommand('impact export', args);
|
|
2195
|
+
if (flags.help) { console.log(renderCommandHelp('impact export')); process.exit(0); }
|
|
2196
|
+
setContext('impact export', flags);
|
|
2197
|
+
const root = requireRepoRoot();
|
|
2198
|
+
if (!['graphify', 'json', 'mermaid'].includes(flags.format)) {
|
|
2199
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--format must be one of graphify|json|mermaid (got "${flags.format}")`);
|
|
2200
|
+
}
|
|
2201
|
+
const graph = buildImpactGraph(root);
|
|
2202
|
+
const rings = flags.rings != null ? Number(flags.rings) : null;
|
|
2203
|
+
if (rings != null && !flags.focus) fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--rings only has an effect together with --focus');
|
|
2204
|
+
|
|
2205
|
+
let output;
|
|
2206
|
+
if (flags.format === 'json') output = `${JSON.stringify(graph, null, 2)}\n`;
|
|
2207
|
+
else if (flags.format === 'mermaid') output = `${toMermaid(graph)}\n`;
|
|
2208
|
+
else output = `${JSON.stringify(toGraphifyExtraction(graph, { focus: flags.focus, rings }), null, 2)}\n`;
|
|
2209
|
+
|
|
2210
|
+
if (flags.out) {
|
|
2211
|
+
writeFileAtomic(flags.out, output);
|
|
2212
|
+
console.log(`wrote ${flags.out}`);
|
|
2213
|
+
} else {
|
|
2214
|
+
process.stdout.write(output);
|
|
2215
|
+
}
|
|
2216
|
+
process.exit(EXIT.PASS);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2029
2219
|
// D-business-rules (R6): compiles specs/<id>/rules.yaml (optional) plus the feature's own contract
|
|
2030
2220
|
// into specs/<id>/rules/<id>.rules.json, and establishes the `rules` gate.
|
|
2031
2221
|
//
|
|
@@ -2719,6 +2909,15 @@ function renderHandlesPlan(plan, actions) {
|
|
|
2719
2909
|
lines.push(`- read via: ${r.readPath ?? '(not found)'}`);
|
|
2720
2910
|
lines.push(`- requiredAuthority (fetch/recover): ${r.requiredAuthority}`);
|
|
2721
2911
|
if (r.requiredAuthorityForPatch !== undefined) lines.push(`- requiredAuthorityForPatch: ${r.requiredAuthorityForPatch}`);
|
|
2912
|
+
// D-resolver-policy-contract (PC2): one line per policies[] record -- java-spring only
|
|
2913
|
+
// (the field is absent on python-fastapi/typescript-express plans).
|
|
2914
|
+
for (const p of r.policies ?? []) {
|
|
2915
|
+
if (p.status === 'materialized') {
|
|
2916
|
+
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}` : ''}` : ''}`);
|
|
2917
|
+
} else {
|
|
2918
|
+
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}`);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2722
2921
|
lines.push('');
|
|
2723
2922
|
}
|
|
2724
2923
|
if (plan.notes.length > 0) {
|
|
@@ -2959,7 +3158,7 @@ function cmdHandlesEmit(args) {
|
|
|
2959
3158
|
// a diff" that also means "and actually write it", so --diff forces dryRun the same as --check
|
|
2960
3159
|
// does, without requiring both flags together.
|
|
2961
3160
|
const dryRun = flags.check || flags.diff;
|
|
2962
|
-
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [] } = provider.emit({
|
|
3161
|
+
const { written, resolverStubs, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [], registrationGaps = [], unresolvedPolicies = [] } = provider.emit({
|
|
2963
3162
|
repoRoot: root, featureId: flags.feature, plan, resourceFilter, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff, enforceRegistry,
|
|
2964
3163
|
});
|
|
2965
3164
|
|
|
@@ -3039,6 +3238,24 @@ function cmdHandlesEmit(args) {
|
|
|
3039
3238
|
process.exit(EXIT_CODES.HANDLES_REGISTRATION_GAP);
|
|
3040
3239
|
}
|
|
3041
3240
|
|
|
3241
|
+
// D-resolver-policy-contract (PC9): unconditional -- NOT gated on enforceRegistry (orthogonal
|
|
3242
|
+
// axis: that one is about revocation/lifecycle and produces 404s; this one is about
|
|
3243
|
+
// authorization and produces 403s/boot failures). Same acknowledgement mechanism as
|
|
3244
|
+
// registrationGaps above (--force --reason, no new flag) -- the AuthorizationPolicy.java
|
|
3245
|
+
// file(s) ARE still written either way; only the overall command's reported success, and the
|
|
3246
|
+
// `handles` gate passing, are gated on acknowledging the gap.
|
|
3247
|
+
if (unresolvedPolicies.length > 0 && !flags.force) {
|
|
3248
|
+
if (flags.json) {
|
|
3249
|
+
console.log(JSON.stringify({ written, resolverStubs, conflicts, orphans, forced, notes: allNotes, actions, unresolvedPolicies, blocked: true, gate: null, check: dryRun }, null, 2));
|
|
3250
|
+
} else {
|
|
3251
|
+
const verb = dryRun ? 'would refuse to report success' : 'refusing to report success';
|
|
3252
|
+
console.error(`${verb}: ${unresolvedPolicies.length} resource action(s) could not have their authorization safely auto-derived:`);
|
|
3253
|
+
for (const u of unresolvedPolicies) console.error(` ${u.resourceType} [${u.action}] (${u.kind}${u.file ? ` @ ${u.file}${u.line ? `:${u.line}` : ''}` : ''})\n ${u.note}`);
|
|
3254
|
+
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 "..."`);
|
|
3255
|
+
}
|
|
3256
|
+
process.exit(EXIT_CODES.HANDLES_UNRESOLVED_POLICY);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3042
3259
|
// D4: dryRun never marks the gate passed -- nothing real happened this run.
|
|
3043
3260
|
const gateState = dryRun ? null : passNamedGate(root, 'handles', flags.feature, { resolverStubs });
|
|
3044
3261
|
|
|
@@ -3048,7 +3265,7 @@ function cmdHandlesEmit(args) {
|
|
|
3048
3265
|
postEmitNotes.push(...describeDownstreamImpact(root, flags.feature));
|
|
3049
3266
|
|
|
3050
3267
|
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));
|
|
3268
|
+
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
3269
|
} else if (!flags.quiet) {
|
|
3053
3270
|
console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
|
|
3054
3271
|
for (const w of written) console.log(` ${w}`);
|
|
@@ -3164,7 +3381,7 @@ async function cmdPatchPropose(args) {
|
|
|
3164
3381
|
}
|
|
3165
3382
|
params = { choice: flags.choice, target: flags.target };
|
|
3166
3383
|
source = { choice: flags.choice };
|
|
3167
|
-
} else {
|
|
3384
|
+
} else if (kind === 'ddl-apply') {
|
|
3168
3385
|
if (!flags['database-url-env'] || !flags['sql-file']) {
|
|
3169
3386
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
|
|
3170
3387
|
}
|
|
@@ -3176,6 +3393,23 @@ async function cmdPatchPropose(args) {
|
|
|
3176
3393
|
}
|
|
3177
3394
|
params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
|
|
3178
3395
|
source = { database_url_env: flags['database-url-env'], schema: flags.schema };
|
|
3396
|
+
} else {
|
|
3397
|
+
// java-source-splice
|
|
3398
|
+
if (!flags['splice-file']) {
|
|
3399
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind java-source-splice requires --splice-file <path>');
|
|
3400
|
+
}
|
|
3401
|
+
let spliceDoc;
|
|
3402
|
+
try {
|
|
3403
|
+
spliceDoc = JSON.parse(fs.readFileSync(flags['splice-file'], 'utf8'));
|
|
3404
|
+
} catch (err) {
|
|
3405
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --splice-file "${flags['splice-file']}": ${err.message}`);
|
|
3406
|
+
}
|
|
3407
|
+
const { ok: spliceOk, errors: spliceErrors } = validateAgainstSchema('java-source-splice.schema.json', spliceDoc);
|
|
3408
|
+
if (!spliceOk) {
|
|
3409
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags['splice-file']}" does not match schemas/java-source-splice.schema.json:\n${formatSchemaErrors(spliceErrors).join('\n')}`);
|
|
3410
|
+
}
|
|
3411
|
+
params = { file: spliceDoc.file, edits: spliceDoc.edits };
|
|
3412
|
+
source = { request_file: flags['splice-file'] };
|
|
3179
3413
|
}
|
|
3180
3414
|
|
|
3181
3415
|
let plan;
|
|
@@ -3202,6 +3436,12 @@ function describePatchTransaction(txn) {
|
|
|
3202
3436
|
if (txn.kind === 'config-apply') {
|
|
3203
3437
|
return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
|
|
3204
3438
|
}
|
|
3439
|
+
if (txn.kind === 'java-source-splice') {
|
|
3440
|
+
const members = txn.target.edits
|
|
3441
|
+
.map((e) => (e.locator ? `${e.op}:${e.locator.member_name}` : `add-import:${e.imports?.[0]}`))
|
|
3442
|
+
.join(', ');
|
|
3443
|
+
return `[java-source-splice] ${txn.target.file}: ${members}`;
|
|
3444
|
+
}
|
|
3205
3445
|
const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
|
|
3206
3446
|
return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
|
|
3207
3447
|
}
|
|
@@ -3231,12 +3471,33 @@ async function cmdPatchApprove(args) {
|
|
|
3231
3471
|
} catch (err) {
|
|
3232
3472
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
3233
3473
|
}
|
|
3234
|
-
|
|
3474
|
+
let updated;
|
|
3475
|
+
try {
|
|
3476
|
+
updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
|
|
3477
|
+
} catch (err) {
|
|
3478
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
|
|
3479
|
+
}
|
|
3235
3480
|
|
|
3236
3481
|
console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
|
|
3237
3482
|
process.exit(0);
|
|
3238
3483
|
}
|
|
3239
3484
|
|
|
3485
|
+
// D-java-source-splice: enriches a StaleTransactionError with the kind's own optional
|
|
3486
|
+
// describeStaleness() hook (only java-source-splice defines one today) -- never changes WHETHER
|
|
3487
|
+
// the transaction is rejected, only the diagnostic text. Checked by `.name`, not `instanceof`
|
|
3488
|
+
// (StaleTransactionError is intentionally not exported from lib/patch-transactions.mjs -- this is
|
|
3489
|
+
// its only consumer, and importing the class just to narrow a catch is unnecessary coupling).
|
|
3490
|
+
function describeStaleTransactionError(err, root, txn, freshPlan) {
|
|
3491
|
+
if (err.name !== 'StaleTransactionError') return err.message;
|
|
3492
|
+
const describe = getPatchKind(txn.kind).describeStaleness;
|
|
3493
|
+
if (!describe) return err.message;
|
|
3494
|
+
try {
|
|
3495
|
+
return describe(root, txn, freshPlan);
|
|
3496
|
+
} catch {
|
|
3497
|
+
return err.message;
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3240
3501
|
// D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
|
|
3241
3502
|
// than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
|
|
3242
3503
|
// is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
|
|
@@ -3262,7 +3523,12 @@ async function cmdPatchApply(args) {
|
|
|
3262
3523
|
} catch (err) {
|
|
3263
3524
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
3264
3525
|
}
|
|
3265
|
-
|
|
3526
|
+
let updated;
|
|
3527
|
+
try {
|
|
3528
|
+
updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
|
|
3529
|
+
} catch (err) {
|
|
3530
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', describeStaleTransactionError(err, root, txn, freshPlan));
|
|
3531
|
+
}
|
|
3266
3532
|
|
|
3267
3533
|
const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
|
|
3268
3534
|
const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
|
|
@@ -4453,6 +4719,18 @@ async function dispatchCommand(cmd, rest) {
|
|
|
4453
4719
|
process.exit(14);
|
|
4454
4720
|
break;
|
|
4455
4721
|
}
|
|
4722
|
+
case 'impact': {
|
|
4723
|
+
const sub = rest[0];
|
|
4724
|
+
const subArgs = rest.slice(1);
|
|
4725
|
+
if (sub === 'check') return cmdImpactCheck(subArgs);
|
|
4726
|
+
if (sub === 'accept') return cmdImpactAccept(subArgs);
|
|
4727
|
+
if (sub === 'disposition') return cmdImpactDisposition(subArgs);
|
|
4728
|
+
if (sub === 'ack') return cmdImpactAck(subArgs);
|
|
4729
|
+
if (sub === 'export') return cmdImpactExport(subArgs);
|
|
4730
|
+
usage();
|
|
4731
|
+
process.exit(14);
|
|
4732
|
+
break;
|
|
4733
|
+
}
|
|
4456
4734
|
case 'rules': {
|
|
4457
4735
|
const sub = rest[0];
|
|
4458
4736
|
const subArgs = rest.slice(1);
|
package/contracts/emit.mjs
CHANGED
|
@@ -15,12 +15,12 @@ import { pathPrefixCandidates, unreflectedPathPrefixes } from './export.mjs';
|
|
|
15
15
|
// a path param. Direction stays one-way (openapi.mjs imports from emit.mjs, never the reverse).
|
|
16
16
|
export const BARE_UUID_PATTERN = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$';
|
|
17
17
|
|
|
18
|
-
// A7/A8/A9/A10: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
|
|
18
|
+
// A7/A8/A9/A10/X2: the single source of truth for schemas/feature-contract.schema.json's `sbf_contract`
|
|
19
19
|
// const -- bin/bskel.mjs's loadContract() imports this too, so the friendly "re-emit with the
|
|
20
|
-
// current bskel" message and the value actually written here cannot drift apart. Bumped "
|
|
21
|
-
// for this item (
|
|
22
|
-
//
|
|
23
|
-
export const CONTRACT_SCHEMA_VERSION = '
|
|
20
|
+
// current bskel" message and the value actually written here cannot drift apart. Bumped "8" -> "9"
|
|
21
|
+
// for this item (the `expansion` field, D-route-expansion-provenance) -- again cheap, the friendly
|
|
22
|
+
// re-emit pre-check needs zero code change.
|
|
23
|
+
export const CONTRACT_SCHEMA_VERSION = '9';
|
|
24
24
|
|
|
25
25
|
// A9 (D-openapi-path-params): `sourcePathParamSchemas` (a Map<name, schema>, contracts/openapi.mjs's
|
|
26
26
|
// applyPathParameterSchemas -- present only for a matched/adopted operation whose source document
|
|
@@ -66,7 +66,11 @@ function pathParamsSchema(routePath, sourcePathParamSchemas = null) {
|
|
|
66
66
|
// Object>>`) -- findMethodParams() shares the same balanced-delimiter analyzer that fixes the
|
|
67
67
|
// scanner's identical GenericWithSpaceController case.
|
|
68
68
|
function detectRequestBody(filePath, methodName) {
|
|
69
|
-
|
|
69
|
+
// X5 (D-route-expansion-provenance): explicit guard, not the incidental fact that a regex built
|
|
70
|
+
// from the literal string "null" also happens not to match anything -- a null methodName means
|
|
71
|
+
// there is genuinely no literal per-action source method to look in (see the same reasoning
|
|
72
|
+
// D-typescript-express-inline-handlers already established for resolveHandlerFile()).
|
|
73
|
+
if (!filePath || !methodName || !fs.existsSync(filePath)) return null;
|
|
70
74
|
const text = fs.readFileSync(filePath, 'utf8');
|
|
71
75
|
const params = findMethodParams(text, methodName);
|
|
72
76
|
if (params === null) return null;
|
|
@@ -377,6 +381,15 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
377
381
|
}));
|
|
378
382
|
}
|
|
379
383
|
const { pathParams, pathParamsHeuristic } = pathParamsSchema(route, pathParamSchemas);
|
|
384
|
+
// X2 (D-route-expansion-provenance): present only when the scan adapter recorded which
|
|
385
|
+
// multi-route declaration this endpoint came from (ep.declarationIndex) AND that
|
|
386
|
+
// declaration actually resolves on the owning controller -- omitted for every ordinary
|
|
387
|
+
// 1:1 endpoint, the same "absent unless it applies" discipline A9's pathParamsHeuristic
|
|
388
|
+
// already established. No adapter populates declarationIndex yet (forward-compatible
|
|
389
|
+
// shape only) -- see test/contract.test.mjs's expansion-field tests for a hand-built
|
|
390
|
+
// fixture exercising this.
|
|
391
|
+
const declaration = ep.declarationIndex != null ? (controller.declarations?.[ep.declarationIndex] ?? null) : null;
|
|
392
|
+
const expansion = declaration ? { rule: declaration.rule, declarationLine: declaration.line, label: declaration.label ?? null } : null;
|
|
380
393
|
operations[operationId] = {
|
|
381
394
|
verb,
|
|
382
395
|
path: route,
|
|
@@ -397,6 +410,9 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
|
|
|
397
410
|
...(sourceRequestBody ? { sourceRequestBody } : {}),
|
|
398
411
|
// A9: omitted (not []) when every segment resolved from source, or the route has none.
|
|
399
412
|
...(pathParamsHeuristic ? { pathParamsHeuristic } : {}),
|
|
413
|
+
// X2: omitted entirely when this endpoint wasn't expanded from a multi-route
|
|
414
|
+
// declaration -- see the computation above.
|
|
415
|
+
...(expansion ? { expansion } : {}),
|
|
400
416
|
// A10: omitted entirely when --descriptions was not passed, the source had none, or
|
|
401
417
|
// it failed the length cap -- same "omitted, never null/false" discipline as every
|
|
402
418
|
// other field above.
|