backend-skeleton 1.0.0-beta.9 → 1.0.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 +122 -12
- package/bin/bskel.mjs +564 -15
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +26 -3
- package/contracts/openapi.mjs +29 -3
- package/handles/_engine.mjs +79 -29
- package/handles/providers/java-spring/plan.mjs +22 -9
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
- package/handles/providers/typescript-express/emit.mjs +23 -23
- package/handles/providers/typescript-express/observe.mjs +101 -0
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +125 -3
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/workflow.mjs +31 -3
- package/package.json +5 -2
- package/scanners/adapters/java-spring.mjs +6 -0
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +6 -0
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/stack/apply.mjs +4 -1
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
package/bin/bskel.mjs
CHANGED
|
@@ -30,12 +30,19 @@ import { buildContract, selectModule, CONTRACT_SCHEMA_VERSION } from '../contrac
|
|
|
30
30
|
import { validateEnvelope, operationPayloadSchema } from '../contracts/validate.mjs';
|
|
31
31
|
import { evaluateResolution, loadResolution, saveResolution, requireWarningCode, warningKey, countByCode } from '../contracts/completeness.mjs';
|
|
32
32
|
import { loadPatchApprovals, savePatchApprovals, approvalKey } from '../lib/patch-approvals.mjs';
|
|
33
|
+
import { proposeTransaction, approveTransaction, applyTransaction, rollbackTransaction, loadTransaction, listTransactions } from '../lib/patch-transactions.mjs';
|
|
34
|
+
import { getPatchKind, replanTransaction, PATCH_KIND_NAMES } from '../lib/patch-kinds.mjs';
|
|
35
|
+
import { generateKeypair, signPayload, verifyPayload } from '../lib/attest.mjs';
|
|
33
36
|
import { loadManifest, saveManifest } from '../lib/handles-manifest.mjs';
|
|
34
37
|
import { createHttpServer } from '../lib/http-server.mjs';
|
|
35
38
|
import {
|
|
36
39
|
resolveClassFile, listDownstreamDependents, DependencyOperationError,
|
|
37
40
|
declareDependency, removeDependency, buildDependencyListReport,
|
|
38
41
|
} from '../lib/field-dependencies.mjs';
|
|
42
|
+
import {
|
|
43
|
+
findCollisions, evaluateCrossFeatureFindings, waiverKey,
|
|
44
|
+
crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
|
|
45
|
+
} from '../lib/cross-feature-collisions.mjs';
|
|
39
46
|
import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
|
|
40
47
|
import {
|
|
41
48
|
requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
|
|
@@ -52,6 +59,8 @@ import { detectBasePackage } from '../handles/providers/java-spring/plan.mjs';
|
|
|
52
59
|
import { emitObserveJavaSpring } from '../handles/providers/java-spring/observe.mjs';
|
|
53
60
|
import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/plan.mjs';
|
|
54
61
|
import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
|
|
62
|
+
import { plan as planTypeScriptExpress } from '../handles/providers/typescript-express/plan.mjs';
|
|
63
|
+
import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
|
|
55
64
|
import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
|
|
56
65
|
import { computeWorkflowState } from '../lib/workflow.mjs';
|
|
57
66
|
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
|
|
@@ -70,6 +79,8 @@ function usage() {
|
|
|
70
79
|
bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
|
|
71
80
|
bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
|
|
72
81
|
bskel scan explain <module> --feature <id> [--json]
|
|
82
|
+
bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
|
|
83
|
+
bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
|
|
73
84
|
bskel feature init --slug <name>
|
|
74
85
|
bskel feature list [--all] [--json]
|
|
75
86
|
bskel feature show <id> [--json]
|
|
@@ -91,8 +102,13 @@ function usage() {
|
|
|
91
102
|
bskel handles emit --feature <id> [--module <name>] [--resource type1,type2] [--force --reason "..."] [--check] [--diff] [--enforce-registry on|off --reason "..."]
|
|
92
103
|
bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]
|
|
93
104
|
bskel handles audit --feature <id> --database-url-env <NAME> [--resource type1,type2] [--json]
|
|
105
|
+
bskel patch propose --feature <id> [--kind config-apply|ddl-apply] --choice <stackChoiceId> --target <config_check target path> --database-url-env <NAME> --schema <name> --sql-file <path> [--json]
|
|
106
|
+
bskel patch approve --feature <id> --transaction <id> --reason "..." [--json]
|
|
107
|
+
bskel patch apply --feature <id> --transaction <id> [--confirm <id-or-dropped-table-name>] [--json]
|
|
108
|
+
bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
|
|
109
|
+
bskel patch list --feature <id> [--json]
|
|
94
110
|
bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
|
|
95
|
-
bskel observe import --feature <id> --receipts <path> [--json]
|
|
111
|
+
bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
|
|
96
112
|
bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
|
|
97
113
|
bskel status [--feature <id>] [--json]
|
|
98
114
|
bskel next [--feature <id>] [--json]
|
|
@@ -101,9 +117,11 @@ function usage() {
|
|
|
101
117
|
bskel gate revoke <name> --reason "..." [--feature <id>]
|
|
102
118
|
bskel gate history <name> [--feature <id>] [--json]
|
|
103
119
|
bskel gate show [<name>] [--feature <id>]
|
|
104
|
-
bskel gate export --feature <id> [--out <path>] [--json]
|
|
120
|
+
bskel gate export --feature <id> [--out <path>] [--sign --key <privateKeyPath>] [--json]
|
|
121
|
+
bskel attest keygen --out <dir> [--force] [--json]
|
|
122
|
+
bskel attest verify --file <path> --pubkey <path> [--json]
|
|
105
123
|
bskel doctor [--workflow ${DOCTOR_WORKFLOWS.join('|')}] [--json]
|
|
106
|
-
bskel serve [--port N] [--host <addr>] [--json]
|
|
124
|
+
bskel serve [--port N] [--host <addr>] [--database-url-env <NAME>] [--schema <name>] [--sign-key <path>] [--require-sign-key] [--json]
|
|
107
125
|
`);
|
|
108
126
|
}
|
|
109
127
|
|
|
@@ -393,6 +411,12 @@ function cmdGateExport(args) {
|
|
|
393
411
|
setContext('gate export', flags);
|
|
394
412
|
const root = requireRepoRoot();
|
|
395
413
|
requireValidFeatureId(flags.feature);
|
|
414
|
+
if (flags.sign && !flags.key) {
|
|
415
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel gate export --sign requires --key <privateKeyPath>');
|
|
416
|
+
}
|
|
417
|
+
if (flags.key && !flags.sign) {
|
|
418
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--key only has an effect together with --sign');
|
|
419
|
+
}
|
|
396
420
|
|
|
397
421
|
const gates = {};
|
|
398
422
|
for (const name of GATE_NAMES) {
|
|
@@ -407,14 +431,45 @@ function cmdGateExport(args) {
|
|
|
407
431
|
git: { branch: currentBranch(root), head_sha: headSha(root), dirty: isDirty(root) },
|
|
408
432
|
gates,
|
|
409
433
|
};
|
|
410
|
-
|
|
434
|
+
// D-gate-attestation-signing: validated unconditionally, signed or not -- a document that can
|
|
435
|
+
// be exported unsigned should be exactly as trustworthy in shape as one that gets signed later.
|
|
436
|
+
{
|
|
437
|
+
const { ok, errors } = validateAgainstSchema('gate-export.schema.json', report);
|
|
438
|
+
if (!ok) {
|
|
439
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate-export report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let payload = report;
|
|
444
|
+
if (flags.sign) {
|
|
445
|
+
let privateKeyPem;
|
|
446
|
+
try {
|
|
447
|
+
privateKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.key), 'utf8');
|
|
448
|
+
} catch (err) {
|
|
449
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --key "${flags.key}": ${err.message}`);
|
|
450
|
+
}
|
|
451
|
+
let signatureValue;
|
|
452
|
+
try {
|
|
453
|
+
signatureValue = signPayload(report, privateKeyPem);
|
|
454
|
+
} catch (err) {
|
|
455
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--key "${flags.key}" is not a usable Ed25519 private key: ${err.message}`);
|
|
456
|
+
}
|
|
457
|
+
const attestation = { schema: 'sbf.gate-attestation/1', report, signature: { algorithm: 'ed25519', value: signatureValue } };
|
|
458
|
+
const { ok, errors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
|
|
459
|
+
if (!ok) {
|
|
460
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed gate attestation failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
|
|
461
|
+
}
|
|
462
|
+
payload = attestation;
|
|
463
|
+
}
|
|
464
|
+
const rendered = `${JSON.stringify(payload, null, 2)}\n`;
|
|
411
465
|
|
|
412
466
|
if (flags.out) {
|
|
413
467
|
const outPath = path.resolve(process.cwd(), flags.out);
|
|
414
468
|
writeFileAtomic(outPath, rendered);
|
|
415
469
|
if (!flags.quiet) {
|
|
416
470
|
const passCount = GATE_NAMES.filter((n) => gates[n].current?.status === 'pass').length;
|
|
417
|
-
|
|
471
|
+
const signedNote = flags.sign ? ' (signed)' : '';
|
|
472
|
+
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)'}${report.git.dirty ? ' (dirty)' : ''}`);
|
|
418
473
|
}
|
|
419
474
|
} else {
|
|
420
475
|
console.log(rendered);
|
|
@@ -422,6 +477,78 @@ function cmdGateExport(args) {
|
|
|
422
477
|
process.exit(0);
|
|
423
478
|
}
|
|
424
479
|
|
|
480
|
+
// D-gate-attestation-signing: --out is always required, never a default/home-directory location --
|
|
481
|
+
// this codebase has zero existing home-directory persistence convention anywhere, and inventing
|
|
482
|
+
// one is explicitly out of scope for this slice (a real fork the user weighed and decided, see
|
|
483
|
+
// DECISIONS.md). Repo-independent -- does not require a git repo at all, matching `attest verify`'s
|
|
484
|
+
// own posture below (both operate purely on files the caller names).
|
|
485
|
+
function cmdAttestKeygen(args) {
|
|
486
|
+
const flags = parseCommand('attest keygen', args);
|
|
487
|
+
if (flags.help) { console.log(renderCommandHelp('attest keygen')); process.exit(0); }
|
|
488
|
+
setContext('attest keygen', flags);
|
|
489
|
+
const outDir = path.resolve(process.cwd(), flags.out);
|
|
490
|
+
const privatePath = path.join(outDir, 'attest-private.pem');
|
|
491
|
+
const publicPath = path.join(outDir, 'attest-public.pem');
|
|
492
|
+
if (!flags.force) {
|
|
493
|
+
const existing = [privatePath, publicPath].filter((p) => fs.existsSync(p));
|
|
494
|
+
if (existing.length > 0) {
|
|
495
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to overwrite existing key file(s) without --force: ${existing.join(', ')} -- regenerating would orphan any attestation already signed with the old key`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const { publicKeyPem, privateKeyPem } = generateKeypair();
|
|
499
|
+
// Restrictive mode (owner read/write only) from the very first write -- see the
|
|
500
|
+
// `D-gate-attestation-signing` entry's own note in `lib/fsutil.mjs` on why this is a
|
|
501
|
+
// `writeFileAtomic` parameter, not a chmod() called after the fact.
|
|
502
|
+
writeFileAtomic(privatePath, privateKeyPem, 0o600);
|
|
503
|
+
writeFileAtomic(publicPath, publicKeyPem);
|
|
504
|
+
|
|
505
|
+
if (flags.json) {
|
|
506
|
+
console.log(JSON.stringify({ private_key: privatePath, public_key: publicPath }, null, 2));
|
|
507
|
+
} else if (!flags.quiet) {
|
|
508
|
+
console.log(`wrote ${privatePath} (0600) and ${publicPath}`);
|
|
509
|
+
}
|
|
510
|
+
process.exit(0);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Deliberately repo-independent (no requireRepoRoot()) -- verifying a previously-exported
|
|
514
|
+
// attestation has nothing to do with the current directory's own git state; the whole point is to
|
|
515
|
+
// check a document someone else produced, possibly on a different machine, offline. Exit code is
|
|
516
|
+
// driven ONLY by signature validity -- whether the gates INSIDE the report passed is a separate,
|
|
517
|
+
// printed question (see DECISIONS.md for why conflating the two would be actively misleading).
|
|
518
|
+
function cmdAttestVerify(args) {
|
|
519
|
+
const flags = parseCommand('attest verify', args);
|
|
520
|
+
if (flags.help) { console.log(renderCommandHelp('attest verify')); process.exit(0); }
|
|
521
|
+
setContext('attest verify', flags);
|
|
522
|
+
|
|
523
|
+
let attestation;
|
|
524
|
+
try {
|
|
525
|
+
attestation = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), flags.file), 'utf8'));
|
|
526
|
+
} catch (err) {
|
|
527
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read/parse --file "${flags.file}": ${err.message}`);
|
|
528
|
+
}
|
|
529
|
+
const { ok: schemaOk, errors: schemaErrors } = validateAgainstSchema('gate-attestation.schema.json', attestation);
|
|
530
|
+
if (!schemaOk) {
|
|
531
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `"${flags.file}" is not a valid gate attestation: ${formatSchemaErrors(schemaErrors).join('; ')}`);
|
|
532
|
+
}
|
|
533
|
+
let publicKeyPem;
|
|
534
|
+
try {
|
|
535
|
+
publicKeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
|
|
536
|
+
} catch (err) {
|
|
537
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const valid = verifyPayload(attestation.report, attestation.signature.value, publicKeyPem);
|
|
541
|
+
const passCount = GATE_NAMES.filter((n) => attestation.report.gates[n]?.current?.status === 'pass').length;
|
|
542
|
+
|
|
543
|
+
if (flags.json) {
|
|
544
|
+
console.log(JSON.stringify({ valid, report_summary: { feature_id: attestation.report.feature_id, generated_at: attestation.report.generated_at, gates_passing: `${passCount}/${GATE_NAMES.length}` } }, null, 2));
|
|
545
|
+
} else if (!flags.quiet) {
|
|
546
|
+
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');
|
|
547
|
+
console.log(`report says: feature ${attestation.report.feature_id}, ${passCount}/${GATE_NAMES.length} gate(s) passing, generated ${attestation.report.generated_at}`);
|
|
548
|
+
}
|
|
549
|
+
process.exit(valid ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED);
|
|
550
|
+
}
|
|
551
|
+
|
|
425
552
|
// Structural enforcement of "preflight blocks everything below it" (see the workflow table in
|
|
426
553
|
// SKILL.md) for every feature-scoped command -- not just documented as a step order, checked.
|
|
427
554
|
// Ad-hoc `bskel scan` (no --feature) is exempt: it's an explicit side-channel quick-look
|
|
@@ -634,6 +761,146 @@ function cmdScanExplain(args) {
|
|
|
634
761
|
process.exit(0);
|
|
635
762
|
}
|
|
636
763
|
|
|
764
|
+
// D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
|
|
765
|
+
// blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
|
|
766
|
+
// identity collisions against every OTHER feature, not this feature's own contract completeness).
|
|
767
|
+
//
|
|
768
|
+
// D-cross-feature-fk-inference: async now -- reuses the EXACT same resolveDbSchemaOrExit() helper
|
|
769
|
+
// cmdScan() already calls for `--db [--database-url-env <NAME>] [--schema public]`, so this command
|
|
770
|
+
// gains those same flags with zero new live-DB code path. If `--db` was never given (the existing,
|
|
771
|
+
// unmodified call shape every current caller -- 5 CI smoke scripts, README's Quickstart -- already
|
|
772
|
+
// uses), findCollisions() falls back to a persisted snapshot or reports fk_check:'unavailable';
|
|
773
|
+
// the first 3 signals' findings and blocking behavior are byte-identical to before this item.
|
|
774
|
+
async function cmdScanCrossFeatureCheck(args) {
|
|
775
|
+
const flags = parseCommand('scan cross-feature-check', args);
|
|
776
|
+
if (flags.help) { console.log(renderCommandHelp('scan cross-feature-check')); process.exit(0); }
|
|
777
|
+
setContext('scan cross-feature-check', flags);
|
|
778
|
+
const root = requireRepoRoot();
|
|
779
|
+
requireValidFeatureId(flags.feature);
|
|
780
|
+
|
|
781
|
+
const liveDbSchema = await resolveDbSchemaOrExit(root, flags);
|
|
782
|
+
const { findings, fk_check, unknowns } = findCollisions(root, flags.feature, { liveDbSchema });
|
|
783
|
+
const report = {
|
|
784
|
+
schema: 'sbf.cross-feature-report/1',
|
|
785
|
+
feature_id: flags.feature,
|
|
786
|
+
generated_at: new Date().toISOString(),
|
|
787
|
+
findings,
|
|
788
|
+
fk_check,
|
|
789
|
+
unknowns,
|
|
790
|
+
};
|
|
791
|
+
const { ok, errors } = validateAgainstSchema('cross-feature-report.schema.json', report);
|
|
792
|
+
if (!ok) {
|
|
793
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `internal error: the computed cross-feature report failed its own schema -- ${formatSchemaErrors(errors).join('; ')}`);
|
|
794
|
+
}
|
|
795
|
+
writeFileAtomic(crossFeatureReportPath(root, flags.feature), `${JSON.stringify(report, null, 2)}\n`);
|
|
796
|
+
|
|
797
|
+
const resolution = loadCrossFeatureResolution(root, flags.feature);
|
|
798
|
+
const evaluation = evaluateCrossFeatureFindings(findings, resolution);
|
|
799
|
+
const evidence = {
|
|
800
|
+
finding_count: findings.length,
|
|
801
|
+
high_confidence_count: findings.filter((f) => f.confidence === 'high').length,
|
|
802
|
+
waived_count: evaluation.waived.length,
|
|
803
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
804
|
+
};
|
|
805
|
+
const gateState = evaluation.blocking
|
|
806
|
+
? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
|
|
807
|
+
: passNamedGate(root, 'cross_feature', flags.feature, evidence);
|
|
808
|
+
|
|
809
|
+
if (flags.json) {
|
|
810
|
+
console.log(JSON.stringify({ report, gate: gateState.gates.cross_feature }, null, 2));
|
|
811
|
+
} else {
|
|
812
|
+
if (!flags.quiet) {
|
|
813
|
+
const otherCount = new Set(findings.map((f) => f.other_feature)).size;
|
|
814
|
+
console.log(`${findings.length} finding(s) (${evidence.high_confidence_count} high-confidence) across ${otherCount} other feature(s)`);
|
|
815
|
+
for (const f of findings) console.log(` [${f.confidence}] ${f.signal}: "${f.identifier}" also declared by ${f.other_feature}`);
|
|
816
|
+
// D-cross-feature-fk-inference (staleness/freshness token): the actual point of the field
|
|
817
|
+
// -- a human SEEING how stale a persisted/migrations-mode correlation is, not just the
|
|
818
|
+
// JSON carrying it silently.
|
|
819
|
+
const fkCheckDetails = [];
|
|
820
|
+
if (fk_check.source_feature) fkCheckDetails.push(`from ${fk_check.source_feature}'s own persisted snapshot`);
|
|
821
|
+
if (fk_check.generated_at) fkCheckDetails.push(`captured ${fk_check.generated_at}`);
|
|
822
|
+
console.log(`fk_check: ${fk_check.mode}${fkCheckDetails.length ? ` (${fkCheckDetails.join(', ')})` : ''}`);
|
|
823
|
+
for (const u of unknowns) console.log(` note: ${u}`);
|
|
824
|
+
console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
|
|
825
|
+
}
|
|
826
|
+
if (evaluation.staleWaivers.length > 0) {
|
|
827
|
+
console.error(`\nnote: ${evaluation.staleWaivers.length} recorded waiver(s) no longer match any current finding (kept as-is, not auto-removed):`);
|
|
828
|
+
for (const w of evaluation.staleWaivers) console.error(` ${w.signal} "${w.identifier}" (${w.other_feature})`);
|
|
829
|
+
}
|
|
830
|
+
if (evaluation.blocking) {
|
|
831
|
+
console.error(`\nblocked: ${evaluation.unwaived.length} unresolved high-confidence collision(s):`);
|
|
832
|
+
for (const f of evaluation.unwaived) {
|
|
833
|
+
console.error(` bskel scan cross-feature-waive --feature ${flags.feature} --signal ${f.signal} --identifier "${f.identifier}" --other-feature ${f.other_feature} --reason "..."`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const CROSS_FEATURE_SIGNALS = ['resource_type', 'table', 'operation_id', 'db_foreign_key'];
|
|
841
|
+
|
|
842
|
+
// Validates against the PERSISTED report from the last `cross-feature-check` run, never a live
|
|
843
|
+
// re-computation -- same precedent `contract waive` already establishes against `loadContract`
|
|
844
|
+
// (contracts/completeness.mjs). If reality moved since that check, the gate's own staleness token
|
|
845
|
+
// (which covers every OTHER feature named in the report) is what surfaces that, not a silent
|
|
846
|
+
// re-check inside this command.
|
|
847
|
+
function cmdScanCrossFeatureWaive(args) {
|
|
848
|
+
const flags = parseCommand('scan cross-feature-waive', args);
|
|
849
|
+
if (flags.help) { console.log(renderCommandHelp('scan cross-feature-waive')); process.exit(0); }
|
|
850
|
+
setContext('scan cross-feature-waive', flags);
|
|
851
|
+
const root = requireRepoRoot();
|
|
852
|
+
requireValidFeatureId(flags.feature);
|
|
853
|
+
requireValidFeatureId(flags['other-feature']);
|
|
854
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
855
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel scan cross-feature-waive requires --reason "..." -- every waiver must be auditable');
|
|
856
|
+
}
|
|
857
|
+
if (!CROSS_FEATURE_SIGNALS.includes(flags.signal)) {
|
|
858
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--signal must be one of: ${CROSS_FEATURE_SIGNALS.join(', ')}`);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const report = loadCrossFeatureReport(root, flags.feature);
|
|
862
|
+
if (!report) {
|
|
863
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no cross-feature-report.json for feature "${flags.feature}" -- run \`bskel scan cross-feature-check --feature ${flags.feature}\` first`);
|
|
864
|
+
}
|
|
865
|
+
const match = report.findings.find((f) => f.signal === flags.signal && f.identifier === flags.identifier && f.other_feature === flags['other-feature']);
|
|
866
|
+
if (!match) {
|
|
867
|
+
const known = report.findings.map((f) => `${f.signal} "${f.identifier}" (${f.other_feature})`);
|
|
868
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no current finding matches --signal ${flags.signal} --identifier "${flags.identifier}" --other-feature ${flags['other-feature']} -- current findings: ${known.join('; ') || '(none)'}`);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const updated = withLockSync(root, 'state', () => {
|
|
872
|
+
const resolution = loadCrossFeatureResolution(root, flags.feature);
|
|
873
|
+
const key = waiverKey({ signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'] });
|
|
874
|
+
const entry = { signal: flags.signal, identifier: flags.identifier, other_feature: flags['other-feature'], reason: flags.reason, at: new Date().toISOString() };
|
|
875
|
+
const next = {
|
|
876
|
+
schema: 'sbf.cross-feature-resolution/1',
|
|
877
|
+
feature_id: flags.feature,
|
|
878
|
+
waivers: [...resolution.waivers.filter((w) => waiverKey(w) !== key), entry],
|
|
879
|
+
};
|
|
880
|
+
saveCrossFeatureResolution(root, flags.feature, next);
|
|
881
|
+
return next;
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
const evaluation = evaluateCrossFeatureFindings(report.findings, updated);
|
|
885
|
+
const evidence = {
|
|
886
|
+
finding_count: report.findings.length,
|
|
887
|
+
high_confidence_count: report.findings.filter((f) => f.confidence === 'high').length,
|
|
888
|
+
waived_count: evaluation.waived.length,
|
|
889
|
+
stale_waivers: evaluation.staleWaivers.length,
|
|
890
|
+
};
|
|
891
|
+
const gateState = evaluation.blocking
|
|
892
|
+
? awaitNamedGateDisposition(root, 'cross_feature', flags.feature, { ...evidence, unwaived: evaluation.unwaived })
|
|
893
|
+
: passNamedGate(root, 'cross_feature', flags.feature, evidence);
|
|
894
|
+
|
|
895
|
+
if (flags.json) {
|
|
896
|
+
console.log(JSON.stringify({ waived: true, gate: gateState.gates.cross_feature }, null, 2));
|
|
897
|
+
} else if (!flags.quiet) {
|
|
898
|
+
console.log(`waived: ${flags.signal} "${flags.identifier}" (${flags['other-feature']})`);
|
|
899
|
+
console.log(`gate: cross_feature -> ${gateState.gates.cross_feature.status}`);
|
|
900
|
+
}
|
|
901
|
+
process.exit(evaluation.blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
902
|
+
}
|
|
903
|
+
|
|
637
904
|
// D6 (D-feature-lifecycle): the whole read-specs/->compute-NNN->write-feature.json->
|
|
638
905
|
// load-modify-save-feature-index.json sequence runs under one exclusive lock -- confirmed live
|
|
639
906
|
// during this item's own grounding, the same lost-update shape S5 already fixed for setGate():
|
|
@@ -2036,6 +2303,25 @@ function cmdHandlesEmit(args) {
|
|
|
2036
2303
|
});
|
|
2037
2304
|
}
|
|
2038
2305
|
|
|
2306
|
+
// D-cross-feature-collision: making "mandatory disposition" actually mandatory, not just
|
|
2307
|
+
// available -- `handles emit` is specifically the step that generates the runtime resolver
|
|
2308
|
+
// code every codegen provider's own resourceType-keyed dispatch (Java/Python/TS, all three)
|
|
2309
|
+
// already implicitly assumes is repo-unique. `cross_feature`'s own gate `verifyPolicy` stays
|
|
2310
|
+
// REQUIRED_WHEN_PRESENT (so `bskel verify` doesn't retroactively fail a contract-only feature
|
|
2311
|
+
// that never touches handles at all) -- this hard-requires it ONLY here, the one command where
|
|
2312
|
+
// the real risk actually lives, mirroring the `contract` gate check immediately above exactly.
|
|
2313
|
+
// Deliberately NOT added to `cmdHandlesPlan` -- that command never writes (dryRun always),
|
|
2314
|
+
// same reasoning it already gives for skipping the `contract` gate entirely.
|
|
2315
|
+
const crossFeatureResult = requireNamedGate(root, 'cross_feature', flags.feature);
|
|
2316
|
+
if (crossFeatureResult.code !== EXIT.PASS) {
|
|
2317
|
+
const cfHint = crossFeatureResult.status === 'awaiting_disposition'
|
|
2318
|
+
? `resolve it first -- \`bskel scan cross-feature-waive --feature ${flags.feature} --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."\`, or \`bskel gate force cross_feature --feature ${flags.feature} --reason "..."\` if intentional.`
|
|
2319
|
+
: `run \`bskel scan cross-feature-check --feature ${flags.feature}\` first.`;
|
|
2320
|
+
fail(crossFeatureResult.code, gateReasonForCode(crossFeatureResult.code), `blocked: \`cross_feature\` gate for ${flags.feature} is ${crossFeatureResult.status} -- ${cfHint}`, {
|
|
2321
|
+
next_actions: [{ command: `bskel scan cross-feature-check --feature ${flags.feature}`, reason: 'the cross_feature gate has not passed yet', mutating: true }],
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2039
2325
|
const scanReport = loadScanReportOrExit(root, flags.feature);
|
|
2040
2326
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2041
2327
|
requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
|
|
@@ -2211,6 +2497,189 @@ function cmdHandlesPatchApprove(args) {
|
|
|
2211
2497
|
process.exit(0);
|
|
2212
2498
|
}
|
|
2213
2499
|
|
|
2500
|
+
// D-patch-transactions: Slice 1 (config_check -> config_apply). All four commands only touch
|
|
2501
|
+
// specs/<featureId>/patch-transactions/ except `apply`/`rollback`, which write to the real target
|
|
2502
|
+
// file too -- matching D4's own "propose/approve are specs/-only, apply/rollback touch the repo"
|
|
2503
|
+
// distinction cmdHandlesEmit/cmdHandlesPlan already draw for --check vs a real emit.
|
|
2504
|
+
// D-ddl-apply: `--kind` picks which patch-transaction kind to propose (default 'config-apply',
|
|
2505
|
+
// fully backward compatible -- every existing call site/script that never passed --kind gets the
|
|
2506
|
+
// exact prior behavior). Kind-specific params are validated by hand here (not via COMMANDS'
|
|
2507
|
+
// declarative `required: true`, which is unconditional per-flag) -- matches this file's own
|
|
2508
|
+
// existing convention for kind-conditional requirements (e.g. --reason on approve/rollback).
|
|
2509
|
+
async function cmdPatchPropose(args) {
|
|
2510
|
+
const flags = parseCommand('patch propose', args);
|
|
2511
|
+
if (flags.help) { console.log(renderCommandHelp('patch propose')); process.exit(0); }
|
|
2512
|
+
setContext('patch propose', flags);
|
|
2513
|
+
const root = requireRepoRoot();
|
|
2514
|
+
requireValidFeatureId(flags.feature);
|
|
2515
|
+
const kind = flags.kind || 'config-apply';
|
|
2516
|
+
if (!PATCH_KIND_NAMES.includes(kind)) {
|
|
2517
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--kind must be one of: ${PATCH_KIND_NAMES.join(', ')}`);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
let params;
|
|
2521
|
+
let source;
|
|
2522
|
+
if (kind === 'config-apply') {
|
|
2523
|
+
if (!flags.choice || !flags.target) {
|
|
2524
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind config-apply requires --choice <stackChoiceId> --target <config_check target path>');
|
|
2525
|
+
}
|
|
2526
|
+
params = { choice: flags.choice, target: flags.target };
|
|
2527
|
+
source = { choice: flags.choice };
|
|
2528
|
+
} else {
|
|
2529
|
+
if (!flags['database-url-env'] || !flags['sql-file']) {
|
|
2530
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch propose --kind ddl-apply requires --database-url-env <NAME> --sql-file <path>');
|
|
2531
|
+
}
|
|
2532
|
+
let sqlText;
|
|
2533
|
+
try {
|
|
2534
|
+
sqlText = fs.readFileSync(flags['sql-file'], 'utf8');
|
|
2535
|
+
} catch (err) {
|
|
2536
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --sql-file "${flags['sql-file']}": ${err.message}`);
|
|
2537
|
+
}
|
|
2538
|
+
params = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, sqlText };
|
|
2539
|
+
source = { database_url_env: flags['database-url-env'], schema: flags.schema };
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
let plan;
|
|
2543
|
+
try {
|
|
2544
|
+
plan = await getPatchKind(kind).planFresh(root, params);
|
|
2545
|
+
} catch (err) {
|
|
2546
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
2547
|
+
}
|
|
2548
|
+
const txn = proposeTransaction(root, flags.feature, kind, plan, source);
|
|
2549
|
+
|
|
2550
|
+
if (flags.json) {
|
|
2551
|
+
console.log(JSON.stringify(txn, null, 2));
|
|
2552
|
+
} else if (!flags.quiet) {
|
|
2553
|
+
console.log(`proposed: ${txn.transaction_id}`);
|
|
2554
|
+
console.log(` ${describePatchTransaction(txn)}`);
|
|
2555
|
+
console.log(`next: bskel patch approve --feature ${flags.feature} --transaction ${txn.transaction_id} --reason "..."`);
|
|
2556
|
+
}
|
|
2557
|
+
process.exit(0);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// Kind-aware one-line summary, reused by propose/list's human-readable output -- config-apply's
|
|
2561
|
+
// target is a file+key_path, ddl-apply's is raw SQL text (truncated for a terminal line).
|
|
2562
|
+
function describePatchTransaction(txn) {
|
|
2563
|
+
if (txn.kind === 'config-apply') {
|
|
2564
|
+
return `${txn.target.file} @ ${txn.target.key_path.join('.')}: "${txn.current_value}" -> "${txn.proposed_value}"`;
|
|
2565
|
+
}
|
|
2566
|
+
const sql = txn.target.sql_text.replace(/\s+/g, ' ').trim();
|
|
2567
|
+
return `[${txn.kind}] ${txn.target.database_url_env}/${txn.target.schema}: ${sql.slice(0, 100)}${sql.length > 100 ? '...' : ''}`;
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
function requireTransactionOrExit(root, featureId, transactionId) {
|
|
2571
|
+
const txn = loadTransaction(root, featureId, transactionId);
|
|
2572
|
+
if (!txn) {
|
|
2573
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no patch transaction "${transactionId}" for feature "${featureId}"`);
|
|
2574
|
+
}
|
|
2575
|
+
return txn;
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
async function cmdPatchApprove(args) {
|
|
2579
|
+
const flags = parseCommand('patch approve', args);
|
|
2580
|
+
if (flags.help) { console.log(renderCommandHelp('patch approve')); process.exit(0); }
|
|
2581
|
+
setContext('patch approve', flags);
|
|
2582
|
+
const root = requireRepoRoot();
|
|
2583
|
+
requireValidFeatureId(flags.feature);
|
|
2584
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
2585
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch approve requires --reason "..." -- every approval must be auditable');
|
|
2586
|
+
}
|
|
2587
|
+
const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
|
|
2588
|
+
|
|
2589
|
+
let freshPlan;
|
|
2590
|
+
try {
|
|
2591
|
+
freshPlan = await replanTransaction(root, txn);
|
|
2592
|
+
} catch (err) {
|
|
2593
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
2594
|
+
}
|
|
2595
|
+
const updated = approveTransaction(root, flags.feature, flags.transaction, flags.reason, freshPlan);
|
|
2596
|
+
|
|
2597
|
+
console.log(flags.json ? JSON.stringify(updated, null, 2) : `approved: ${flags.transaction}`);
|
|
2598
|
+
process.exit(0);
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// D-ddl-apply: --confirm is required (and must exactly equal --transaction) for any kind other
|
|
2602
|
+
// than 'config-apply' -- deliberately checked here, at the CLI boundary, before applyTransaction()
|
|
2603
|
+
// is ever called, as human-factors friction layered ON TOP of the engine's own load-bearing
|
|
2604
|
+
// preimage-hash TOCTOU check (this check does not replace it). Optional/ignored for config-apply,
|
|
2605
|
+
// zero behavior change for that kind.
|
|
2606
|
+
async function cmdPatchApply(args) {
|
|
2607
|
+
const flags = parseCommand('patch apply', args);
|
|
2608
|
+
if (flags.help) { console.log(renderCommandHelp('patch apply')); process.exit(0); }
|
|
2609
|
+
setContext('patch apply', flags);
|
|
2610
|
+
const root = requireRepoRoot();
|
|
2611
|
+
requirePreflightPassed(root);
|
|
2612
|
+
requireValidFeatureId(flags.feature);
|
|
2613
|
+
const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
|
|
2614
|
+
|
|
2615
|
+
const requiredConfirm = getPatchKind(txn.kind).requiredConfirmValue(txn);
|
|
2616
|
+
if (requiredConfirm !== null && flags.confirm !== requiredConfirm) {
|
|
2617
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `bskel patch apply --kind ${txn.kind} requires --confirm ${JSON.stringify(requiredConfirm)} exactly -- pass --confirm ${requiredConfirm} once you've reviewed this transaction`);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
let freshPlan;
|
|
2621
|
+
try {
|
|
2622
|
+
freshPlan = await replanTransaction(root, txn);
|
|
2623
|
+
} catch (err) {
|
|
2624
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
2625
|
+
}
|
|
2626
|
+
const updated = await applyTransaction(root, flags.feature, flags.transaction, freshPlan, getPatchKind(txn.kind).apply);
|
|
2627
|
+
|
|
2628
|
+
const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, applied_at: updated.apply.at };
|
|
2629
|
+
const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
|
|
2630
|
+
|
|
2631
|
+
console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `applied: ${flags.transaction} [${updated.kind}]`);
|
|
2632
|
+
process.exit(0);
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
async function cmdPatchRollback(args) {
|
|
2636
|
+
const flags = parseCommand('patch rollback', args);
|
|
2637
|
+
if (flags.help) { console.log(renderCommandHelp('patch rollback')); process.exit(0); }
|
|
2638
|
+
setContext('patch rollback', flags);
|
|
2639
|
+
const root = requireRepoRoot();
|
|
2640
|
+
requirePreflightPassed(root);
|
|
2641
|
+
requireValidFeatureId(flags.feature);
|
|
2642
|
+
if (!flags.reason || !flags.reason.trim()) {
|
|
2643
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel patch rollback requires --reason "..." -- every rollback must be auditable');
|
|
2644
|
+
}
|
|
2645
|
+
const txn = requireTransactionOrExit(root, flags.feature, flags.transaction);
|
|
2646
|
+
const updated = await rollbackTransaction(root, flags.feature, flags.transaction, flags.reason, { force: flags.force }, getPatchKind(txn.kind).rollback);
|
|
2647
|
+
|
|
2648
|
+
// Re-passing (not revoking) mirrors cmdScanCrossFeatureWaive's own precedent for this exact
|
|
2649
|
+
// situation -- recompute() only loops status:'applied' transactions, so a rolled-back one
|
|
2650
|
+
// naturally drops out of the gate's own input set on the next pass; a fresh pass here reflects
|
|
2651
|
+
// "still fine, just smaller" immediately rather than leaving the gate to be reactively
|
|
2652
|
+
// discovered stale by the next `bskel verify`.
|
|
2653
|
+
const evidence = { transaction_id: updated.transaction_id, kind: updated.kind, rolled_back_at: updated.rollback.at };
|
|
2654
|
+
const gateState = passNamedGate(root, 'patch_transactions', flags.feature, evidence);
|
|
2655
|
+
|
|
2656
|
+
console.log(flags.json ? JSON.stringify({ transaction: updated, gate: gateState.gates.patch_transactions }, null, 2) : `rolled back: ${flags.transaction} [${updated.kind}]`);
|
|
2657
|
+
process.exit(0);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// A deliberately-omitted-until-now, easy read-only convenience (D-patch-transactions' own EXIT
|
|
2661
|
+
// list named it explicitly) -- mirrors `dependency list`/`contract history`'s own pure-reader
|
|
2662
|
+
// posture, gate-independent like both.
|
|
2663
|
+
function cmdPatchList(args) {
|
|
2664
|
+
const flags = parseCommand('patch list', args);
|
|
2665
|
+
if (flags.help) { console.log(renderCommandHelp('patch list')); process.exit(0); }
|
|
2666
|
+
setContext('patch list', flags);
|
|
2667
|
+
const root = requireRepoRoot();
|
|
2668
|
+
requireValidFeatureId(flags.feature);
|
|
2669
|
+
const transactions = listTransactions(root, flags.feature);
|
|
2670
|
+
|
|
2671
|
+
if (flags.json) {
|
|
2672
|
+
console.log(JSON.stringify({ feature: flags.feature, transactions }, null, 2));
|
|
2673
|
+
} else if (!flags.quiet) {
|
|
2674
|
+
console.log(`patch transactions -- feature ${flags.feature}`);
|
|
2675
|
+
for (const t of transactions) {
|
|
2676
|
+
console.log(` ${t.transaction_id} [${t.status}] ${describePatchTransaction(t)}`);
|
|
2677
|
+
}
|
|
2678
|
+
if (transactions.length === 0) console.log(' (none proposed)');
|
|
2679
|
+
}
|
|
2680
|
+
process.exit(0);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2214
2683
|
// O7 (D-handle-audit-report): a pure reader, deliberately gate-independent -- matches
|
|
2215
2684
|
// D-contract-history/D-gate-export's own posture, not `handles plan`/`handles emit`'s capability
|
|
2216
2685
|
// gating. It never touches adapter-specific codegen (the query is over `feature_uid` alone, the
|
|
@@ -2341,8 +2810,23 @@ function cmdObserveEmit(args) {
|
|
|
2341
2810
|
} catch (err) {
|
|
2342
2811
|
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2343
2812
|
}
|
|
2813
|
+
} else if (scanReport.adapter === 'typescript-express') {
|
|
2814
|
+
// typescript-express's own project-root detection needs a module to anchor itself too (same
|
|
2815
|
+
// reasoning as python-fastapi's own --module dependency) -- plan.mjs's detectProjectRoot()
|
|
2816
|
+
// walks up from the module's own scanned files.
|
|
2817
|
+
let tsPlan;
|
|
2818
|
+
try {
|
|
2819
|
+
tsPlan = planTypeScriptExpress({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
|
|
2820
|
+
} catch (err) {
|
|
2821
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2822
|
+
}
|
|
2823
|
+
try {
|
|
2824
|
+
result = emitObserveTypeScriptExpress({ repoRoot: root, featureId: flags.feature, contract, plan: tsPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2825
|
+
} catch (err) {
|
|
2826
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2827
|
+
}
|
|
2344
2828
|
} else {
|
|
2345
|
-
fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi)`);
|
|
2829
|
+
fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel observe emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi, typescript-express)`);
|
|
2346
2830
|
}
|
|
2347
2831
|
const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
|
|
2348
2832
|
const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
|
|
@@ -2493,11 +2977,20 @@ function cmdObserveImport(args) {
|
|
|
2493
2977
|
const reportPath = specPath(root, flags.feature, 'observe', `${flags.feature}.conformance-report.json`);
|
|
2494
2978
|
writeFileAtomic(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
2495
2979
|
|
|
2496
|
-
// Evidence-first, not verdict-first (same as `contract`'s own precedent: a `partial`
|
|
2497
|
-
// is still passable via waiver) -- passes on a successful STRUCTURAL import, never on
|
|
2498
|
-
// violations found"
|
|
2499
|
-
//
|
|
2500
|
-
|
|
2980
|
+
// Evidence-first, not verdict-first by DEFAULT (same as `contract`'s own precedent: a `partial`
|
|
2981
|
+
// contract is still passable via waiver) -- passes on a successful STRUCTURAL import, never on
|
|
2982
|
+
// "zero violations found", unless --fail-on-violation opts into the stricter behavior. Whether
|
|
2983
|
+
// violation counts should block CI was a policy question deliberately left undecided in v1 --
|
|
2984
|
+
// this is that v1.1 layer, see D-runtime-conformance-receipts's own "Continued" entry in
|
|
2985
|
+
// DECISIONS.md. `awaitNamedGateDisposition` is the EXACT same disposition mechanism `contract`
|
|
2986
|
+
// already uses for "evidence exists but isn't good enough to pass silently" -- resolved by the
|
|
2987
|
+
// already-generic `bskel gate force conformance --feature <id> --reason "..."` (no new CLI verb
|
|
2988
|
+
// needed; `bskel next`'s own generic awaitingDispositionCommand() fallback already names it).
|
|
2989
|
+
const evidence = { receipt_count: receipts.length, matched, violations: violationCount };
|
|
2990
|
+
const blocking = flags['fail-on-violation'] && violationCount > 0;
|
|
2991
|
+
const gateState = blocking
|
|
2992
|
+
? awaitNamedGateDisposition(root, 'conformance', flags.feature, evidence)
|
|
2993
|
+
: passNamedGate(root, 'conformance', flags.feature, evidence);
|
|
2501
2994
|
|
|
2502
2995
|
if (flags.json) {
|
|
2503
2996
|
console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
|
|
@@ -2506,8 +2999,11 @@ function cmdObserveImport(args) {
|
|
|
2506
2999
|
console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
|
|
2507
3000
|
console.log(`wrote ${path.relative(root, reportPath)}`);
|
|
2508
3001
|
console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
|
|
3002
|
+
if (blocking) {
|
|
3003
|
+
console.error(`\nblocked: ${violationCount} violation(s) found (--fail-on-violation) -- review ${path.relative(root, reportPath)}, then \`bskel gate force conformance --feature ${flags.feature} --reason "..."\` once accounted for.`);
|
|
3004
|
+
}
|
|
2509
3005
|
}
|
|
2510
|
-
process.exit(
|
|
3006
|
+
process.exit(blocking ? EXIT.AWAITING_DISPOSITION : EXIT.PASS);
|
|
2511
3007
|
}
|
|
2512
3008
|
|
|
2513
3009
|
// S2: "stale" alone sends a human/agent re-running steps until one happens to stick. Name the
|
|
@@ -2532,10 +3028,17 @@ function renderVerifyReport({ featureId, gates, artifacts, conflicts, build, all
|
|
|
2532
3028
|
const completenessNote = g.gate === 'contract' && evidence?.completeness
|
|
2533
3029
|
? ` (${evidence.completeness}${evidence.waived_count ? `: ${evidence.waived_count} waived` : ''})`
|
|
2534
3030
|
: '';
|
|
3031
|
+
// D-runtime-conformance-receipts (Continued, --fail-on-violation): same "surface the evidence
|
|
3032
|
+
// right in the verify report" precedent as `completenessNote` above -- fires for both `pass`
|
|
3033
|
+
// and `awaiting_disposition` states, since a human should see the count even when it didn't
|
|
3034
|
+
// end up blocking (i.e. --fail-on-violation wasn't used at import time).
|
|
3035
|
+
const conformanceNote = g.gate === 'conformance' && evidence?.violations
|
|
3036
|
+
? ` (${evidence.violations} violation(s), ${evidence.matched}/${evidence.receipt_count} matched)`
|
|
3037
|
+
: '';
|
|
2535
3038
|
// S4 (D-gate-history): a revoked gate's reason is exactly the kind of "why is this
|
|
2536
3039
|
// blocking" detail describeStale() already surfaces for stale gates -- same treatment here.
|
|
2537
3040
|
const revokedNote = g.status === 'revoked' && g.record?.reason ? ` (revoked: ${g.record.reason})` : '';
|
|
2538
|
-
lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${describeStale(g)}${revokedNote}`);
|
|
3041
|
+
lines.push(`- [${marker}] ${g.gate}${suffix}${completenessNote}${conformanceNote}${describeStale(g)}${revokedNote}`);
|
|
2539
3042
|
}
|
|
2540
3043
|
lines.push('', '## Artifacts');
|
|
2541
3044
|
for (const a of artifacts) lines.push(`- [${a.exists ? 'OK' : 'MISSING'}] ${a.artifact}: ${a.path}`);
|
|
@@ -2747,19 +3250,46 @@ async function cmdServe(args) {
|
|
|
2747
3250
|
const root = requireRepoRoot();
|
|
2748
3251
|
const port = Number.parseInt(flags.port, 10);
|
|
2749
3252
|
|
|
3253
|
+
// D-ddl-apply: eagerly checked here, at startup -- the DB-touching routes must not silently
|
|
3254
|
+
// 404 forever because of a typo'd env var name; missing/unset is BAD_ARGS at the CLI boundary,
|
|
3255
|
+
// same convention as resolveDbSchemaOrExit's own `bskel scan --db` handling. The live
|
|
3256
|
+
// connection itself still only ever opens lazily, per-request (no eager connect-at-startup, no
|
|
3257
|
+
// pool) -- a transient DB outage must not block the unrelated, DB-independent parts of the UI.
|
|
3258
|
+
let dbConfig = null;
|
|
3259
|
+
if (flags['database-url-env']) {
|
|
3260
|
+
if (!process.env[flags['database-url-env']]) {
|
|
3261
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--database-url-env ${flags['database-url-env']} names an environment variable that isn't set -- export it first (never read from .env directly; see D-db-schema-plane in DECISIONS.md)`);
|
|
3262
|
+
}
|
|
3263
|
+
dbConfig = { databaseUrlEnv: flags['database-url-env'], schema: flags.schema, signKeyPath: flags['sign-key'] };
|
|
3264
|
+
// D-ddl-apply: closes this feature's own named "mandatory signing... cheap, well-justified
|
|
3265
|
+
// near-term addition" EXIT item -- an opt-in-to-MORE-strictness knob, letting a cautious team
|
|
3266
|
+
// enforce mandatory signing for themselves without this tool forcing it on every user. A
|
|
3267
|
+
// no-op when --database-url-env wasn't given at all (this whole block never runs then) --
|
|
3268
|
+
// nothing to enforce on a DDL surface that isn't running.
|
|
3269
|
+
if (flags['require-sign-key'] && !dbConfig.signKeyPath) {
|
|
3270
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--require-sign-key was given but --sign-key was not -- refusing to start the DDL surface without an audit-signing key. Pass --sign-key <path>, or drop --require-sign-key to allow starting unsigned (with a warning).');
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
|
|
2750
3274
|
let started;
|
|
2751
3275
|
try {
|
|
2752
|
-
started = await createHttpServer(root, { host: flags.host, port });
|
|
3276
|
+
started = await createHttpServer(root, { host: flags.host, port, dbConfig });
|
|
2753
3277
|
} catch (err) {
|
|
2754
3278
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not start server: ${err.message}`);
|
|
2755
3279
|
}
|
|
2756
3280
|
|
|
2757
3281
|
if (flags.json) {
|
|
2758
|
-
console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root }));
|
|
3282
|
+
console.log(JSON.stringify({ listening: started.url, host: started.host, port: started.port, repo: root, ddlApplyEnabled: Boolean(dbConfig) }));
|
|
2759
3283
|
} else if (!flags.quiet) {
|
|
2760
3284
|
console.log(`bskel serve -- listening on ${started.url}`);
|
|
2761
3285
|
console.log(` UI: ${started.url}/`);
|
|
2762
3286
|
console.log(` API: ${started.url}/api/...`);
|
|
3287
|
+
if (dbConfig) {
|
|
3288
|
+
console.log(` DDL apply routes: ENABLED (schema "${dbConfig.schema}", connection from $${dbConfig.databaseUrlEnv})`);
|
|
3289
|
+
if (!dbConfig.signKeyPath) {
|
|
3290
|
+
console.log(' WARNING: no --sign-key given -- every propose/approve/apply is recorded in specs/ but NOT cryptographically attested. Pass --sign-key <path> to change this.');
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
2763
3293
|
console.log('press Ctrl+C to stop');
|
|
2764
3294
|
}
|
|
2765
3295
|
|
|
@@ -2988,6 +3518,8 @@ async function dispatchCommand(cmd, rest) {
|
|
|
2988
3518
|
case 'scan': {
|
|
2989
3519
|
if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
|
|
2990
3520
|
if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
|
|
3521
|
+
if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
|
|
3522
|
+
if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
|
|
2991
3523
|
await cmdScan(rest);
|
|
2992
3524
|
break;
|
|
2993
3525
|
}
|
|
@@ -3031,6 +3563,16 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3031
3563
|
process.exit(14);
|
|
3032
3564
|
break;
|
|
3033
3565
|
}
|
|
3566
|
+
case 'patch': {
|
|
3567
|
+
if (rest[0] === 'propose') return cmdPatchPropose(rest.slice(1));
|
|
3568
|
+
if (rest[0] === 'approve') return cmdPatchApprove(rest.slice(1));
|
|
3569
|
+
if (rest[0] === 'apply') return cmdPatchApply(rest.slice(1));
|
|
3570
|
+
if (rest[0] === 'rollback') return cmdPatchRollback(rest.slice(1));
|
|
3571
|
+
if (rest[0] === 'list') return cmdPatchList(rest.slice(1));
|
|
3572
|
+
usage();
|
|
3573
|
+
process.exit(14);
|
|
3574
|
+
break;
|
|
3575
|
+
}
|
|
3034
3576
|
case 'catalog': {
|
|
3035
3577
|
if (rest[0] === 'lint') return cmdCatalogLint(rest.slice(1));
|
|
3036
3578
|
usage();
|
|
@@ -3075,6 +3617,13 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3075
3617
|
process.exit(14);
|
|
3076
3618
|
break;
|
|
3077
3619
|
}
|
|
3620
|
+
case 'attest': {
|
|
3621
|
+
if (rest[0] === 'keygen') return cmdAttestKeygen(rest.slice(1));
|
|
3622
|
+
if (rest[0] === 'verify') return cmdAttestVerify(rest.slice(1));
|
|
3623
|
+
usage();
|
|
3624
|
+
process.exit(14);
|
|
3625
|
+
break;
|
|
3626
|
+
}
|
|
3078
3627
|
case 'doctor':
|
|
3079
3628
|
cmdDoctor(rest);
|
|
3080
3629
|
break;
|