backend-skeleton 1.1.1 → 1.3.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 +127 -1
- package/bin/bskel.mjs +526 -20
- package/contracts/csv.mjs +100 -0
- package/handles/providers/java-spring/observe.mjs +15 -1
- package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +38 -0
- package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/ReceiptSigner.java.tmpl +174 -0
- package/handles/providers/python-fastapi/observe.mjs +5 -0
- package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +19 -0
- package/handles/providers/python-fastapi/templates/receipt_sign.py.tmpl +68 -0
- package/handles/providers/typescript-express/observe.mjs +5 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +19 -0
- package/handles/providers/typescript-express/templates/receiptSign.ts.tmpl +65 -0
- package/lib/cli.mjs +78 -2
- package/lib/doctor.mjs +11 -10
- package/lib/field-dependencies.mjs +2 -1
- package/lib/gate-definitions.mjs +8 -4
- package/lib/scan-report-paths.mjs +47 -0
- package/lib/workflow.mjs +6 -0
- package/new/index.mjs +20 -0
- package/package.json +5 -2
- package/patterns/schema.sql +18 -0
- package/patterns/store.mjs +122 -0
- package/scanners/adapters/_express-shared.mjs +7 -9
- package/scanners/adapters/java-spring.mjs +7 -9
- package/scanners/adapters/python-fastapi.mjs +68 -11
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +71 -18
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/conformance-report.schema.json +12 -1
- package/schemas/observe-receipt.schema.json +10 -1
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +7 -3
package/bin/bskel.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, await
|
|
|
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
12
|
import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
|
|
13
|
+
import { hydrateScanReportFilePaths, dehydrateScanReportFilePaths } from '../lib/scan-report-paths.mjs';
|
|
13
14
|
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
15
|
import { withLockSync } from '../lib/lock.mjs';
|
|
15
16
|
import { specDir, specPath, sbfPath } from '../lib/paths.mjs';
|
|
@@ -43,7 +44,8 @@ import {
|
|
|
43
44
|
findCollisions, evaluateCrossFeatureFindings, waiverKey,
|
|
44
45
|
crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
|
|
45
46
|
} from '../lib/cross-feature-collisions.mjs';
|
|
46
|
-
import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting } from '../new/index.mjs';
|
|
47
|
+
import { STACKS as NEW_STACKS, ALL_STACK_PARAMS, stacksAccepting, reusableParamsFor } from '../new/index.mjs';
|
|
48
|
+
import { recordPattern, listPatterns, getPattern, isMissingPatternTable, summarizePatternFrequency } from '../patterns/store.mjs';
|
|
47
49
|
import {
|
|
48
50
|
requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
|
|
49
51
|
requireValidPythonVersion, requireValidLicense, requireValidDatabase, requireSupportedJavaVersion,
|
|
@@ -52,6 +54,8 @@ import {
|
|
|
52
54
|
import { DEFAULT_GROUP_ID, DEFAULT_JAVA_VERSION, resolveSpringDependencies } from '../new/spring.mjs';
|
|
53
55
|
import { buildReconciliation, snapshotFromReconciliation, describeSourceFile } from '../contracts/openapi.mjs';
|
|
54
56
|
import { buildOpenApiDocument, pathPrefixCandidates, unreflectedPathPrefixes, STATUS_CODE_MODES } from '../contracts/export.mjs';
|
|
57
|
+
import { buildContractCsv } from '../contracts/csv.mjs';
|
|
58
|
+
import { buildErdDiagram } from '../scanners/db/erd.mjs';
|
|
55
59
|
import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../stack/apply.mjs';
|
|
56
60
|
import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
|
|
57
61
|
import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
@@ -64,7 +68,7 @@ import { plan as planTypeScriptExpress } from '../handles/providers/typescript-e
|
|
|
64
68
|
import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
|
|
65
69
|
import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
|
|
66
70
|
import { computeWorkflowState } from '../lib/workflow.mjs';
|
|
67
|
-
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
|
|
71
|
+
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS, binaryAvailable } from '../lib/doctor.mjs';
|
|
68
72
|
import { parseCommand, renderCommandHelp, diagnostic } from '../lib/cli.mjs';
|
|
69
73
|
import { EXIT_CODES } from '../lib/exit-codes.mjs';
|
|
70
74
|
import { RESIDUAL_TEMPLATE_VAR_RE } from '../lib/template.mjs';
|
|
@@ -75,11 +79,15 @@ const SKILL_ROOT = path.resolve(__dirname, '..');
|
|
|
75
79
|
function usage() {
|
|
76
80
|
console.error(`bskel -- backend-skeleton CLI
|
|
77
81
|
|
|
78
|
-
bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none]
|
|
82
|
+
bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none] [--record-pattern --pattern-database-url-env <NAME>]
|
|
83
|
+
bskel pattern list --pattern-database-url-env <NAME> [--stack spring|fastapi] [--json]
|
|
84
|
+
bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]
|
|
85
|
+
bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]
|
|
79
86
|
bskel preflight [--max-behind N] [--offline|--no-fetch] [--allow-dirty] [--max-age-minutes N] [--fetch-timeout-seconds N] [--json]
|
|
80
87
|
bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
|
|
81
88
|
bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
|
|
82
89
|
bskel scan explain <module> --feature <id> [--json]
|
|
90
|
+
bskel scan repair --feature <id> [--json]
|
|
83
91
|
bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
|
|
84
92
|
bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
|
|
85
93
|
bskel feature init --slug <name>
|
|
@@ -90,6 +98,8 @@ function usage() {
|
|
|
90
98
|
bskel feature archive <id> --reason "..." [--json]
|
|
91
99
|
bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]
|
|
92
100
|
bskel contract export --feature <id> [--out <path>] [--json] [--allow-unprefixed] [--status-codes range|literal]
|
|
101
|
+
bskel contract export-csv --feature <id> [--out <path>] [--bom] [--json]
|
|
102
|
+
bskel db erd [--database-url-env <NAME>] [--schema public] [--out <path>] [--json]
|
|
93
103
|
bskel contract history --feature <id> [--json]
|
|
94
104
|
bskel contract validate --feature <id> --file <envelope.json>
|
|
95
105
|
bskel contract tool-schema --feature <id> --operation <operationId>
|
|
@@ -109,7 +119,7 @@ function usage() {
|
|
|
109
119
|
bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
|
|
110
120
|
bskel patch list --feature <id> [--json]
|
|
111
121
|
bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
|
|
112
|
-
bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
|
|
122
|
+
bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]
|
|
113
123
|
bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
|
|
114
124
|
bskel status [--feature <id>] [--json]
|
|
115
125
|
bskel next [--feature <id>] [--json]
|
|
@@ -603,8 +613,16 @@ async function cmdScan(args) {
|
|
|
603
613
|
setContext('scan', flags);
|
|
604
614
|
const root = requireRepoRoot();
|
|
605
615
|
const terms = deriveTerms(flags);
|
|
606
|
-
|
|
607
|
-
|
|
616
|
+
// D-zero-config-scan: only refuse when the user EXPLICITLY tried to supply terms (or a
|
|
617
|
+
// --feature) and it resolved to nothing -- a real usage mistake worth catching (a `--terms ""`/
|
|
618
|
+
// `--terms ,,` typo, or a --feature slug that pathologically derives zero words), same as
|
|
619
|
+
// before this item. Neither flag given at all is no longer an error -- it's the new zero-flag
|
|
620
|
+
// "inventory" mode (every module this adapter finds, unscored -- see scanners/index.mjs's
|
|
621
|
+
// runScan()), reachable only because the block below still gates it on `!flags.feature` before
|
|
622
|
+
// any gate/file write happens, exactly like today's ad-hoc mode already does.
|
|
623
|
+
const termsFlagGiven = args.some((a) => a === '--terms' || a.startsWith('--terms='));
|
|
624
|
+
if (terms.length === 0 && (termsFlagGiven || flags.feature)) {
|
|
625
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan [--feature <id>] [--terms a,b,c] (--terms was given but resolved to no real search term -- pass at least one, e.g. --terms organization, or drop --terms entirely for a full unscored inventory of every module this repo\'s adapter finds)');
|
|
608
626
|
}
|
|
609
627
|
if (flags.feature) {
|
|
610
628
|
requireValidFeatureId(flags.feature);
|
|
@@ -612,6 +630,11 @@ async function cmdScan(args) {
|
|
|
612
630
|
}
|
|
613
631
|
|
|
614
632
|
const dbSchema = await resolveDbSchemaOrExit(root, flags);
|
|
633
|
+
// D-zero-config-scan: the same CLI-boundary-resolved-input pattern `dbSchema` above already
|
|
634
|
+
// establishes, applied to a second external dependency -- computed once, used on BOTH the
|
|
635
|
+
// inventory and the --feature-scoped path (a real Spring repo scanned with --feature+--terms is
|
|
636
|
+
// exactly as vulnerable to the silent detect()-time degradation as the zero-flag case).
|
|
637
|
+
const rgAvailable = binaryAvailable('rg');
|
|
615
638
|
|
|
616
639
|
// G1: a broken adapter file doesn't stop the adapters that DID load, but every `scan` run
|
|
617
640
|
// says so loudly (also see `bskel doctor`, which exits 1 while any of these remain).
|
|
@@ -620,7 +643,7 @@ async function cmdScan(args) {
|
|
|
620
643
|
}
|
|
621
644
|
let report;
|
|
622
645
|
try {
|
|
623
|
-
report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema });
|
|
646
|
+
report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable });
|
|
624
647
|
} catch (err) {
|
|
625
648
|
// Unreachable with the two shipped adapters (generic-grep's specificity-0 detect() is
|
|
626
649
|
// unconditional) -- becomes reachable the moment a future adapter's detect() is
|
|
@@ -668,7 +691,12 @@ async function cmdScan(args) {
|
|
|
668
691
|
|
|
669
692
|
const dir = specDir(root, flags.feature);
|
|
670
693
|
fs.mkdirSync(dir, { recursive: true });
|
|
671
|
-
|
|
694
|
+
// D-scan-report-portable-paths: `report`'s `.file` fields stay absolute in memory (every
|
|
695
|
+
// consumer's expected shape, including this same function's OWN JSON stdout print below) --
|
|
696
|
+
// only the ON-DISK copy is converted to repo-relative, so the committed artifact survives
|
|
697
|
+
// being checked out somewhere else (a second worktree, a different clone, CI) without
|
|
698
|
+
// requiring any change to how `.file` is read back in memory once re-hydrated.
|
|
699
|
+
writeScanReportOrExit(specPath(root, flags.feature, 'brownfield-scan.json'), dehydrateScanReportFilePaths(report, root));
|
|
672
700
|
writeFileAtomic(specPath(root, flags.feature, 'brownfield-scan.md'), renderScanMarkdown(report));
|
|
673
701
|
|
|
674
702
|
let gateState;
|
|
@@ -748,7 +776,7 @@ function cmdScanExplain(args) {
|
|
|
748
776
|
if (!moduleName) {
|
|
749
777
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan explain <module> --feature <id> [--json]');
|
|
750
778
|
}
|
|
751
|
-
const report =
|
|
779
|
+
const report = loadHydratedScanReportOrExit(root, flags.feature);
|
|
752
780
|
const mod = report.related_modules.find((m) => m.module === moduleName);
|
|
753
781
|
if (!mod) {
|
|
754
782
|
const known = report.related_modules.map((m) => m.module).join(', ') || '(none)';
|
|
@@ -762,6 +790,60 @@ function cmdScanExplain(args) {
|
|
|
762
790
|
process.exit(0);
|
|
763
791
|
}
|
|
764
792
|
|
|
793
|
+
// D-scan-report-portable-paths: a one-time, non-destructive migration for a committed
|
|
794
|
+
// brownfield-scan.json written BEFORE this fix (schema "sbf.scan-report/1", `.file` absolute) --
|
|
795
|
+
// `bskel scan`'s own re-run is NOT a substitute here: runScan() never carries `disposition`
|
|
796
|
+
// forward, so re-scanning would silently wipe an already-disposed feature's disposition and
|
|
797
|
+
// cascade `scan`/`contract`/`handles` gates back to stale/awaiting_disposition. This command
|
|
798
|
+
// touches ONLY `.file` strings and `schema` -- `disposition` and everything else stay
|
|
799
|
+
// byte-identical. Must run from the SAME location the original `bskel scan` ran from (a fresh
|
|
800
|
+
// worktree's absolute paths won't resolve to anything real) -- fails closed, naming the exact
|
|
801
|
+
// file, rather than guessing at a mapping.
|
|
802
|
+
function cmdScanRepair(args) {
|
|
803
|
+
const flags = parseCommand('scan repair', args);
|
|
804
|
+
if (flags.help) { console.log(renderCommandHelp('scan repair')); process.exit(0); }
|
|
805
|
+
setContext('scan repair', flags);
|
|
806
|
+
const root = requireRepoRoot();
|
|
807
|
+
requireValidFeatureId(flags.feature);
|
|
808
|
+
|
|
809
|
+
// Deliberately NOT loadScanReportOrExit() -- that validates against the CURRENT schema const
|
|
810
|
+
// ("sbf.scan-report/2"), which a genuinely old ("/1") report can never match by definition.
|
|
811
|
+
// This command's whole job is repairing exactly that mismatch, so it reads raw here.
|
|
812
|
+
const reportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
813
|
+
if (!fs.existsSync(reportPath)) {
|
|
814
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${reportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
|
|
815
|
+
}
|
|
816
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
817
|
+
if (report.schema !== 'sbf.scan-report/1') {
|
|
818
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.feature}'s scan report is already schema "${report.schema}" -- nothing to repair (this command only migrates a "sbf.scan-report/1" report's absolute \`.file\` paths to repo-relative).`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const unresolvable = [];
|
|
822
|
+
for (const mod of report.related_modules ?? []) {
|
|
823
|
+
for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
|
|
824
|
+
for (const item of mod[key] ?? []) {
|
|
825
|
+
if (item.file && !fs.existsSync(item.file)) unresolvable.push(item.file);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (unresolvable.length > 0) {
|
|
830
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to guess: ${unresolvable.length} file(s) referenced in this scan report do not exist under the CURRENT root -- re-run this command from the exact location \`bskel scan\` originally ran from:\n${unresolvable.map((f) => ` ${f}`).join('\n')}`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// D-scan-report-portable-paths: same transform `cmdScan` applies at every real write -- a
|
|
834
|
+
// legacy report is, by definition, exactly the "still absolute" case that needs converting.
|
|
835
|
+
const repaired = dehydrateScanReportFilePaths(report, root);
|
|
836
|
+
repaired.schema = 'sbf.scan-report/2';
|
|
837
|
+
writeScanReportOrExit(reportPath, repaired);
|
|
838
|
+
|
|
839
|
+
if (flags.json) {
|
|
840
|
+
console.log(JSON.stringify({ feature_id: flags.feature, schema: repaired.schema, repaired: true }, null, 2));
|
|
841
|
+
} else {
|
|
842
|
+
console.log(`repaired: ${flags.feature}'s scan report is now schema "${repaired.schema}" (disposition and every other field untouched)`);
|
|
843
|
+
}
|
|
844
|
+
process.exit(0);
|
|
845
|
+
}
|
|
846
|
+
|
|
765
847
|
// D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
|
|
766
848
|
// blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
|
|
767
849
|
// identity collisions against every OTHER feature, not this feature's own contract completeness).
|
|
@@ -1194,11 +1276,11 @@ function cmdContractEmit(args) {
|
|
|
1194
1276
|
});
|
|
1195
1277
|
}
|
|
1196
1278
|
|
|
1279
|
+
// D-scan-report-portable-paths: was its own inline fs.existsSync/JSON.parse here, duplicating
|
|
1280
|
+
// (and bypassing) loadScanReportOrExit()'s schema validation -- consolidated onto the shared,
|
|
1281
|
+
// hydrated loader, closing both that gap and the portability bug the hydration itself fixes.
|
|
1197
1282
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
1198
|
-
|
|
1199
|
-
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${scanReportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
|
|
1200
|
-
}
|
|
1201
|
-
const scanReport = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
|
|
1283
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
1202
1284
|
requireCapabilitiesOrExit(scanReport, 'contract emit', {
|
|
1203
1285
|
featureId: flags.feature,
|
|
1204
1286
|
scanReportPath,
|
|
@@ -1528,6 +1610,181 @@ function cmdContractExport(args) {
|
|
|
1528
1610
|
process.exitCode = EXIT.PASS;
|
|
1529
1611
|
}
|
|
1530
1612
|
|
|
1613
|
+
// D-contract-csv: the soft-refusal counterpart to loadScanReportOrExit() above -- returns null
|
|
1614
|
+
// (skip the path-prefix check entirely) on ANY failure (missing file, unparseable JSON, schema
|
|
1615
|
+
// violation) instead of exiting. `contract export-csv` treats this check as advisory (see C5 in
|
|
1616
|
+
// D-contract-csv, DECISIONS.md): a scan report that can't be read is not itself a reason to block a
|
|
1617
|
+
// human-reviewed artifact, unlike `contract export`'s own hard guard for a machine-consumed one.
|
|
1618
|
+
function tryLoadScanReport(root, featureId) {
|
|
1619
|
+
const scanReportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
1620
|
+
if (!fs.existsSync(scanReportPath)) return null;
|
|
1621
|
+
try {
|
|
1622
|
+
const parsed = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
|
|
1623
|
+
const { ok } = validateAgainstSchema('scan-report.schema.json', parsed);
|
|
1624
|
+
return ok ? parsed : null;
|
|
1625
|
+
} catch {
|
|
1626
|
+
return null;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// D-contract-csv: a spreadsheet-shaped projection of a feature contract. Mirrors
|
|
1631
|
+
// cmdContractExport's overall shape (loadContract, zero-operation refusal, --out/stdout via
|
|
1632
|
+
// writeFileAtomic) but is deliberately UNGATED -- it never calls requireNamedGate('contract', ...)
|
|
1633
|
+
// and never hard-refuses on an unreflected path prefix, only warns. See D-contract-csv in
|
|
1634
|
+
// DECISIONS.md for the full risk-model argument (a CSV is read by a human deciding whether to
|
|
1635
|
+
// waive a partial contract -- gating it would make it useless exactly when it matters).
|
|
1636
|
+
function cmdContractExportCsv(args) {
|
|
1637
|
+
const flags = parseCommand('contract export-csv', args);
|
|
1638
|
+
if (flags.help) { console.log(renderCommandHelp('contract export-csv')); process.exit(0); }
|
|
1639
|
+
setContext('contract export-csv', flags);
|
|
1640
|
+
const root = requireRepoRoot();
|
|
1641
|
+
requireValidFeatureId(flags.feature);
|
|
1642
|
+
|
|
1643
|
+
const contract = loadContract(root, flags.feature);
|
|
1644
|
+
|
|
1645
|
+
// Same positive-false-claim refusal `contract export` itself makes (see that function's own
|
|
1646
|
+
// comment) -- a zero-row CSV handed to a stakeholder reads as "this feature has no API".
|
|
1647
|
+
if (Object.keys(contract.operations).length === 0) {
|
|
1648
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `\`${flags.feature}\`'s contract has zero operations (completeness: ${contract.completeness.status}) -- exporting it would produce a table positively claiming this API has no operations. Fix --module/--terms and re-run \`bskel contract emit --feature ${flags.feature}\`.`);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// C5 (D-contract-csv): advisory only. If the scan report cannot be read, the check is skipped
|
|
1652
|
+
// silently rather than refusing (contrast cmdContractExport's loadScanReportOrExit(), which hard-exits).
|
|
1653
|
+
let pathPrefixWarning = null;
|
|
1654
|
+
const scanReport = tryLoadScanReport(root, flags.feature);
|
|
1655
|
+
if (scanReport) {
|
|
1656
|
+
const candidates = pathPrefixCandidates(scanReport.path_prefix_signals);
|
|
1657
|
+
const unreflected = unreflectedPathPrefixes(contract, candidates);
|
|
1658
|
+
if (unreflected.length > 0) {
|
|
1659
|
+
pathPrefixWarning = `this repo's scan found a global path-prefix signal (${unreflected.join(', ')}) that ${flags.feature}'s contract paths do not reflect -- the paths in this CSV may be missing it. Re-run \`bskel contract emit --feature ${flags.feature} --openapi-file <real-generated-doc>\` to correct them, or treat this export as informational only.`;
|
|
1660
|
+
console.error(`note: ${pathPrefixWarning}`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const built = buildContractCsv({ contract });
|
|
1665
|
+
if (!built.ok) {
|
|
1666
|
+
// Unreachable given the zero-operation refusal above already covers this case -- kept as
|
|
1667
|
+
// a real check rather than assuming buildContractCsv()'s precondition forever.
|
|
1668
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `cannot export ${flags.feature}'s contract to CSV: ${built.error}`);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// C2 (D-contract-csv): unconditional -- a reader must not have to guess whether a blank column
|
|
1672
|
+
// means "nothing to show" or "the tool is broken".
|
|
1673
|
+
if (built.emptyColumns.length > 0) {
|
|
1674
|
+
console.error(`note: ${built.emptyColumns.length} of ${built.columns.length} columns are empty for every operation (${built.emptyColumns.join(', ')}) -- this contract was emitted without --openapi-file, so no source document ever stated them. Re-run \`bskel contract emit --feature ${flags.feature} --openapi-file <doc>\` to populate them.`);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// C4 (D-contract-csv): BOM is opt-in -- Excel-on-Windows mangles non-ASCII without one, but a
|
|
1678
|
+
// BOM breaks naive parsers/`head`/`diff` for everyone else, so neither default is right for everyone.
|
|
1679
|
+
const csvText = flags.bom ? `\uFEFF${built.csv}` : built.csv;
|
|
1680
|
+
|
|
1681
|
+
if (flags.out) {
|
|
1682
|
+
const outPath = path.resolve(process.cwd(), flags.out);
|
|
1683
|
+
writeFileAtomic(outPath, csvText);
|
|
1684
|
+
if (flags.json) {
|
|
1685
|
+
console.log(JSON.stringify({
|
|
1686
|
+
schema: 'sbf.contract-export-csv/1',
|
|
1687
|
+
feature_id: contract.feature_id,
|
|
1688
|
+
out: flags.out,
|
|
1689
|
+
row_count: built.rowCount,
|
|
1690
|
+
columns: built.columns,
|
|
1691
|
+
completeness: contract.completeness.status,
|
|
1692
|
+
path_prefix_warning: pathPrefixWarning,
|
|
1693
|
+
}, null, 2));
|
|
1694
|
+
} else if (!flags.quiet) {
|
|
1695
|
+
console.log(`wrote ${flags.out} -- ${built.rowCount} operation(s), completeness: ${contract.completeness.status}`);
|
|
1696
|
+
}
|
|
1697
|
+
} else {
|
|
1698
|
+
// process.stdout.write, not console.log -- `csvText` already ends in exactly one `\n`
|
|
1699
|
+
// (contracts/csv.mjs's toCsv()), and console.log would append a SECOND one, making stdout
|
|
1700
|
+
// byte-different from the file --out writes. C7 (D-contract-csv): the artifact is the only
|
|
1701
|
+
// thing on stdout here.
|
|
1702
|
+
process.stdout.write(csvText);
|
|
1703
|
+
if (flags.json) {
|
|
1704
|
+
console.error('note: --json has no effect without --out -- stdout is the CSV itself. Pass --out <path> to get both.');
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
// D-process-exit-audit: NOT process.exit() -- same reasoning as cmdContractExport's own
|
|
1708
|
+
// trailing comment; a 300-operation CSV can clear the 64KB pipe buffer just as easily.
|
|
1709
|
+
process.exitCode = EXIT.PASS;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// D-db-erd: a Mermaid `erDiagram` of the database plane -- repo-independent like `bskel new`/
|
|
1713
|
+
// `bskel pattern *` (no --feature; the database plane is not feature-scoped, see E6 in D-db-erd,
|
|
1714
|
+
// DECISIONS.md). Reuses resolveDbSchemaOrExit() unchanged (via a synthetic `db: true`) so
|
|
1715
|
+
// --database-url-env's env-var handling and every error string stay byte-identical to `scan --db`.
|
|
1716
|
+
async function cmdDbErd(args) {
|
|
1717
|
+
const flags = parseCommand('db erd', args);
|
|
1718
|
+
if (flags.help) { console.log(renderCommandHelp('db erd')); process.exit(0); }
|
|
1719
|
+
setContext('db erd', flags);
|
|
1720
|
+
const root = requireRepoRoot();
|
|
1721
|
+
|
|
1722
|
+
// E1 (D-db-erd)'s own `--db` flag doesn't exist on this command -- the verb `db erd` implies
|
|
1723
|
+
// it, so a synthetic `db: true` is threaded through to the exact same helper `scan --db` uses,
|
|
1724
|
+
// never a second copy of its env-var-unset/connection-failure handling.
|
|
1725
|
+
const { live, migrations } = await resolveDbSchemaOrExit(root, { ...flags, db: true });
|
|
1726
|
+
|
|
1727
|
+
if (!live && (!migrations || migrations.tables.length === 0)) {
|
|
1728
|
+
if (migrations && migrations.tool === 'liquibase') {
|
|
1729
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no schema to draw: this repo's Liquibase changelogs were detected (${migrations.files.length} file(s)) but none are plain .sql -- XML/YAML changelog parsing is not supported (see D-db-schema-plane in DECISIONS.md). Pass --database-url-env <NAME> for live introspection instead.`);
|
|
1730
|
+
}
|
|
1731
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'no schema to draw: no Flyway/Liquibase migration files found in this repo, and no --database-url-env was given. Pass --database-url-env <NAME> (an already-exported environment variable; never read from .env directly -- see D-db-schema-plane in DECISIONS.md) for live introspection.');
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
const version = JSON.parse(fs.readFileSync(path.join(SKILL_ROOT, 'package.json'), 'utf8')).version;
|
|
1735
|
+
const invocation = flags['database-url-env']
|
|
1736
|
+
? `bskel db erd --database-url-env ${flags['database-url-env']} --schema ${flags.schema}`
|
|
1737
|
+
: 'bskel db erd';
|
|
1738
|
+
const built = buildErdDiagram({ live, migrations, generatedBy: `bskel ${version} -- \`${invocation}\`` });
|
|
1739
|
+
if (!built.ok) {
|
|
1740
|
+
// Unreachable given the refusal above already covers both reasons buildErdDiagram() can
|
|
1741
|
+
// report -- kept as a real check rather than assuming its precondition forever.
|
|
1742
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no schema to draw (${built.reason})`);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// E2 (D-db-erd): unconditional when degraded -- a reader must see this before the diagram, not
|
|
1746
|
+
// discover it by noticing every type says `unknown`.
|
|
1747
|
+
if (built.degraded) {
|
|
1748
|
+
console.error(`note: this diagram is DEGRADED -- built from migration files, not a live database. Missing: ${built.missing.join(', ')}. Pass --database-url-env <NAME> for a complete diagram.`);
|
|
1749
|
+
}
|
|
1750
|
+
// E6 (D-db-erd): a readability nudge only, never a refusal -- whole-schema is the only mode
|
|
1751
|
+
// this version supports.
|
|
1752
|
+
if (built.entityCount > 40) {
|
|
1753
|
+
console.error(`note: ${built.entityCount} entities -- this diagram may be dense (whole-schema only in this version; see E6 in D-db-erd, DECISIONS.md).`);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (flags.out) {
|
|
1757
|
+
const outPath = path.resolve(process.cwd(), flags.out);
|
|
1758
|
+
writeFileAtomic(outPath, built.mermaid);
|
|
1759
|
+
if (flags.json) {
|
|
1760
|
+
console.log(JSON.stringify({
|
|
1761
|
+
schema: 'sbf.db-erd/1',
|
|
1762
|
+
out: flags.out,
|
|
1763
|
+
plane: built.plane,
|
|
1764
|
+
entity_count: built.entityCount,
|
|
1765
|
+
relationship_count: built.relationshipCount,
|
|
1766
|
+
unresolved_relationships: built.unresolvedRelationships,
|
|
1767
|
+
external_tables: built.externalTables,
|
|
1768
|
+
renames: built.renames,
|
|
1769
|
+
degraded: built.degraded,
|
|
1770
|
+
missing: built.missing,
|
|
1771
|
+
}, null, 2));
|
|
1772
|
+
} else if (!flags.quiet) {
|
|
1773
|
+
console.log(`wrote ${flags.out} -- ${built.entityCount} entity(ies), ${built.relationshipCount} relationship(s), plane: ${built.plane}${built.degraded ? ' (degraded)' : ''}`);
|
|
1774
|
+
}
|
|
1775
|
+
} else {
|
|
1776
|
+
// process.stdout.write, not console.log -- same E10 (D-db-erd) byte-exactness reasoning as
|
|
1777
|
+
// cmdContractExportCsv's own C7 (built.mermaid already ends in exactly one `\n`).
|
|
1778
|
+
process.stdout.write(built.mermaid);
|
|
1779
|
+
if (flags.json) {
|
|
1780
|
+
console.error('note: --json has no effect without --out -- stdout is the diagram itself. Pass --out <path> to get both.');
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
// D-process-exit-audit: NOT process.exit() -- a 200-table diagram can clear the 64KB pipe
|
|
1784
|
+
// buffer just as easily as a schema-rich OpenAPI export.
|
|
1785
|
+
process.exitCode = EXIT.PASS;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1531
1788
|
// A5: the `scan disposition` of contracts -- lets a human explicitly accept a `partial`
|
|
1532
1789
|
// contract's outstanding warnings so the `contract` gate can pass. Deliberately no wildcard
|
|
1533
1790
|
// waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
|
|
@@ -2101,6 +2358,19 @@ function loadScanReportOrExit(root, featureId) {
|
|
|
2101
2358
|
return parsed;
|
|
2102
2359
|
}
|
|
2103
2360
|
|
|
2361
|
+
// D-scan-report-portable-paths: the read-only sibling of loadScanReportOrExit() -- every consumer
|
|
2362
|
+
// that only READS the report (never writes it back) should go through this instead, so its
|
|
2363
|
+
// `related_modules[].{controllers,entities,enums,dtos}[].file` values are correctly re-anchored to
|
|
2364
|
+
// THIS root before anything downstream (handles codegen, contract emission, gate recomputation)
|
|
2365
|
+
// touches them. Deliberately NOT folded into loadScanReportOrExit() itself: cmdScanDisposition()
|
|
2366
|
+
// does load -> mutate `.disposition` -> write back the WHOLE object -- if hydration lived in the
|
|
2367
|
+
// base loader, that round trip would silently re-persist re-absolutized paths to disk, resurrecting
|
|
2368
|
+
// the exact portability bug this closes. Any future read-modify-write command must stay on the raw
|
|
2369
|
+
// loader for the same reason.
|
|
2370
|
+
function loadHydratedScanReportOrExit(root, featureId) {
|
|
2371
|
+
return hydrateScanReportFilePaths(loadScanReportOrExit(root, featureId), root);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2104
2374
|
// S5 (D-persistence-integrity): the write-side sibling of loadScanReportOrExit() above -- validated
|
|
2105
2375
|
// before it ever touches disk, same "fail loud here, not as a confusing error somewhere later"
|
|
2106
2376
|
// reasoning as lib/state.mjs's saveState(). Used by both cmdScan()'s own write and
|
|
@@ -2225,7 +2495,7 @@ async function cmdHandlesPlan(args) {
|
|
|
2225
2495
|
if (flags.help) { console.log(renderCommandHelp('handles plan')); process.exit(0); }
|
|
2226
2496
|
setContext('handles plan', flags);
|
|
2227
2497
|
const root = requireRepoRoot();
|
|
2228
|
-
const scanReport =
|
|
2498
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2229
2499
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2230
2500
|
requireCapabilitiesOrExit(scanReport, 'handles plan', { featureId: flags.feature, scanReportPath });
|
|
2231
2501
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2360,7 +2630,7 @@ function cmdHandlesEmit(args) {
|
|
|
2360
2630
|
});
|
|
2361
2631
|
}
|
|
2362
2632
|
|
|
2363
|
-
const scanReport =
|
|
2633
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2364
2634
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2365
2635
|
requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
|
|
2366
2636
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2524,7 +2794,7 @@ function cmdHandlesPatchApprove(args) {
|
|
|
2524
2794
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch approve requires --reason "..." -- every approval must be auditable');
|
|
2525
2795
|
}
|
|
2526
2796
|
|
|
2527
|
-
const scanReport =
|
|
2797
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2528
2798
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2529
2799
|
requireCapabilitiesOrExit(scanReport, 'handles patch approve', { featureId: flags.feature, scanReportPath });
|
|
2530
2800
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2800,7 +3070,7 @@ async function cmdHandlesAudit(args) {
|
|
|
2800
3070
|
// live database rather than a static regex proxy.
|
|
2801
3071
|
let registryCoverage = null;
|
|
2802
3072
|
if (flags['check-registry-coverage']) {
|
|
2803
|
-
const scanReport =
|
|
3073
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2804
3074
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2805
3075
|
requireCapabilitiesOrExit(scanReport, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
|
|
2806
3076
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2875,7 +3145,7 @@ function cmdObserveEmit(args) {
|
|
|
2875
3145
|
});
|
|
2876
3146
|
}
|
|
2877
3147
|
|
|
2878
|
-
const scanReport =
|
|
3148
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2879
3149
|
const contract = loadContract(root, flags.feature);
|
|
2880
3150
|
const dryRun = flags.check || flags.diff;
|
|
2881
3151
|
|
|
@@ -2993,6 +3263,20 @@ function cmdObserveImport(args) {
|
|
|
2993
3263
|
const flags = parseCommand('observe import', args);
|
|
2994
3264
|
if (flags.help) { console.log(renderCommandHelp('observe import')); process.exit(0); }
|
|
2995
3265
|
setContext('observe import', flags);
|
|
3266
|
+
if (flags['require-signature'] && !flags.pubkey) {
|
|
3267
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--require-signature was given but --pubkey was not -- refusing to require a signature this command has no way to check. Pass --pubkey <path>, or drop --require-signature to allow unsigned/unverified receipts (with a warning).');
|
|
3268
|
+
}
|
|
3269
|
+
// Mirrors `cmdAttestVerify`'s own pubkey-read pattern exactly. `null` (not given) means: don't
|
|
3270
|
+
// verify at all -- a signature field, if present on a receipt, is simply ignored, matching
|
|
3271
|
+
// today's behavior byte-for-byte (full backward compatibility with every already-deployed app).
|
|
3272
|
+
let pubkeyPem = null;
|
|
3273
|
+
if (flags.pubkey) {
|
|
3274
|
+
try {
|
|
3275
|
+
pubkeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
|
|
3276
|
+
} catch (err) {
|
|
3277
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
|
|
3278
|
+
}
|
|
3279
|
+
}
|
|
2996
3280
|
const root = requireRepoRoot();
|
|
2997
3281
|
requirePreflightPassed(root);
|
|
2998
3282
|
|
|
@@ -3044,12 +3328,50 @@ function cmdObserveImport(args) {
|
|
|
3044
3328
|
}
|
|
3045
3329
|
|
|
3046
3330
|
const currentContractHash = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
|
|
3331
|
+
|
|
3332
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): verification always
|
|
3333
|
+
// happens here, in Node, regardless of which language's runtime produced+signed the receipt --
|
|
3334
|
+
// lib/attest.mjs's verifyPayload() is reused completely unmodified (already payload-shape-
|
|
3335
|
+
// agnostic, the same reuse D-ddl-apply's own maybeSignStep() already established for a
|
|
3336
|
+
// different payload shape). `unsigned` is computed regardless of --pubkey (forward visibility);
|
|
3337
|
+
// `signature_invalid` is only ever non-zero when --pubkey was given -- there is no key to judge
|
|
3338
|
+
// a signature against otherwise. Without --pubkey, every receipt is trusted exactly like today
|
|
3339
|
+
// (a signature field, if present, is never even looked at) -- full backward compatibility.
|
|
3340
|
+
let unsignedCount = 0;
|
|
3341
|
+
let signatureInvalidCount = 0;
|
|
3342
|
+
const trustedByIndex = receipts.map((r) => {
|
|
3343
|
+
const hasSignature = Boolean(r.signature);
|
|
3344
|
+
if (!hasSignature) unsignedCount++;
|
|
3345
|
+
if (!pubkeyPem) return true;
|
|
3346
|
+
if (!hasSignature) return false;
|
|
3347
|
+
const { signature, ...unsigned } = r;
|
|
3348
|
+
const valid = verifyPayload(unsigned, signature.value, pubkeyPem);
|
|
3349
|
+
if (!valid) signatureInvalidCount++;
|
|
3350
|
+
return valid;
|
|
3351
|
+
});
|
|
3352
|
+
if (flags['require-signature']) {
|
|
3353
|
+
const badIndex = trustedByIndex.findIndex((trusted) => !trusted);
|
|
3354
|
+
if (badIndex !== -1) {
|
|
3355
|
+
const bad = receipts[badIndex];
|
|
3356
|
+
const reason = bad.signature ? 'its signature does not verify against --pubkey' : 'it has no signature at all';
|
|
3357
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a receipt for operation "${bad.operation_id}" is untrusted -- ${reason}. Aborting the whole import (--require-signature demands every receipt verify, and a corrupted/untrusted receipts file must not partially land).`);
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
if (pubkeyPem && unsignedCount > 0 && !flags.quiet) {
|
|
3361
|
+
console.error(`WARNING: ${unsignedCount} receipt(s) have no signature -- excluded from matched/violation counts now that --pubkey is checking signatures. Pass --require-signature to make this a hard failure instead.`);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
// Untrusted receipts (unsigned or signature-invalid, only possible when --pubkey was given)
|
|
3365
|
+
// are excluded from EVERY count below -- not "noise" (non-JSON garbage) and not "corruption"
|
|
3366
|
+
// (schema-invalid, aborts the whole import), a genuinely new third tier alongside
|
|
3367
|
+
// stale_contract_ref's own existing "kept on record, excluded from current evidence" precedent.
|
|
3047
3368
|
let matched = 0;
|
|
3048
3369
|
let staleContractRef = 0;
|
|
3049
3370
|
let violationCount = 0;
|
|
3050
3371
|
let unsupportedCount = 0;
|
|
3051
3372
|
const byOperation = {};
|
|
3052
|
-
|
|
3373
|
+
receipts.forEach((r, i) => {
|
|
3374
|
+
if (!trustedByIndex[i]) return;
|
|
3053
3375
|
const isMatched = r.contract_ref === currentContractHash;
|
|
3054
3376
|
if (isMatched) matched++; else staleContractRef++;
|
|
3055
3377
|
const opStats = byOperation[r.operation_id] ?? { matched: 0, stale_contract_ref: 0, violations: 0 };
|
|
@@ -3059,7 +3381,7 @@ function cmdObserveImport(args) {
|
|
|
3059
3381
|
if (isMatched) opStats.violations++;
|
|
3060
3382
|
}
|
|
3061
3383
|
byOperation[r.operation_id] = opStats;
|
|
3062
|
-
}
|
|
3384
|
+
});
|
|
3063
3385
|
|
|
3064
3386
|
const report = {
|
|
3065
3387
|
sbf_conformance_report: '1',
|
|
@@ -3073,8 +3395,11 @@ function cmdObserveImport(args) {
|
|
|
3073
3395
|
matched, stale_contract_ref: staleContractRef,
|
|
3074
3396
|
violations: violationCount,
|
|
3075
3397
|
unsupported: unsupportedCount,
|
|
3398
|
+
unsigned: unsignedCount,
|
|
3399
|
+
signature_invalid: signatureInvalidCount,
|
|
3076
3400
|
},
|
|
3077
3401
|
by_operation: byOperation,
|
|
3402
|
+
verification: { pubkey_given: Boolean(pubkeyPem), require_signature: Boolean(flags['require-signature']) },
|
|
3078
3403
|
};
|
|
3079
3404
|
const { ok: reportOk, errors: reportErrors } = validateAgainstSchema('conformance-report.schema.json', report);
|
|
3080
3405
|
if (!reportOk) {
|
|
@@ -3103,6 +3428,9 @@ function cmdObserveImport(args) {
|
|
|
3103
3428
|
console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
|
|
3104
3429
|
} else {
|
|
3105
3430
|
console.log(`imported ${receipts.length} receipt(s) (${matched} matched the current contract, ${staleContractRef} stale, ${noiseLines} noise line(s) skipped)`);
|
|
3431
|
+
if (pubkeyPem) {
|
|
3432
|
+
console.log(`signatures: ${unsignedCount} unsigned, ${signatureInvalidCount} invalid (both excluded from the counts above)`);
|
|
3433
|
+
}
|
|
3106
3434
|
console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
|
|
3107
3435
|
console.log(`wrote ${path.relative(root, reportPath)}`);
|
|
3108
3436
|
console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
|
|
@@ -3485,6 +3813,132 @@ async function resolveNewParams(stack, flags) {
|
|
|
3485
3813
|
};
|
|
3486
3814
|
}
|
|
3487
3815
|
|
|
3816
|
+
// D-pattern-accrual: all three pattern commands are repo-independent, like `bskel new` itself --
|
|
3817
|
+
// a pattern store is a user-owned CROSS-PROJECT resource, never scoped to the current repo/feature,
|
|
3818
|
+
// so none of them call requireRepoRoot(). Read-only; --pattern-database-url-env is required on all
|
|
3819
|
+
// three (mirrors O7's `handles audit` -- there is no meaningful "run without a live connection" mode).
|
|
3820
|
+
async function cmdPatternList(args) {
|
|
3821
|
+
const flags = parseCommand('pattern list', args);
|
|
3822
|
+
if (flags.help) { console.log(renderCommandHelp('pattern list')); process.exit(0); }
|
|
3823
|
+
setContext('pattern list', flags);
|
|
3824
|
+
|
|
3825
|
+
const connectionString = process.env[flags['pattern-database-url-env']];
|
|
3826
|
+
if (!connectionString) {
|
|
3827
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--pattern-database-url-env ${flags['pattern-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)`);
|
|
3828
|
+
}
|
|
3829
|
+
if (flags.stack && !NEW_STACKS[flags.stack]) {
|
|
3830
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--stack must be one of: ${Object.keys(NEW_STACKS).join(', ')} (got ${JSON.stringify(flags.stack)})`);
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
let records;
|
|
3834
|
+
try {
|
|
3835
|
+
records = await listPatterns({ connectionString, stack: flags.stack });
|
|
3836
|
+
} catch (err) {
|
|
3837
|
+
if (isMissingPatternTable(err)) {
|
|
3838
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', 'sbf_pattern does not exist in this database -- run patterns/schema.sql against it first (bskel never applies it automatically; see D-migration-scope in DECISIONS.md).');
|
|
3839
|
+
}
|
|
3840
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query the pattern store: ${describeConnectionError(err)}`);
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
if (flags.json) {
|
|
3844
|
+
console.log(JSON.stringify({ records }, null, 2));
|
|
3845
|
+
} else {
|
|
3846
|
+
console.log(`pattern store -- ${records.length} record(s)${flags.stack ? ` (stack: ${flags.stack})` : ''}`);
|
|
3847
|
+
for (const r of records) {
|
|
3848
|
+
console.log(` ${r.pattern_id} [${r.stack}] ${r.recorded_at} ${JSON.stringify(r.params)}`);
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
process.exit(0);
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
async function cmdPatternShow(args) {
|
|
3855
|
+
const flags = parseCommand('pattern show', args);
|
|
3856
|
+
if (flags.help) { console.log(renderCommandHelp('pattern show')); process.exit(0); }
|
|
3857
|
+
setContext('pattern show', flags);
|
|
3858
|
+
const patternId = flags._[0];
|
|
3859
|
+
if (!patternId) {
|
|
3860
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel pattern show <pattern_id> --pattern-database-url-env <NAME>');
|
|
3861
|
+
}
|
|
3862
|
+
const connectionString = process.env[flags['pattern-database-url-env']];
|
|
3863
|
+
if (!connectionString) {
|
|
3864
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--pattern-database-url-env ${flags['pattern-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)`);
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
let record;
|
|
3868
|
+
try {
|
|
3869
|
+
record = await getPattern({ connectionString, patternId });
|
|
3870
|
+
} catch (err) {
|
|
3871
|
+
if (isMissingPatternTable(err)) {
|
|
3872
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', 'sbf_pattern does not exist in this database -- run patterns/schema.sql against it first (bskel never applies it automatically; see D-migration-scope in DECISIONS.md).');
|
|
3873
|
+
}
|
|
3874
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query the pattern store: ${describeConnectionError(err)}`);
|
|
3875
|
+
}
|
|
3876
|
+
if (!record) {
|
|
3877
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no pattern record with id ${patternId}`);
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
if (flags.json) {
|
|
3881
|
+
console.log(JSON.stringify(record, null, 2));
|
|
3882
|
+
} else {
|
|
3883
|
+
console.log(`pattern ${record.pattern_id} [${record.stack}] recorded ${record.recorded_at}`);
|
|
3884
|
+
for (const [k, v] of Object.entries(record.params)) console.log(` --${k} ${v}`);
|
|
3885
|
+
}
|
|
3886
|
+
process.exit(0);
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
// D-pattern-accrual: `suggest`'s output is TEXT only -- a per-value frequency breakdown, honest about
|
|
3890
|
+
// disagreement, plus one paste-ready command line built from the top-ranked value per param. Never
|
|
3891
|
+
// fed into `bskel new` as a default; `bskel new` has no flag that would accept it as one (see
|
|
3892
|
+
// D-pattern-accrual's WHY for why this is the one design decision that keeps this feature inside
|
|
3893
|
+
// D-greenfield-parameters' safe/unsafe line).
|
|
3894
|
+
async function cmdPatternSuggest(args) {
|
|
3895
|
+
const flags = parseCommand('pattern suggest', args);
|
|
3896
|
+
if (flags.help) { console.log(renderCommandHelp('pattern suggest')); process.exit(0); }
|
|
3897
|
+
setContext('pattern suggest', flags);
|
|
3898
|
+
|
|
3899
|
+
const stack = NEW_STACKS[flags.stack];
|
|
3900
|
+
if (!stack) {
|
|
3901
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--stack must be one of: ${Object.keys(NEW_STACKS).join(', ')} (got ${JSON.stringify(flags.stack)})`);
|
|
3902
|
+
}
|
|
3903
|
+
const connectionString = process.env[flags['pattern-database-url-env']];
|
|
3904
|
+
if (!connectionString) {
|
|
3905
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--pattern-database-url-env ${flags['pattern-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)`);
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
let records;
|
|
3909
|
+
try {
|
|
3910
|
+
records = await listPatterns({ connectionString, stack: flags.stack });
|
|
3911
|
+
} catch (err) {
|
|
3912
|
+
if (isMissingPatternTable(err)) {
|
|
3913
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', 'sbf_pattern does not exist in this database -- run patterns/schema.sql against it first (bskel never applies it automatically; see D-migration-scope in DECISIONS.md).');
|
|
3914
|
+
}
|
|
3915
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query the pattern store: ${describeConnectionError(err)}`);
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
const summary = summarizePatternFrequency(records, stack.reusableParams);
|
|
3919
|
+
const suggestedFlags = summary.map(({ param, values }) => `--${param} ${values[0].value}`).join(' ');
|
|
3920
|
+
const suggestedCommand = `bskel new --stack ${stack.id} --slug <name>${suggestedFlags ? ` ${suggestedFlags}` : ''}`;
|
|
3921
|
+
|
|
3922
|
+
if (flags.json) {
|
|
3923
|
+
console.log(JSON.stringify({ stack: stack.id, total_records: records.length, summary, suggested_command: suggestedCommand }, null, 2));
|
|
3924
|
+
} else {
|
|
3925
|
+
console.log(`pattern suggest -- ${records.length} recorded ${stack.id} run(s)`);
|
|
3926
|
+
if (records.length === 0) {
|
|
3927
|
+
console.log(' (no patterns recorded yet for this stack -- nothing to suggest)');
|
|
3928
|
+
} else {
|
|
3929
|
+
for (const { param, values } of summary) {
|
|
3930
|
+
for (const { value, count, total } of values) {
|
|
3931
|
+
console.log(` --${param} ${value} (${count}/${total} runs)`);
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
console.log('');
|
|
3935
|
+
console.log('suggested command (edit before running -- bskel never applies this automatically):');
|
|
3936
|
+
console.log(` ${suggestedCommand}`);
|
|
3937
|
+
}
|
|
3938
|
+
}
|
|
3939
|
+
process.exit(0);
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3488
3942
|
async function cmdNew(args) {
|
|
3489
3943
|
const flags = parseCommand('new', args);
|
|
3490
3944
|
if (flags.help) { console.log(renderCommandHelp('new')); process.exit(0); }
|
|
@@ -3496,6 +3950,21 @@ async function cmdNew(args) {
|
|
|
3496
3950
|
}
|
|
3497
3951
|
requireValidSlug(flags.slug);
|
|
3498
3952
|
requireStackParams(stack, flags);
|
|
3953
|
+
// D-pattern-accrual: checked BEFORE any network call or filesystem write, same ordering
|
|
3954
|
+
// principle P2b's own comment states above -- a usage mistake (flag given without its required
|
|
3955
|
+
// partner, or an env var that was never exported) must never leave a half-scaffolded project
|
|
3956
|
+
// behind. The actual DB write later in this function is a SEPARATE, best-effort concern; this
|
|
3957
|
+
// block only validates that recording, if requested, is even POSSIBLE to attempt.
|
|
3958
|
+
let patternConnectionString = null;
|
|
3959
|
+
if (flags['record-pattern']) {
|
|
3960
|
+
if (!flags['pattern-database-url-env']) {
|
|
3961
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--record-pattern requires --pattern-database-url-env <NAME>. Nothing was written.');
|
|
3962
|
+
}
|
|
3963
|
+
patternConnectionString = process.env[flags['pattern-database-url-env']];
|
|
3964
|
+
if (!patternConnectionString) {
|
|
3965
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--pattern-database-url-env ${flags['pattern-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). Nothing was written.`);
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3499
3968
|
|
|
3500
3969
|
let stackParams;
|
|
3501
3970
|
let warnings;
|
|
@@ -3539,6 +4008,27 @@ async function cmdNew(args) {
|
|
|
3539
4008
|
}
|
|
3540
4009
|
execFileSync('git', commitArgs, { cwd: dir });
|
|
3541
4010
|
|
|
4011
|
+
// D-pattern-accrual: best-effort, deliberately AFTER the project is already scaffolded and
|
|
4012
|
+
// committed -- failing here would be strictly worse than not recording (the project the user
|
|
4013
|
+
// asked for already exists). Only ever a stderr warning, never a fail()/non-zero exit; records
|
|
4014
|
+
// ONLY the flags new/index.mjs's reusableParamsFor(stack) names, and only the ones the user
|
|
4015
|
+
// actually typed (a param the user never passed stays absent from `params`, not defaulted in --
|
|
4016
|
+
// an omission is itself real information for `pattern suggest`, see patterns/store.mjs).
|
|
4017
|
+
if (patternConnectionString) {
|
|
4018
|
+
const patternParams = {};
|
|
4019
|
+
for (const param of reusableParamsFor(stack.id)) {
|
|
4020
|
+
if (flags[param] != null) patternParams[param] = String(flags[param]);
|
|
4021
|
+
}
|
|
4022
|
+
try {
|
|
4023
|
+
await recordPattern({ connectionString: patternConnectionString, stack: stack.id, params: patternParams });
|
|
4024
|
+
} catch (err) {
|
|
4025
|
+
const hint = isMissingPatternTable(err)
|
|
4026
|
+
? 'sbf_pattern does not exist in this database -- run patterns/schema.sql against it first (bskel never applies it automatically; see D-migration-scope).'
|
|
4027
|
+
: describeConnectionError(err);
|
|
4028
|
+
console.error(`warning: --record-pattern could not write to the pattern store: ${hint}`);
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
|
|
3542
4032
|
const { postScaffoldNotes = [], ...resultRest } = result;
|
|
3543
4033
|
if (flags.json) {
|
|
3544
4034
|
console.log(JSON.stringify({ stack: flags.stack, dir, ...resultRest, warnings, postScaffoldNotes }, null, 2));
|
|
@@ -3625,6 +4115,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3625
4115
|
case 'scan': {
|
|
3626
4116
|
if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
|
|
3627
4117
|
if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
|
|
4118
|
+
if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
|
|
3628
4119
|
if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
|
|
3629
4120
|
if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
|
|
3630
4121
|
await cmdScan(rest);
|
|
@@ -3646,6 +4137,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3646
4137
|
const subArgs = rest.slice(1);
|
|
3647
4138
|
if (sub === 'emit') return cmdContractEmit(subArgs);
|
|
3648
4139
|
if (sub === 'export') return cmdContractExport(subArgs);
|
|
4140
|
+
if (sub === 'export-csv') return cmdContractExportCsv(subArgs);
|
|
3649
4141
|
if (sub === 'history') return cmdContractHistory(subArgs);
|
|
3650
4142
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
3651
4143
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
@@ -3738,6 +4230,20 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3738
4230
|
return cmdServe(rest);
|
|
3739
4231
|
case 'new':
|
|
3740
4232
|
return cmdNew(rest);
|
|
4233
|
+
case 'pattern': {
|
|
4234
|
+
if (rest[0] === 'list') return cmdPatternList(rest.slice(1));
|
|
4235
|
+
if (rest[0] === 'show') return cmdPatternShow(rest.slice(1));
|
|
4236
|
+
if (rest[0] === 'suggest') return cmdPatternSuggest(rest.slice(1));
|
|
4237
|
+
usage();
|
|
4238
|
+
process.exit(14);
|
|
4239
|
+
break;
|
|
4240
|
+
}
|
|
4241
|
+
case 'db': {
|
|
4242
|
+
if (rest[0] === 'erd') return await cmdDbErd(rest.slice(1));
|
|
4243
|
+
usage();
|
|
4244
|
+
process.exit(14);
|
|
4245
|
+
break;
|
|
4246
|
+
}
|
|
3741
4247
|
default:
|
|
3742
4248
|
usage();
|
|
3743
4249
|
process.exit(cmd ? 14 : 0);
|