backend-skeleton 1.2.0 → 1.4.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 +100 -1
- package/bin/bskel.mjs +687 -6
- package/contracts/csv.mjs +100 -0
- package/handles/providers/java-spring/rules.mjs +143 -0
- package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
- package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
- package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
- package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
- package/handles/providers/python-fastapi/rules.mjs +133 -0
- package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
- package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
- package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
- package/handles/providers/typescript-express/rules.mjs +129 -0
- package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
- package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
- package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
- package/lib/cli.mjs +99 -1
- package/lib/doctor.mjs +11 -10
- package/lib/gate-definitions.mjs +27 -1
- package/lib/workflow.mjs +15 -0
- package/new/index.mjs +20 -0
- package/package.json +6 -2
- package/patterns/schema.sql +18 -0
- package/patterns/store.mjs +122 -0
- package/rules/compile.mjs +433 -0
- package/rules/derived.mjs +87 -0
- package/rules/diagnostics.mjs +147 -0
- package/rules/store.mjs +141 -0
- package/rules/vocabulary.mjs +172 -0
- package/scanners/adapters/_express-shared.mjs +7 -9
- package/scanners/adapters/java-spring.mjs +7 -9
- package/scanners/adapters/python-fastapi.mjs +7 -9
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +70 -17
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/feature-rules.schema.json +139 -0
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +6 -2
package/bin/bskel.mjs
CHANGED
|
@@ -44,7 +44,15 @@ import {
|
|
|
44
44
|
findCollisions, evaluateCrossFeatureFindings, waiverKey,
|
|
45
45
|
crossFeatureReportPath, loadCrossFeatureReport, loadCrossFeatureResolution, saveCrossFeatureResolution,
|
|
46
46
|
} from '../lib/cross-feature-collisions.mjs';
|
|
47
|
-
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';
|
|
49
|
+
// D-business-rules. rules/compile.mjs is pure (no fs/git/exit); rules/store.mjs owns all disk I/O.
|
|
50
|
+
import { compileRules, summarizeArtifact } from '../rules/compile.mjs';
|
|
51
|
+
import { rulesSourcePath, loadRulesSource, loadRulesArtifact, saveRulesArtifact, starterRulesSource } from '../rules/store.mjs';
|
|
52
|
+
import { PREDICATE_KINDS, explainRule } from '../rules/vocabulary.mjs';
|
|
53
|
+
import { emitRulesJavaSpring } from '../handles/providers/java-spring/rules.mjs';
|
|
54
|
+
import { emitRulesPythonFastApi } from '../handles/providers/python-fastapi/rules.mjs';
|
|
55
|
+
import { emitRulesTypeScriptExpress } from '../handles/providers/typescript-express/rules.mjs';
|
|
48
56
|
import {
|
|
49
57
|
requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
|
|
50
58
|
requireValidPythonVersion, requireValidLicense, requireValidDatabase, requireSupportedJavaVersion,
|
|
@@ -53,6 +61,8 @@ import {
|
|
|
53
61
|
import { DEFAULT_GROUP_ID, DEFAULT_JAVA_VERSION, resolveSpringDependencies } from '../new/spring.mjs';
|
|
54
62
|
import { buildReconciliation, snapshotFromReconciliation, describeSourceFile } from '../contracts/openapi.mjs';
|
|
55
63
|
import { buildOpenApiDocument, pathPrefixCandidates, unreflectedPathPrefixes, STATUS_CODE_MODES } from '../contracts/export.mjs';
|
|
64
|
+
import { buildContractCsv } from '../contracts/csv.mjs';
|
|
65
|
+
import { buildErdDiagram } from '../scanners/db/erd.mjs';
|
|
56
66
|
import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../stack/apply.mjs';
|
|
57
67
|
import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
|
|
58
68
|
import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
@@ -65,7 +75,7 @@ import { plan as planTypeScriptExpress } from '../handles/providers/typescript-e
|
|
|
65
75
|
import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
|
|
66
76
|
import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
|
|
67
77
|
import { computeWorkflowState } from '../lib/workflow.mjs';
|
|
68
|
-
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
|
|
78
|
+
import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS, binaryAvailable } from '../lib/doctor.mjs';
|
|
69
79
|
import { parseCommand, renderCommandHelp, diagnostic } from '../lib/cli.mjs';
|
|
70
80
|
import { EXIT_CODES } from '../lib/exit-codes.mjs';
|
|
71
81
|
import { RESIDUAL_TEMPLATE_VAR_RE } from '../lib/template.mjs';
|
|
@@ -76,7 +86,10 @@ const SKILL_ROOT = path.resolve(__dirname, '..');
|
|
|
76
86
|
function usage() {
|
|
77
87
|
console.error(`bskel -- backend-skeleton CLI
|
|
78
88
|
|
|
79
|
-
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]
|
|
89
|
+
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>]
|
|
90
|
+
bskel pattern list --pattern-database-url-env <NAME> [--stack spring|fastapi] [--json]
|
|
91
|
+
bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]
|
|
92
|
+
bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]
|
|
80
93
|
bskel preflight [--max-behind N] [--offline|--no-fetch] [--allow-dirty] [--max-age-minutes N] [--fetch-timeout-seconds N] [--json]
|
|
81
94
|
bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
|
|
82
95
|
bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
|
|
@@ -92,6 +105,8 @@ function usage() {
|
|
|
92
105
|
bskel feature archive <id> --reason "..." [--json]
|
|
93
106
|
bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]
|
|
94
107
|
bskel contract export --feature <id> [--out <path>] [--json] [--allow-unprefixed] [--status-codes range|literal]
|
|
108
|
+
bskel contract export-csv --feature <id> [--out <path>] [--bom] [--json]
|
|
109
|
+
bskel db erd [--database-url-env <NAME>] [--schema public] [--out <path>] [--json]
|
|
95
110
|
bskel contract history --feature <id> [--json]
|
|
96
111
|
bskel contract validate --feature <id> --file <envelope.json>
|
|
97
112
|
bskel contract tool-schema --feature <id> --operation <operationId>
|
|
@@ -99,6 +114,10 @@ function usage() {
|
|
|
99
114
|
bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
|
|
100
115
|
bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
|
|
101
116
|
bskel dependency list --feature <id> [--json]
|
|
117
|
+
bskel rules check --feature <id> [--init] [--json]
|
|
118
|
+
bskel rules list --feature <id> [--json]
|
|
119
|
+
bskel rules explain --feature <id> --rule <id> [--json]
|
|
120
|
+
bskel rules emit --feature <id> [--module <name>] [--check] [--diff] [--force --reason "..."] [--json]
|
|
102
121
|
bskel stack apply --choice <id> [--apply] [--port N] [--force --reason "..."] [--json]
|
|
103
122
|
bskel catalog lint [<choice>] [--json]
|
|
104
123
|
bskel handles plan --feature <id> [--module <name>] [--resource type1,type2] [--diff] [--ast]
|
|
@@ -605,8 +624,16 @@ async function cmdScan(args) {
|
|
|
605
624
|
setContext('scan', flags);
|
|
606
625
|
const root = requireRepoRoot();
|
|
607
626
|
const terms = deriveTerms(flags);
|
|
608
|
-
|
|
609
|
-
|
|
627
|
+
// D-zero-config-scan: only refuse when the user EXPLICITLY tried to supply terms (or a
|
|
628
|
+
// --feature) and it resolved to nothing -- a real usage mistake worth catching (a `--terms ""`/
|
|
629
|
+
// `--terms ,,` typo, or a --feature slug that pathologically derives zero words), same as
|
|
630
|
+
// before this item. Neither flag given at all is no longer an error -- it's the new zero-flag
|
|
631
|
+
// "inventory" mode (every module this adapter finds, unscored -- see scanners/index.mjs's
|
|
632
|
+
// runScan()), reachable only because the block below still gates it on `!flags.feature` before
|
|
633
|
+
// any gate/file write happens, exactly like today's ad-hoc mode already does.
|
|
634
|
+
const termsFlagGiven = args.some((a) => a === '--terms' || a.startsWith('--terms='));
|
|
635
|
+
if (terms.length === 0 && (termsFlagGiven || flags.feature)) {
|
|
636
|
+
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)');
|
|
610
637
|
}
|
|
611
638
|
if (flags.feature) {
|
|
612
639
|
requireValidFeatureId(flags.feature);
|
|
@@ -614,6 +641,11 @@ async function cmdScan(args) {
|
|
|
614
641
|
}
|
|
615
642
|
|
|
616
643
|
const dbSchema = await resolveDbSchemaOrExit(root, flags);
|
|
644
|
+
// D-zero-config-scan: the same CLI-boundary-resolved-input pattern `dbSchema` above already
|
|
645
|
+
// establishes, applied to a second external dependency -- computed once, used on BOTH the
|
|
646
|
+
// inventory and the --feature-scoped path (a real Spring repo scanned with --feature+--terms is
|
|
647
|
+
// exactly as vulnerable to the silent detect()-time degradation as the zero-flag case).
|
|
648
|
+
const rgAvailable = binaryAvailable('rg');
|
|
617
649
|
|
|
618
650
|
// G1: a broken adapter file doesn't stop the adapters that DID load, but every `scan` run
|
|
619
651
|
// says so loudly (also see `bskel doctor`, which exits 1 while any of these remain).
|
|
@@ -622,7 +654,7 @@ async function cmdScan(args) {
|
|
|
622
654
|
}
|
|
623
655
|
let report;
|
|
624
656
|
try {
|
|
625
|
-
report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema });
|
|
657
|
+
report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable });
|
|
626
658
|
} catch (err) {
|
|
627
659
|
// Unreachable with the two shipped adapters (generic-grep's specificity-0 detect() is
|
|
628
660
|
// unconditional) -- becomes reachable the moment a future adapter's detect() is
|
|
@@ -1589,6 +1621,181 @@ function cmdContractExport(args) {
|
|
|
1589
1621
|
process.exitCode = EXIT.PASS;
|
|
1590
1622
|
}
|
|
1591
1623
|
|
|
1624
|
+
// D-contract-csv: the soft-refusal counterpart to loadScanReportOrExit() above -- returns null
|
|
1625
|
+
// (skip the path-prefix check entirely) on ANY failure (missing file, unparseable JSON, schema
|
|
1626
|
+
// violation) instead of exiting. `contract export-csv` treats this check as advisory (see C5 in
|
|
1627
|
+
// D-contract-csv, DECISIONS.md): a scan report that can't be read is not itself a reason to block a
|
|
1628
|
+
// human-reviewed artifact, unlike `contract export`'s own hard guard for a machine-consumed one.
|
|
1629
|
+
function tryLoadScanReport(root, featureId) {
|
|
1630
|
+
const scanReportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
1631
|
+
if (!fs.existsSync(scanReportPath)) return null;
|
|
1632
|
+
try {
|
|
1633
|
+
const parsed = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
|
|
1634
|
+
const { ok } = validateAgainstSchema('scan-report.schema.json', parsed);
|
|
1635
|
+
return ok ? parsed : null;
|
|
1636
|
+
} catch {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// D-contract-csv: a spreadsheet-shaped projection of a feature contract. Mirrors
|
|
1642
|
+
// cmdContractExport's overall shape (loadContract, zero-operation refusal, --out/stdout via
|
|
1643
|
+
// writeFileAtomic) but is deliberately UNGATED -- it never calls requireNamedGate('contract', ...)
|
|
1644
|
+
// and never hard-refuses on an unreflected path prefix, only warns. See D-contract-csv in
|
|
1645
|
+
// DECISIONS.md for the full risk-model argument (a CSV is read by a human deciding whether to
|
|
1646
|
+
// waive a partial contract -- gating it would make it useless exactly when it matters).
|
|
1647
|
+
function cmdContractExportCsv(args) {
|
|
1648
|
+
const flags = parseCommand('contract export-csv', args);
|
|
1649
|
+
if (flags.help) { console.log(renderCommandHelp('contract export-csv')); process.exit(0); }
|
|
1650
|
+
setContext('contract export-csv', flags);
|
|
1651
|
+
const root = requireRepoRoot();
|
|
1652
|
+
requireValidFeatureId(flags.feature);
|
|
1653
|
+
|
|
1654
|
+
const contract = loadContract(root, flags.feature);
|
|
1655
|
+
|
|
1656
|
+
// Same positive-false-claim refusal `contract export` itself makes (see that function's own
|
|
1657
|
+
// comment) -- a zero-row CSV handed to a stakeholder reads as "this feature has no API".
|
|
1658
|
+
if (Object.keys(contract.operations).length === 0) {
|
|
1659
|
+
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}\`.`);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// C5 (D-contract-csv): advisory only. If the scan report cannot be read, the check is skipped
|
|
1663
|
+
// silently rather than refusing (contrast cmdContractExport's loadScanReportOrExit(), which hard-exits).
|
|
1664
|
+
let pathPrefixWarning = null;
|
|
1665
|
+
const scanReport = tryLoadScanReport(root, flags.feature);
|
|
1666
|
+
if (scanReport) {
|
|
1667
|
+
const candidates = pathPrefixCandidates(scanReport.path_prefix_signals);
|
|
1668
|
+
const unreflected = unreflectedPathPrefixes(contract, candidates);
|
|
1669
|
+
if (unreflected.length > 0) {
|
|
1670
|
+
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.`;
|
|
1671
|
+
console.error(`note: ${pathPrefixWarning}`);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const built = buildContractCsv({ contract });
|
|
1676
|
+
if (!built.ok) {
|
|
1677
|
+
// Unreachable given the zero-operation refusal above already covers this case -- kept as
|
|
1678
|
+
// a real check rather than assuming buildContractCsv()'s precondition forever.
|
|
1679
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `cannot export ${flags.feature}'s contract to CSV: ${built.error}`);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// C2 (D-contract-csv): unconditional -- a reader must not have to guess whether a blank column
|
|
1683
|
+
// means "nothing to show" or "the tool is broken".
|
|
1684
|
+
if (built.emptyColumns.length > 0) {
|
|
1685
|
+
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.`);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// C4 (D-contract-csv): BOM is opt-in -- Excel-on-Windows mangles non-ASCII without one, but a
|
|
1689
|
+
// BOM breaks naive parsers/`head`/`diff` for everyone else, so neither default is right for everyone.
|
|
1690
|
+
const csvText = flags.bom ? `\uFEFF${built.csv}` : built.csv;
|
|
1691
|
+
|
|
1692
|
+
if (flags.out) {
|
|
1693
|
+
const outPath = path.resolve(process.cwd(), flags.out);
|
|
1694
|
+
writeFileAtomic(outPath, csvText);
|
|
1695
|
+
if (flags.json) {
|
|
1696
|
+
console.log(JSON.stringify({
|
|
1697
|
+
schema: 'sbf.contract-export-csv/1',
|
|
1698
|
+
feature_id: contract.feature_id,
|
|
1699
|
+
out: flags.out,
|
|
1700
|
+
row_count: built.rowCount,
|
|
1701
|
+
columns: built.columns,
|
|
1702
|
+
completeness: contract.completeness.status,
|
|
1703
|
+
path_prefix_warning: pathPrefixWarning,
|
|
1704
|
+
}, null, 2));
|
|
1705
|
+
} else if (!flags.quiet) {
|
|
1706
|
+
console.log(`wrote ${flags.out} -- ${built.rowCount} operation(s), completeness: ${contract.completeness.status}`);
|
|
1707
|
+
}
|
|
1708
|
+
} else {
|
|
1709
|
+
// process.stdout.write, not console.log -- `csvText` already ends in exactly one `\n`
|
|
1710
|
+
// (contracts/csv.mjs's toCsv()), and console.log would append a SECOND one, making stdout
|
|
1711
|
+
// byte-different from the file --out writes. C7 (D-contract-csv): the artifact is the only
|
|
1712
|
+
// thing on stdout here.
|
|
1713
|
+
process.stdout.write(csvText);
|
|
1714
|
+
if (flags.json) {
|
|
1715
|
+
console.error('note: --json has no effect without --out -- stdout is the CSV itself. Pass --out <path> to get both.');
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
// D-process-exit-audit: NOT process.exit() -- same reasoning as cmdContractExport's own
|
|
1719
|
+
// trailing comment; a 300-operation CSV can clear the 64KB pipe buffer just as easily.
|
|
1720
|
+
process.exitCode = EXIT.PASS;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// D-db-erd: a Mermaid `erDiagram` of the database plane -- repo-independent like `bskel new`/
|
|
1724
|
+
// `bskel pattern *` (no --feature; the database plane is not feature-scoped, see E6 in D-db-erd,
|
|
1725
|
+
// DECISIONS.md). Reuses resolveDbSchemaOrExit() unchanged (via a synthetic `db: true`) so
|
|
1726
|
+
// --database-url-env's env-var handling and every error string stay byte-identical to `scan --db`.
|
|
1727
|
+
async function cmdDbErd(args) {
|
|
1728
|
+
const flags = parseCommand('db erd', args);
|
|
1729
|
+
if (flags.help) { console.log(renderCommandHelp('db erd')); process.exit(0); }
|
|
1730
|
+
setContext('db erd', flags);
|
|
1731
|
+
const root = requireRepoRoot();
|
|
1732
|
+
|
|
1733
|
+
// E1 (D-db-erd)'s own `--db` flag doesn't exist on this command -- the verb `db erd` implies
|
|
1734
|
+
// it, so a synthetic `db: true` is threaded through to the exact same helper `scan --db` uses,
|
|
1735
|
+
// never a second copy of its env-var-unset/connection-failure handling.
|
|
1736
|
+
const { live, migrations } = await resolveDbSchemaOrExit(root, { ...flags, db: true });
|
|
1737
|
+
|
|
1738
|
+
if (!live && (!migrations || migrations.tables.length === 0)) {
|
|
1739
|
+
if (migrations && migrations.tool === 'liquibase') {
|
|
1740
|
+
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.`);
|
|
1741
|
+
}
|
|
1742
|
+
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.');
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
const version = JSON.parse(fs.readFileSync(path.join(SKILL_ROOT, 'package.json'), 'utf8')).version;
|
|
1746
|
+
const invocation = flags['database-url-env']
|
|
1747
|
+
? `bskel db erd --database-url-env ${flags['database-url-env']} --schema ${flags.schema}`
|
|
1748
|
+
: 'bskel db erd';
|
|
1749
|
+
const built = buildErdDiagram({ live, migrations, generatedBy: `bskel ${version} -- \`${invocation}\`` });
|
|
1750
|
+
if (!built.ok) {
|
|
1751
|
+
// Unreachable given the refusal above already covers both reasons buildErdDiagram() can
|
|
1752
|
+
// report -- kept as a real check rather than assuming its precondition forever.
|
|
1753
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no schema to draw (${built.reason})`);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// E2 (D-db-erd): unconditional when degraded -- a reader must see this before the diagram, not
|
|
1757
|
+
// discover it by noticing every type says `unknown`.
|
|
1758
|
+
if (built.degraded) {
|
|
1759
|
+
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.`);
|
|
1760
|
+
}
|
|
1761
|
+
// E6 (D-db-erd): a readability nudge only, never a refusal -- whole-schema is the only mode
|
|
1762
|
+
// this version supports.
|
|
1763
|
+
if (built.entityCount > 40) {
|
|
1764
|
+
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).`);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
if (flags.out) {
|
|
1768
|
+
const outPath = path.resolve(process.cwd(), flags.out);
|
|
1769
|
+
writeFileAtomic(outPath, built.mermaid);
|
|
1770
|
+
if (flags.json) {
|
|
1771
|
+
console.log(JSON.stringify({
|
|
1772
|
+
schema: 'sbf.db-erd/1',
|
|
1773
|
+
out: flags.out,
|
|
1774
|
+
plane: built.plane,
|
|
1775
|
+
entity_count: built.entityCount,
|
|
1776
|
+
relationship_count: built.relationshipCount,
|
|
1777
|
+
unresolved_relationships: built.unresolvedRelationships,
|
|
1778
|
+
external_tables: built.externalTables,
|
|
1779
|
+
renames: built.renames,
|
|
1780
|
+
degraded: built.degraded,
|
|
1781
|
+
missing: built.missing,
|
|
1782
|
+
}, null, 2));
|
|
1783
|
+
} else if (!flags.quiet) {
|
|
1784
|
+
console.log(`wrote ${flags.out} -- ${built.entityCount} entity(ies), ${built.relationshipCount} relationship(s), plane: ${built.plane}${built.degraded ? ' (degraded)' : ''}`);
|
|
1785
|
+
}
|
|
1786
|
+
} else {
|
|
1787
|
+
// process.stdout.write, not console.log -- same E10 (D-db-erd) byte-exactness reasoning as
|
|
1788
|
+
// cmdContractExportCsv's own C7 (built.mermaid already ends in exactly one `\n`).
|
|
1789
|
+
process.stdout.write(built.mermaid);
|
|
1790
|
+
if (flags.json) {
|
|
1791
|
+
console.error('note: --json has no effect without --out -- stdout is the diagram itself. Pass --out <path> to get both.');
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
// D-process-exit-audit: NOT process.exit() -- a 200-table diagram can clear the 64KB pipe
|
|
1795
|
+
// buffer just as easily as a schema-rich OpenAPI export.
|
|
1796
|
+
process.exitCode = EXIT.PASS;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1592
1799
|
// A5: the `scan disposition` of contracts -- lets a human explicitly accept a `partial`
|
|
1593
1800
|
// contract's outstanding warnings so the `contract` gate can pass. Deliberately no wildcard
|
|
1594
1801
|
// waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
|
|
@@ -1819,6 +2026,292 @@ function cmdDependencyList(args) {
|
|
|
1819
2026
|
process.exit(0);
|
|
1820
2027
|
}
|
|
1821
2028
|
|
|
2029
|
+
// D-business-rules (R6): compiles specs/<id>/rules.yaml (optional) plus the feature's own contract
|
|
2030
|
+
// into specs/<id>/rules/<id>.rules.json, and establishes the `rules` gate.
|
|
2031
|
+
//
|
|
2032
|
+
// Gated on `contract` having PASSED, the same posture `contract export`/`handles emit` take and
|
|
2033
|
+
// deliberately not the ungated posture `contract validate` takes: every rule's pointer, scalar
|
|
2034
|
+
// type, and enum state is verified against the contract, so compiling against a contract nobody
|
|
2035
|
+
// has accepted yet would bake unaccepted facts into an artifact that later drives real codegen.
|
|
2036
|
+
//
|
|
2037
|
+
// Refuses rather than approximates (R6): an ERROR-severity diagnostic is an authored mistake with
|
|
2038
|
+
// a real fix, not a fact to be waived -- see rules/diagnostics.mjs's own header for why this
|
|
2039
|
+
// command has no `waive` sibling the way `contract` does.
|
|
2040
|
+
function cmdRulesCheck(args) {
|
|
2041
|
+
const flags = parseCommand('rules check', args);
|
|
2042
|
+
if (flags.help) { console.log(renderCommandHelp('rules check')); process.exit(0); }
|
|
2043
|
+
setContext('rules check', flags);
|
|
2044
|
+
const root = requireRepoRoot();
|
|
2045
|
+
requirePreflightPassed(root);
|
|
2046
|
+
const contractResult = requireNamedGate(root, 'contract', flags.feature);
|
|
2047
|
+
if (contractResult.code !== EXIT.PASS) {
|
|
2048
|
+
const hint = contractResult.status === 'awaiting_disposition'
|
|
2049
|
+
? `resolve it first -- \`bskel contract waive --feature ${flags.feature} --code <CODE> (--subject "..."|--all) --reason "..."\`, or \`bskel gate force contract --feature ${flags.feature} --reason "..."\` if intentional.`
|
|
2050
|
+
: `run \`bskel contract emit --feature ${flags.feature}\` first.`;
|
|
2051
|
+
fail(contractResult.code, gateReasonForCode(contractResult.code), `blocked: \`contract\` gate for ${flags.feature} is ${contractResult.status} -- ${hint}`, {
|
|
2052
|
+
next_actions: [{ command: `bskel contract emit --feature ${flags.feature}`, reason: 'the contract gate has not passed yet', mutating: true }],
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const contract = loadContract(root, flags.feature);
|
|
2057
|
+
const contractRef = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
|
|
2058
|
+
|
|
2059
|
+
const sourcePath = rulesSourcePath(root, flags.feature);
|
|
2060
|
+
if (flags.init) {
|
|
2061
|
+
// Never overwrites: a starter file is a convenience for an empty slot, not a reset button.
|
|
2062
|
+
if (fs.existsSync(sourcePath)) {
|
|
2063
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--init refuses to overwrite an existing ${path.relative(root, sourcePath)} -- edit it directly, or delete it first if you really want a fresh starter.`);
|
|
2064
|
+
}
|
|
2065
|
+
fs.mkdirSync(path.dirname(sourcePath), { recursive: true });
|
|
2066
|
+
writeFileAtomic(sourcePath, starterRulesSource(flags.feature));
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
let source;
|
|
2070
|
+
try {
|
|
2071
|
+
source = loadRulesSource(root, flags.feature);
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const { artifact, diagnostics, blocking } = compileRules({ contract, source, contractRef });
|
|
2077
|
+
const errors = diagnostics.filter((d) => d.severity === 'error');
|
|
2078
|
+
const warnings = diagnostics.filter((d) => d.severity === 'warn');
|
|
2079
|
+
|
|
2080
|
+
if (blocking) {
|
|
2081
|
+
// Nothing is written on refusal -- the same "Nothing was written." posture
|
|
2082
|
+
// explainMissingCapability() uses. A half-compiled artifact would be worse than none.
|
|
2083
|
+
const detail = errors.map((e) => ` ${e.code}${e.subject ? ` (${e.subject})` : ''}: ${e.message}`).join('\n');
|
|
2084
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `blocked: ${errors.length} rule error(s) in ${path.relative(root, sourcePath)} -- nothing was written.\n${detail}`, {
|
|
2085
|
+
next_actions: [{ command: `bskel rules check --feature ${flags.feature}`, reason: 'fix the rule(s) above and re-run', mutating: true }],
|
|
2086
|
+
});
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
saveRulesArtifact(root, flags.feature, artifact);
|
|
2090
|
+
const summary = summarizeArtifact(artifact);
|
|
2091
|
+
const gateState = passNamedGate(root, 'rules', flags.feature, {
|
|
2092
|
+
rule_count: summary.field + summary.cross + summary.transition + summary.derived,
|
|
2093
|
+
from_contract: summary.fromContract,
|
|
2094
|
+
declared: summary.declared,
|
|
2095
|
+
unsupported: summary.unsupported,
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
if (flags.json) {
|
|
2099
|
+
// `gateState.gates.rules`, not the whole state object -- passNamedGate() returns the full
|
|
2100
|
+
// state, and every other command's --json exposes just its own gate record (cmdScan,
|
|
2101
|
+
// cmdScanDisposition, cmdScanCrossFeatureCheck all do exactly this).
|
|
2102
|
+
console.log(JSON.stringify({ feature_id: flags.feature, summary, unsupported: artifact.unsupported, gate: gateState.gates.rules }, null, 2));
|
|
2103
|
+
} else {
|
|
2104
|
+
console.log(`rules -- feature ${flags.feature}`);
|
|
2105
|
+
console.log(` ${summary.field} field, ${summary.cross} cross-field, ${summary.transition} transition across ${summary.operations} operation(s), ${summary.derived} derived (resource-scoped)`);
|
|
2106
|
+
console.log(` ${summary.fromContract} projected from the contract's own schema, ${summary.declared} declared in rules.yaml`);
|
|
2107
|
+
if (warnings.length > 0) {
|
|
2108
|
+
// Warnings go to stderr so `--json` stdout stays exactly one JSON document, and so
|
|
2109
|
+
// --quiet never suppresses them -- D-cli-contract's own rule.
|
|
2110
|
+
console.error(`\n${warnings.length} constraint(s) in the contract are NOT enforced by these rules:`);
|
|
2111
|
+
for (const w of warnings) console.error(` ${w.code}: ${w.message}`);
|
|
2112
|
+
}
|
|
2113
|
+
if (summary.field + summary.cross + summary.transition + summary.derived === 0) {
|
|
2114
|
+
console.log(` (no rules yet -- run \`bskel rules check --feature ${flags.feature} --init\` for a starter rules.yaml, or point \`bskel contract emit\` at an OpenAPI document to pick up its constraints automatically)`);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
process.exit(0);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function cmdRulesList(args) {
|
|
2121
|
+
const flags = parseCommand('rules list', args);
|
|
2122
|
+
if (flags.help) { console.log(renderCommandHelp('rules list')); process.exit(0); }
|
|
2123
|
+
setContext('rules list', flags);
|
|
2124
|
+
const root = requireRepoRoot();
|
|
2125
|
+
const artifact = loadRulesArtifactOrExit(root, flags.feature);
|
|
2126
|
+
|
|
2127
|
+
if (flags.json) { console.log(JSON.stringify(artifact, null, 2)); process.exit(0); }
|
|
2128
|
+
|
|
2129
|
+
console.log(`rules -- feature ${artifact.feature_id}`);
|
|
2130
|
+
for (const [operationId, kinds] of Object.entries(artifact.operations)) {
|
|
2131
|
+
console.log(`\n ${operationId}`);
|
|
2132
|
+
for (const r of kinds.field ?? []) console.log(` [field] ${r.id} ${r.pointer} ${r.assert} ${JSON.stringify(r.value)} (${r.origin})`);
|
|
2133
|
+
for (const r of kinds.cross ?? []) console.log(` [cross] ${r.id} ${r.pointers.join(` ${r.assert} `)} (${r.origin})`);
|
|
2134
|
+
for (const r of kinds.transition ?? []) console.log(` [transition] ${r.id} ${r.pointer}: ${r.from.join('|')} -> ${r.to.join('|')} (${r.origin})`);
|
|
2135
|
+
}
|
|
2136
|
+
if (Object.keys(artifact.operations).length === 0 && (artifact.derived ?? []).length === 0) console.log(' (none)');
|
|
2137
|
+
if ((artifact.derived ?? []).length > 0) {
|
|
2138
|
+
console.log('\n derived (resource-scoped, not operation-scoped):');
|
|
2139
|
+
for (const r of artifact.derived) console.log(` [derived] ${r.id} ${r.resource}.${r.field} <- (${r.params.join(', ')}) (${r.origin})`);
|
|
2140
|
+
}
|
|
2141
|
+
if (artifact.unsupported.length > 0) {
|
|
2142
|
+
console.log(`\n NOT enforced (${artifact.unsupported.length}):`);
|
|
2143
|
+
for (const u of artifact.unsupported) console.log(` ${u.code}: ${u.reason}`);
|
|
2144
|
+
}
|
|
2145
|
+
process.exit(0);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
function cmdRulesExplain(args) {
|
|
2149
|
+
const flags = parseCommand('rules explain', args);
|
|
2150
|
+
if (flags.help) { console.log(renderCommandHelp('rules explain')); process.exit(0); }
|
|
2151
|
+
setContext('rules explain', flags);
|
|
2152
|
+
const root = requireRepoRoot();
|
|
2153
|
+
const artifact = loadRulesArtifactOrExit(root, flags.feature);
|
|
2154
|
+
|
|
2155
|
+
const found = [];
|
|
2156
|
+
for (const [operationId, kinds] of Object.entries(artifact.operations)) {
|
|
2157
|
+
for (const kind of PREDICATE_KINDS) {
|
|
2158
|
+
for (const rule of kinds[kind] ?? []) {
|
|
2159
|
+
if (rule.id === flags.rule) found.push({ operation: operationId, kind, rule });
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
// `derived` rules are resource-scoped, not operation-scoped (R5) -- searched separately, same
|
|
2164
|
+
// reasoning compileRules() gives for compiling them on their own path.
|
|
2165
|
+
for (const rule of artifact.derived ?? []) {
|
|
2166
|
+
if (rule.id === flags.rule) found.push({ operation: null, kind: 'derived', rule });
|
|
2167
|
+
}
|
|
2168
|
+
if (found.length === 0) {
|
|
2169
|
+
const known = [];
|
|
2170
|
+
for (const kinds of Object.values(artifact.operations)) {
|
|
2171
|
+
for (const kind of PREDICATE_KINDS) for (const r of kinds[kind] ?? []) known.push(r.id);
|
|
2172
|
+
}
|
|
2173
|
+
for (const r of artifact.derived ?? []) known.push(r.id);
|
|
2174
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `no rule "${flags.rule}" in feature ${flags.feature} -- known rule ids: ${known.sort().join(', ') || '(none)'}`);
|
|
2175
|
+
}
|
|
2176
|
+
const [{ operation, kind, rule }] = found;
|
|
2177
|
+
const explanation = explainRule({ operation, kind, rule });
|
|
2178
|
+
if (flags.json) console.log(JSON.stringify({ feature_id: artifact.feature_id, operation, kind, rule, explanation }, null, 2));
|
|
2179
|
+
else {
|
|
2180
|
+
console.log(`rule "${rule.id}" -- feature ${artifact.feature_id}`);
|
|
2181
|
+
if (operation) console.log(` operation: ${operation}`);
|
|
2182
|
+
else console.log(` resource: ${rule.resource}.${rule.field}`);
|
|
2183
|
+
console.log(` kind: ${kind}`);
|
|
2184
|
+
console.log(` origin: ${rule.origin}${rule.origin === 'contract' ? " (projected from this operation's own requestBodySchema -- change the OpenAPI document, not rules.yaml)" : ' (declared in rules.yaml)'}`);
|
|
2185
|
+
console.log(` means: ${explanation}`);
|
|
2186
|
+
}
|
|
2187
|
+
process.exit(0);
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
// D-business-rules (R9): emits the generic rule executor plus the compiled artifact as a classpath
|
|
2191
|
+
// resource. Mirrors cmdObserveEmit's own precondition chain and blocked/--check reporting shape --
|
|
2192
|
+
// same emitUnits() conflict machinery, same --check/--diff/--force/--reason semantics, and the same
|
|
2193
|
+
// explicit adapter dispatch rather than handles/registry.mjs's plan+emit provider mechanism (this
|
|
2194
|
+
// command has no `plan` verb either, and operates directly on an already-compiled artifact).
|
|
2195
|
+
//
|
|
2196
|
+
// Gated on the `rules` gate rather than `contract`: the artifact this emits is what `rules check`
|
|
2197
|
+
// produced and schema-validated, so emitting while that gate is stale would ship a runtime resource
|
|
2198
|
+
// that no longer matches the rules anyone reviewed.
|
|
2199
|
+
function cmdRulesEmit(args) {
|
|
2200
|
+
const flags = parseCommand('rules emit', args);
|
|
2201
|
+
if (flags.help) { console.log(renderCommandHelp('rules emit')); process.exit(0); }
|
|
2202
|
+
setContext('rules emit', flags);
|
|
2203
|
+
const root = requireRepoRoot();
|
|
2204
|
+
requirePreflightPassed(root);
|
|
2205
|
+
if (flags.force && (!flags.reason || !flags.reason.trim())) {
|
|
2206
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel rules emit --force requires --reason "..." -- every overwrite of diverged generated code must be auditable');
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const rulesResult = requireNamedGate(root, 'rules', flags.feature);
|
|
2210
|
+
if (rulesResult.code !== EXIT.PASS) {
|
|
2211
|
+
fail(rulesResult.code, gateReasonForCode(rulesResult.code), `blocked: \`rules\` gate for ${flags.feature} is ${rulesResult.status} -- run \`bskel rules check --feature ${flags.feature}\` first.`, {
|
|
2212
|
+
next_actions: [{ command: `bskel rules check --feature ${flags.feature}`, reason: 'the rules gate has not passed yet', mutating: true }],
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2217
|
+
const artifact = loadRulesArtifactOrExit(root, flags.feature);
|
|
2218
|
+
const dryRun = flags.check || flags.diff;
|
|
2219
|
+
|
|
2220
|
+
let result;
|
|
2221
|
+
if (scanReport.adapter === 'java-spring') {
|
|
2222
|
+
let basePackage;
|
|
2223
|
+
try {
|
|
2224
|
+
basePackage = detectBasePackage(root);
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2227
|
+
}
|
|
2228
|
+
if (!basePackage) {
|
|
2229
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', 'could not detect the base package (no *Application.java found under src/main/java) -- is this a Spring Boot project?');
|
|
2230
|
+
}
|
|
2231
|
+
try {
|
|
2232
|
+
result = emitRulesJavaSpring({ repoRoot: root, featureId: flags.feature, artifact, basePackage, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2233
|
+
} catch (err) {
|
|
2234
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2235
|
+
}
|
|
2236
|
+
} else if (scanReport.adapter === 'python-fastapi') {
|
|
2237
|
+
// python's own package-root detection needs a module to anchor itself -- same asymmetry
|
|
2238
|
+
// observe emit's own comment already documents for this exact adapter.
|
|
2239
|
+
let fastApiPlan;
|
|
2240
|
+
try {
|
|
2241
|
+
fastApiPlan = planPythonFastApi({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
|
|
2242
|
+
} catch (err) {
|
|
2243
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2244
|
+
}
|
|
2245
|
+
try {
|
|
2246
|
+
result = emitRulesPythonFastApi({ repoRoot: root, featureId: flags.feature, artifact, plan: fastApiPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2247
|
+
} catch (err) {
|
|
2248
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2249
|
+
}
|
|
2250
|
+
} else if (scanReport.adapter === 'typescript-express') {
|
|
2251
|
+
// TS's own project-root detection needs a module to anchor itself too -- same --module
|
|
2252
|
+
// dependency python-fastapi's own rules emit already established.
|
|
2253
|
+
let tsPlan;
|
|
2254
|
+
try {
|
|
2255
|
+
tsPlan = planTypeScriptExpress({ repoRoot: root, scanReport, module: flags.module, resourceFilter: null });
|
|
2256
|
+
} catch (err) {
|
|
2257
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2258
|
+
}
|
|
2259
|
+
try {
|
|
2260
|
+
result = emitRulesTypeScriptExpress({ repoRoot: root, featureId: flags.feature, artifact, plan: tsPlan, force: flags.force, reason: flags.reason, dryRun, computeDiff: flags.diff });
|
|
2261
|
+
} catch (err) {
|
|
2262
|
+
fail(EXIT_CODES.NOT_PASSED, 'PLAN_FAILED', err.message);
|
|
2263
|
+
}
|
|
2264
|
+
} else {
|
|
2265
|
+
fail(EXIT_CODES.MISSING_CAPABILITY, 'MISSING_CAPABILITY', `bskel rules emit does not support the "${scanReport.adapter}" adapter yet (supported: java-spring, python-fastapi, typescript-express).`);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
const { written, conflicts, orphans, notes, forced, blocked, actions, postEmitNotes = [] } = result;
|
|
2269
|
+
const wouldChange = actions.some((a) => a.action !== 'unchanged' && a.action !== 'adopt-unchanged');
|
|
2270
|
+
const allNotes = [...notes];
|
|
2271
|
+
if (flags.force && forced.length === 0 && conflicts.length === 0) allNotes.push('--force had no effect: 0 conflicts found in this run\'s scope');
|
|
2272
|
+
else if (flags.force && forced.length > 0) allNotes.push(`--force overwrote ${forced.length} diverged file(s): ${forced.join(', ')}`);
|
|
2273
|
+
|
|
2274
|
+
if (blocked) {
|
|
2275
|
+
if (flags.json) {
|
|
2276
|
+
console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: true, check: dryRun }, null, 2));
|
|
2277
|
+
} else {
|
|
2278
|
+
const verb = dryRun ? 'would be blocked' : 'blocked';
|
|
2279
|
+
console.error(`${verb}: ${conflicts.length} generated file(s) diverged from what backend-skeleton last wrote -- ${dryRun ? 'a real run would refuse to overwrite them' : 'refusing to overwrite'} without --force:`);
|
|
2280
|
+
for (const c of conflicts) console.error(` ${c.path} (${c.kind})\n ${c.reason}`);
|
|
2281
|
+
if (!dryRun) console.error(`\nre-run with: bskel rules emit --feature ${flags.feature}${flags.module ? ` --module ${flags.module}` : ''} --force --reason "..."`);
|
|
2282
|
+
}
|
|
2283
|
+
process.exit(EXIT_CODES.HANDLES_CONFLICT);
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
if (flags.json) {
|
|
2287
|
+
console.log(JSON.stringify({ written, conflicts, orphans, forced, notes: allNotes, actions, blocked: false, check: dryRun, postEmitNotes }, null, 2));
|
|
2288
|
+
} else if (!flags.quiet) {
|
|
2289
|
+
console.log(`${dryRun ? 'would write' : 'wrote'} ${written.length} file(s):`);
|
|
2290
|
+
for (const w of written) console.log(` ${w}`);
|
|
2291
|
+
if (allNotes.length > 0) {
|
|
2292
|
+
console.log('\nnotes:');
|
|
2293
|
+
for (const n of allNotes) console.log(` - ${n}`);
|
|
2294
|
+
}
|
|
2295
|
+
if (dryRun) console.log(`\n${renderFileActions(actions)}`);
|
|
2296
|
+
else for (const n of postEmitNotes) console.log(`\n${n}`);
|
|
2297
|
+
}
|
|
2298
|
+
if (dryRun) process.exit(wouldChange ? EXIT_CODES.CHECK_FAILED : EXIT_CODES.OK);
|
|
2299
|
+
process.exit(0);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function loadRulesArtifactOrExit(root, featureId) {
|
|
2303
|
+
let artifact;
|
|
2304
|
+
try {
|
|
2305
|
+
artifact = loadRulesArtifact(root, featureId);
|
|
2306
|
+
} catch (err) {
|
|
2307
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', err.message);
|
|
2308
|
+
}
|
|
2309
|
+
if (!artifact) {
|
|
2310
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no compiled rules for ${featureId} -- run \`bskel rules check --feature ${featureId}\` first`);
|
|
2311
|
+
}
|
|
2312
|
+
return artifact;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
1822
2315
|
// D-contract-history: a derived VIEW over the contract file's own git history in whatever repo
|
|
1823
2316
|
// bskel is invoked in -- reads, never writes. Deliberately does NOT try to correlate a commit to
|
|
1824
2317
|
// a specific `.sbf/<feature>.history.jsonl` gate-pass event: that file is per-machine, gitignored,
|
|
@@ -3617,6 +4110,132 @@ async function resolveNewParams(stack, flags) {
|
|
|
3617
4110
|
};
|
|
3618
4111
|
}
|
|
3619
4112
|
|
|
4113
|
+
// D-pattern-accrual: all three pattern commands are repo-independent, like `bskel new` itself --
|
|
4114
|
+
// a pattern store is a user-owned CROSS-PROJECT resource, never scoped to the current repo/feature,
|
|
4115
|
+
// so none of them call requireRepoRoot(). Read-only; --pattern-database-url-env is required on all
|
|
4116
|
+
// three (mirrors O7's `handles audit` -- there is no meaningful "run without a live connection" mode).
|
|
4117
|
+
async function cmdPatternList(args) {
|
|
4118
|
+
const flags = parseCommand('pattern list', args);
|
|
4119
|
+
if (flags.help) { console.log(renderCommandHelp('pattern list')); process.exit(0); }
|
|
4120
|
+
setContext('pattern list', flags);
|
|
4121
|
+
|
|
4122
|
+
const connectionString = process.env[flags['pattern-database-url-env']];
|
|
4123
|
+
if (!connectionString) {
|
|
4124
|
+
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)`);
|
|
4125
|
+
}
|
|
4126
|
+
if (flags.stack && !NEW_STACKS[flags.stack]) {
|
|
4127
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--stack must be one of: ${Object.keys(NEW_STACKS).join(', ')} (got ${JSON.stringify(flags.stack)})`);
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
let records;
|
|
4131
|
+
try {
|
|
4132
|
+
records = await listPatterns({ connectionString, stack: flags.stack });
|
|
4133
|
+
} catch (err) {
|
|
4134
|
+
if (isMissingPatternTable(err)) {
|
|
4135
|
+
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).');
|
|
4136
|
+
}
|
|
4137
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query the pattern store: ${describeConnectionError(err)}`);
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4140
|
+
if (flags.json) {
|
|
4141
|
+
console.log(JSON.stringify({ records }, null, 2));
|
|
4142
|
+
} else {
|
|
4143
|
+
console.log(`pattern store -- ${records.length} record(s)${flags.stack ? ` (stack: ${flags.stack})` : ''}`);
|
|
4144
|
+
for (const r of records) {
|
|
4145
|
+
console.log(` ${r.pattern_id} [${r.stack}] ${r.recorded_at} ${JSON.stringify(r.params)}`);
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
process.exit(0);
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
async function cmdPatternShow(args) {
|
|
4152
|
+
const flags = parseCommand('pattern show', args);
|
|
4153
|
+
if (flags.help) { console.log(renderCommandHelp('pattern show')); process.exit(0); }
|
|
4154
|
+
setContext('pattern show', flags);
|
|
4155
|
+
const patternId = flags._[0];
|
|
4156
|
+
if (!patternId) {
|
|
4157
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel pattern show <pattern_id> --pattern-database-url-env <NAME>');
|
|
4158
|
+
}
|
|
4159
|
+
const connectionString = process.env[flags['pattern-database-url-env']];
|
|
4160
|
+
if (!connectionString) {
|
|
4161
|
+
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)`);
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
let record;
|
|
4165
|
+
try {
|
|
4166
|
+
record = await getPattern({ connectionString, patternId });
|
|
4167
|
+
} catch (err) {
|
|
4168
|
+
if (isMissingPatternTable(err)) {
|
|
4169
|
+
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).');
|
|
4170
|
+
}
|
|
4171
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query the pattern store: ${describeConnectionError(err)}`);
|
|
4172
|
+
}
|
|
4173
|
+
if (!record) {
|
|
4174
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no pattern record with id ${patternId}`);
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
if (flags.json) {
|
|
4178
|
+
console.log(JSON.stringify(record, null, 2));
|
|
4179
|
+
} else {
|
|
4180
|
+
console.log(`pattern ${record.pattern_id} [${record.stack}] recorded ${record.recorded_at}`);
|
|
4181
|
+
for (const [k, v] of Object.entries(record.params)) console.log(` --${k} ${v}`);
|
|
4182
|
+
}
|
|
4183
|
+
process.exit(0);
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
// D-pattern-accrual: `suggest`'s output is TEXT only -- a per-value frequency breakdown, honest about
|
|
4187
|
+
// disagreement, plus one paste-ready command line built from the top-ranked value per param. Never
|
|
4188
|
+
// fed into `bskel new` as a default; `bskel new` has no flag that would accept it as one (see
|
|
4189
|
+
// D-pattern-accrual's WHY for why this is the one design decision that keeps this feature inside
|
|
4190
|
+
// D-greenfield-parameters' safe/unsafe line).
|
|
4191
|
+
async function cmdPatternSuggest(args) {
|
|
4192
|
+
const flags = parseCommand('pattern suggest', args);
|
|
4193
|
+
if (flags.help) { console.log(renderCommandHelp('pattern suggest')); process.exit(0); }
|
|
4194
|
+
setContext('pattern suggest', flags);
|
|
4195
|
+
|
|
4196
|
+
const stack = NEW_STACKS[flags.stack];
|
|
4197
|
+
if (!stack) {
|
|
4198
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `--stack must be one of: ${Object.keys(NEW_STACKS).join(', ')} (got ${JSON.stringify(flags.stack)})`);
|
|
4199
|
+
}
|
|
4200
|
+
const connectionString = process.env[flags['pattern-database-url-env']];
|
|
4201
|
+
if (!connectionString) {
|
|
4202
|
+
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)`);
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
let records;
|
|
4206
|
+
try {
|
|
4207
|
+
records = await listPatterns({ connectionString, stack: flags.stack });
|
|
4208
|
+
} catch (err) {
|
|
4209
|
+
if (isMissingPatternTable(err)) {
|
|
4210
|
+
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).');
|
|
4211
|
+
}
|
|
4212
|
+
fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', `could not query the pattern store: ${describeConnectionError(err)}`);
|
|
4213
|
+
}
|
|
4214
|
+
|
|
4215
|
+
const summary = summarizePatternFrequency(records, stack.reusableParams);
|
|
4216
|
+
const suggestedFlags = summary.map(({ param, values }) => `--${param} ${values[0].value}`).join(' ');
|
|
4217
|
+
const suggestedCommand = `bskel new --stack ${stack.id} --slug <name>${suggestedFlags ? ` ${suggestedFlags}` : ''}`;
|
|
4218
|
+
|
|
4219
|
+
if (flags.json) {
|
|
4220
|
+
console.log(JSON.stringify({ stack: stack.id, total_records: records.length, summary, suggested_command: suggestedCommand }, null, 2));
|
|
4221
|
+
} else {
|
|
4222
|
+
console.log(`pattern suggest -- ${records.length} recorded ${stack.id} run(s)`);
|
|
4223
|
+
if (records.length === 0) {
|
|
4224
|
+
console.log(' (no patterns recorded yet for this stack -- nothing to suggest)');
|
|
4225
|
+
} else {
|
|
4226
|
+
for (const { param, values } of summary) {
|
|
4227
|
+
for (const { value, count, total } of values) {
|
|
4228
|
+
console.log(` --${param} ${value} (${count}/${total} runs)`);
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
console.log('');
|
|
4232
|
+
console.log('suggested command (edit before running -- bskel never applies this automatically):');
|
|
4233
|
+
console.log(` ${suggestedCommand}`);
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
process.exit(0);
|
|
4237
|
+
}
|
|
4238
|
+
|
|
3620
4239
|
async function cmdNew(args) {
|
|
3621
4240
|
const flags = parseCommand('new', args);
|
|
3622
4241
|
if (flags.help) { console.log(renderCommandHelp('new')); process.exit(0); }
|
|
@@ -3628,6 +4247,21 @@ async function cmdNew(args) {
|
|
|
3628
4247
|
}
|
|
3629
4248
|
requireValidSlug(flags.slug);
|
|
3630
4249
|
requireStackParams(stack, flags);
|
|
4250
|
+
// D-pattern-accrual: checked BEFORE any network call or filesystem write, same ordering
|
|
4251
|
+
// principle P2b's own comment states above -- a usage mistake (flag given without its required
|
|
4252
|
+
// partner, or an env var that was never exported) must never leave a half-scaffolded project
|
|
4253
|
+
// behind. The actual DB write later in this function is a SEPARATE, best-effort concern; this
|
|
4254
|
+
// block only validates that recording, if requested, is even POSSIBLE to attempt.
|
|
4255
|
+
let patternConnectionString = null;
|
|
4256
|
+
if (flags['record-pattern']) {
|
|
4257
|
+
if (!flags['pattern-database-url-env']) {
|
|
4258
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--record-pattern requires --pattern-database-url-env <NAME>. Nothing was written.');
|
|
4259
|
+
}
|
|
4260
|
+
patternConnectionString = process.env[flags['pattern-database-url-env']];
|
|
4261
|
+
if (!patternConnectionString) {
|
|
4262
|
+
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.`);
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
3631
4265
|
|
|
3632
4266
|
let stackParams;
|
|
3633
4267
|
let warnings;
|
|
@@ -3671,6 +4305,27 @@ async function cmdNew(args) {
|
|
|
3671
4305
|
}
|
|
3672
4306
|
execFileSync('git', commitArgs, { cwd: dir });
|
|
3673
4307
|
|
|
4308
|
+
// D-pattern-accrual: best-effort, deliberately AFTER the project is already scaffolded and
|
|
4309
|
+
// committed -- failing here would be strictly worse than not recording (the project the user
|
|
4310
|
+
// asked for already exists). Only ever a stderr warning, never a fail()/non-zero exit; records
|
|
4311
|
+
// ONLY the flags new/index.mjs's reusableParamsFor(stack) names, and only the ones the user
|
|
4312
|
+
// actually typed (a param the user never passed stays absent from `params`, not defaulted in --
|
|
4313
|
+
// an omission is itself real information for `pattern suggest`, see patterns/store.mjs).
|
|
4314
|
+
if (patternConnectionString) {
|
|
4315
|
+
const patternParams = {};
|
|
4316
|
+
for (const param of reusableParamsFor(stack.id)) {
|
|
4317
|
+
if (flags[param] != null) patternParams[param] = String(flags[param]);
|
|
4318
|
+
}
|
|
4319
|
+
try {
|
|
4320
|
+
await recordPattern({ connectionString: patternConnectionString, stack: stack.id, params: patternParams });
|
|
4321
|
+
} catch (err) {
|
|
4322
|
+
const hint = isMissingPatternTable(err)
|
|
4323
|
+
? 'sbf_pattern does not exist in this database -- run patterns/schema.sql against it first (bskel never applies it automatically; see D-migration-scope).'
|
|
4324
|
+
: describeConnectionError(err);
|
|
4325
|
+
console.error(`warning: --record-pattern could not write to the pattern store: ${hint}`);
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
|
|
3674
4329
|
const { postScaffoldNotes = [], ...resultRest } = result;
|
|
3675
4330
|
if (flags.json) {
|
|
3676
4331
|
console.log(JSON.stringify({ stack: flags.stack, dir, ...resultRest, warnings, postScaffoldNotes }, null, 2));
|
|
@@ -3779,6 +4434,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3779
4434
|
const subArgs = rest.slice(1);
|
|
3780
4435
|
if (sub === 'emit') return cmdContractEmit(subArgs);
|
|
3781
4436
|
if (sub === 'export') return cmdContractExport(subArgs);
|
|
4437
|
+
if (sub === 'export-csv') return cmdContractExportCsv(subArgs);
|
|
3782
4438
|
if (sub === 'history') return cmdContractHistory(subArgs);
|
|
3783
4439
|
if (sub === 'validate') return cmdContractValidate(subArgs);
|
|
3784
4440
|
if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
|
|
@@ -3797,6 +4453,17 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3797
4453
|
process.exit(14);
|
|
3798
4454
|
break;
|
|
3799
4455
|
}
|
|
4456
|
+
case 'rules': {
|
|
4457
|
+
const sub = rest[0];
|
|
4458
|
+
const subArgs = rest.slice(1);
|
|
4459
|
+
if (sub === 'check') return cmdRulesCheck(subArgs);
|
|
4460
|
+
if (sub === 'list') return cmdRulesList(subArgs);
|
|
4461
|
+
if (sub === 'explain') return cmdRulesExplain(subArgs);
|
|
4462
|
+
if (sub === 'emit') return cmdRulesEmit(subArgs);
|
|
4463
|
+
usage();
|
|
4464
|
+
process.exit(14);
|
|
4465
|
+
break;
|
|
4466
|
+
}
|
|
3800
4467
|
case 'stack': {
|
|
3801
4468
|
if (rest[0] === 'apply') return cmdStackApply(rest.slice(1));
|
|
3802
4469
|
usage();
|
|
@@ -3871,6 +4538,20 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3871
4538
|
return cmdServe(rest);
|
|
3872
4539
|
case 'new':
|
|
3873
4540
|
return cmdNew(rest);
|
|
4541
|
+
case 'pattern': {
|
|
4542
|
+
if (rest[0] === 'list') return cmdPatternList(rest.slice(1));
|
|
4543
|
+
if (rest[0] === 'show') return cmdPatternShow(rest.slice(1));
|
|
4544
|
+
if (rest[0] === 'suggest') return cmdPatternSuggest(rest.slice(1));
|
|
4545
|
+
usage();
|
|
4546
|
+
process.exit(14);
|
|
4547
|
+
break;
|
|
4548
|
+
}
|
|
4549
|
+
case 'db': {
|
|
4550
|
+
if (rest[0] === 'erd') return await cmdDbErd(rest.slice(1));
|
|
4551
|
+
usage();
|
|
4552
|
+
process.exit(14);
|
|
4553
|
+
break;
|
|
4554
|
+
}
|
|
3874
4555
|
default:
|
|
3875
4556
|
usage();
|
|
3876
4557
|
process.exit(cmd ? 14 : 0);
|