backend-skeleton 1.2.0 → 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 CHANGED
@@ -32,9 +32,13 @@ check for a specific failure mode found the same way — see `DECISIONS.md` for
32
32
 
33
33
  - [Status: 1.0.0](#status-100)
34
34
  - [Quickstart](#quickstart)
35
+ - [Try it in 10 seconds](#try-it-in-10-seconds)
36
+ - [The gated workflow](#the-gated-workflow)
35
37
  - [Starting from nothing (greenfield)](#starting-from-nothing-greenfield)
36
38
  - [Publishing a feature's contract as OpenAPI (optional)](#publishing-a-features-contract-as-openapi-optional)
39
+ - [A CSV table of a feature's contract (optional)](#a-csv-table-of-a-features-contract-optional)
37
40
  - [Database schema (optional)](#database-schema-optional)
41
+ - [An ERD of your database schema (optional)](#an-erd-of-your-database-schema-optional)
38
42
  - [Applying DDL to a live database (optional)](#applying-ddl-to-a-live-database-optional)
39
43
  - [Declaring field-to-field dependencies (optional)](#declaring-field-to-field-dependencies-optional)
40
44
  - [Patching a config file (optional)](#patching-a-config-file-optional)
@@ -84,6 +88,21 @@ production -- which still splits the same way it always has:
84
88
 
85
89
  ## Quickstart
86
90
 
91
+ ### Try it in 10 seconds
92
+
93
+ ```bash
94
+ npm install -g backend-skeleton # or: npx backend-skeleton <command>
95
+ cd <any-existing-repo> # must be a git repository -- that's the only requirement
96
+ bskel scan # zero flags: every module/controller/entity/enum this repo's
97
+ # adapter can see, unscored -- no preflight, no feature, no
98
+ # files written, no gate touched
99
+ ```
100
+
101
+ That's a read-only look, not the gated workflow — for real feature work (collision-checked against
102
+ a specific idea, contract-gated, codegen), see below.
103
+
104
+ ### The gated workflow
105
+
87
106
  ```bash
88
107
  npm install -g backend-skeleton # or: npx backend-skeleton <command>
89
108
  cd <target-repo> # must be a git repository
@@ -193,6 +212,27 @@ bskel new --stack fastapi --slug my-service \
193
212
  The full parameter list, the measured API-validation matrix behind that split, and the warning
194
213
  behaviour are in `D-greenfield-parameters` in `DECISIONS.md`.
195
214
 
215
+ #### Remembering your own conventions across projects (optional)
216
+
217
+ If you start several projects with the same conventions, `bskel new` can record them into a
218
+ database **you own** -- never bskel's own state, never a shared store:
219
+
220
+ ```bash
221
+ export MY_PATTERNS=postgres://localhost/my_patterns # once: run patterns/schema.sql against it
222
+
223
+ bskel new --stack spring --slug billing \
224
+ --java-version 21 --group-id com.acme --dependencies web,data-jpa,validation,flyway \
225
+ --record-pattern --pattern-database-url-env MY_PATTERNS
226
+
227
+ bskel pattern suggest --stack spring --pattern-database-url-env MY_PATTERNS
228
+ ```
229
+
230
+ `pattern suggest` prints what you've recorded, with per-value frequency, and a ready-to-paste
231
+ command line at the bottom -- it never runs `bskel new` for you and `bskel new` has no flag that
232
+ would accept a suggestion as a default. Every value in a generated project is still one you typed
233
+ in that invocation. Omitting `--record-pattern`/`--pattern-database-url-env` leaves `bskel new`
234
+ exactly as it is today. See `D-pattern-accrual` in `DECISIONS.md`.
235
+
196
236
  ### Publishing a feature's contract as OpenAPI (optional)
197
237
 
198
238
  ```bash
@@ -232,6 +272,31 @@ contract's paths don't reflect (`--allow-unprefixed` overrides), and stamps ever
232
272
  reconciling a contract against its own export would make it confirm itself. See `D-openapi-export`
233
273
  in `DECISIONS.md`.
234
274
 
275
+ ### A CSV table of a feature's contract (optional)
276
+
277
+ ```bash
278
+ bskel contract export-csv --feature 001-organization-management --out organization.csv
279
+ ```
280
+
281
+ One row per operation, opened by someone who will never read a JSON Schema — a PM reviewing scope,
282
+ a lead deciding whether a partial contract is good enough to waive. Fourteen columns, always, in
283
+ the same order: `operation_id, verb, path, path_params, path_params_unverified, body,
284
+ request_body_required, request_body_fields, response_fields, error_fields, provenance, summary,
285
+ tags, security`.
286
+
287
+ **Every column is always present, even when every row leaves it blank** — a scan-only contract
288
+ (no `--openapi-file`) never states `summary`/`tags`/`security`, and the blank columns *are* that
289
+ finding, not something to hide: `export-csv` prints exactly which columns are empty for every
290
+ operation, and why, on stderr. Dropping an empty column would make the file's own shape depend on
291
+ its content, so it never does.
292
+
293
+ **Unlike `contract export`, this command is deliberately UNGATED** — it works even when the
294
+ `contract` gate hasn't passed yet, because its single most valuable moment is reviewing a partial
295
+ contract to decide whether to waive it. An unreflected global path-prefix signal (the thing
296
+ `contract export` hard-refuses on) downgrades to a stderr warning here instead. `--bom` prepends a
297
+ UTF-8 byte-order mark for Excel-on-Windows, which otherwise mangles non-ASCII text; omit it for
298
+ `pandas`/`csv.DictReader`/`diff`, which don't want one. See `D-contract-csv` in `DECISIONS.md`.
299
+
235
300
  ### Database schema (optional)
236
301
 
237
302
  `bskel scan --db` additionally scans Flyway/Liquibase migration files (local only, no network).
@@ -249,6 +314,40 @@ report also carries `generated_at` — when the underlying data was actually cap
249
314
  be judged for staleness rather than trusted blindly. See `D-cross-feature-fk-inference` in
250
315
  `DECISIONS.md`.
251
316
 
317
+ ### An ERD of your database schema (optional)
318
+
319
+ ```bash
320
+ bskel db erd --database-url-env BSKEL_DB_URL --schema public --out schema.mmd
321
+ ```
322
+
323
+ A Mermaid `erDiagram` of the database — paste it straight into a GitHub/GitLab/Notion/Obsidian
324
+ markdown file (fence it in \`\`\`mermaid) or [mermaid.live](https://mermaid.live) and it renders
325
+ with no extra tooling. Works two ways:
326
+
327
+ - **With `--database-url-env`**: a real, live Postgres introspection — full column types,
328
+ nullability, and primary/foreign keys.
329
+ - **Without it**: falls back to scanning Flyway/Liquibase `.sql` migration files (no network, no
330
+ credentials needed) — a real but **degraded** diagram, clearly marked as such in the file itself:
331
+ every column types as `unknown`, no `PK` badge appears anywhere, and a header block spells out
332
+ exactly what's missing. Useful for evaluating the tool on a repo you don't have DB credentials
333
+ for yet.
334
+
335
+ Two things this diagram deliberately does **not** guess:
336
+
337
+ - **Composite foreign keys.** Postgres's own `information_schema` doesn't retain which source
338
+ column pairs with which target column once a foreign key spans more than one column — the raw
339
+ data is a cross product that can include pairs that were never declared. Rather than draw a wrong
340
+ relationship line, a composite FK collapses to one line labeled with all its source columns
341
+ joined by `+`, with the ambiguity spelled out in a `%%` comment above it. (Composite *primary*
342
+ keys have no such problem and render fully.)
343
+ - **1:1 vs 1:N.** The child side of every relationship is drawn as "zero or more," never "exactly
344
+ one" — telling those apart needs a UNIQUE constraint check this tool doesn't perform. The header
345
+ says so.
346
+
347
+ Whole-schema only in this version (no `--feature`/`--tables` filtering yet) — for a very large
348
+ schema, `db erd` prints a note above 40 entities rather than silently producing an unreadable
349
+ diagram. See `D-db-erd` in `DECISIONS.md`.
350
+
252
351
  ### Applying DDL to a live database (optional)
253
352
 
254
353
  `bskel patch propose --kind ddl-apply` extends the same propose/approve/apply/rollback lifecycle
@@ -385,7 +484,7 @@ below).
385
484
  |---|---|---|
386
485
  | Node.js | `>=18` | ES2022 (`Object.hasOwn`) + ESM top-level `await` — nothing newer is used anywhere in the runtime code (verified by grep across every recent-ES-addition pattern; see `D-npm-packaging` in `DECISIONS.md`) |
387
486
  | git | required | every gate is git-state-derived |
388
- | [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) | required for `scan`/`handles` | every scanner adapter shells out to it directly, and throws (not degrades) if it's missing |
487
+ | [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) | required for `scan`/`handles` | every scanner adapter shells out to it at `detect()` time behind a blanket try/catch — traced live, missing `rg` does NOT throw, it silently makes every real adapter detect nothing (degrades to the low-confidence `generic-grep` fallback). `bskel scan`'s report now carries a `rg_available: false` field plus an explicit `unknowns` warning whenever this happens, so it stays distinguishable from a genuinely-unrecognized repo — see `D-zero-config-scan` in `DECISIONS.md` |
389
488
  | `gh` (GitHub CLI) | optional | only used for `preflight`'s 3-way default-branch cross-check; already soft-guarded, never a hard requirement |
390
489
  | `python3` | optional | only needed to run this repository's own cross-language codec test — `bskel` itself never invokes `python3` |
391
490
  | a build wrapper (`gradlew`/`pom.xml`+`mvnw`/`package.json`) | optional | only `bskel verify --build` needs one; `handles emit` never compiles anything itself |
package/bin/bskel.mjs CHANGED
@@ -44,7 +44,8 @@ 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';
48
49
  import {
49
50
  requireSingleLineText, requireValidJavaPackageName, requireValidArtifactId,
50
51
  requireValidPythonVersion, requireValidLicense, requireValidDatabase, requireSupportedJavaVersion,
@@ -53,6 +54,8 @@ import {
53
54
  import { DEFAULT_GROUP_ID, DEFAULT_JAVA_VERSION, resolveSpringDependencies } from '../new/spring.mjs';
54
55
  import { buildReconciliation, snapshotFromReconciliation, describeSourceFile } from '../contracts/openapi.mjs';
55
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';
56
59
  import { loadCatalogEntry, listCatalogChoices, planApply, applyPlan } from '../stack/apply.mjs';
57
60
  import { PROVIDERS, PROVIDER_LOAD_ERRORS, providerById } from '../handles/registry.mjs';
58
61
  import { detectAstHelperAvailable, runAstClassify } from '../handles/providers/java-spring/ast-bridge.mjs';
@@ -65,7 +68,7 @@ import { plan as planTypeScriptExpress } from '../handles/providers/typescript-e
65
68
  import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
66
69
  import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
67
70
  import { computeWorkflowState } from '../lib/workflow.mjs';
68
- import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS } from '../lib/doctor.mjs';
71
+ import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS, binaryAvailable } from '../lib/doctor.mjs';
69
72
  import { parseCommand, renderCommandHelp, diagnostic } from '../lib/cli.mjs';
70
73
  import { EXIT_CODES } from '../lib/exit-codes.mjs';
71
74
  import { RESIDUAL_TEMPLATE_VAR_RE } from '../lib/template.mjs';
@@ -76,7 +79,10 @@ const SKILL_ROOT = path.resolve(__dirname, '..');
76
79
  function usage() {
77
80
  console.error(`bskel -- backend-skeleton CLI
78
81
 
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]
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]
80
86
  bskel preflight [--max-behind N] [--offline|--no-fetch] [--allow-dirty] [--max-age-minutes N] [--fetch-timeout-seconds N] [--json]
81
87
  bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
82
88
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
@@ -92,6 +98,8 @@ function usage() {
92
98
  bskel feature archive <id> --reason "..." [--json]
93
99
  bskel contract emit --feature <id> [--module <name>] [--json] [--openapi-file <path>] [--path-prefix /api/v0] [--descriptions]
94
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]
95
103
  bskel contract history --feature <id> [--json]
96
104
  bskel contract validate --feature <id> --file <envelope.json>
97
105
  bskel contract tool-schema --feature <id> --operation <operationId>
@@ -605,8 +613,16 @@ async function cmdScan(args) {
605
613
  setContext('scan', flags);
606
614
  const root = requireRepoRoot();
607
615
  const terms = deriveTerms(flags);
608
- if (terms.length === 0) {
609
- fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan [--feature <id>] --terms a,b,c (need at least one search term, from --terms or a --feature slug)');
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)');
610
626
  }
611
627
  if (flags.feature) {
612
628
  requireValidFeatureId(flags.feature);
@@ -614,6 +630,11 @@ async function cmdScan(args) {
614
630
  }
615
631
 
616
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');
617
638
 
618
639
  // G1: a broken adapter file doesn't stop the adapters that DID load, but every `scan` run
619
640
  // says so loudly (also see `bskel doctor`, which exits 1 while any of these remain).
@@ -622,7 +643,7 @@ async function cmdScan(args) {
622
643
  }
623
644
  let report;
624
645
  try {
625
- report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema });
646
+ report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable });
626
647
  } catch (err) {
627
648
  // Unreachable with the two shipped adapters (generic-grep's specificity-0 detect() is
628
649
  // unconditional) -- becomes reachable the moment a future adapter's detect() is
@@ -1589,6 +1610,181 @@ function cmdContractExport(args) {
1589
1610
  process.exitCode = EXIT.PASS;
1590
1611
  }
1591
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
+
1592
1788
  // A5: the `scan disposition` of contracts -- lets a human explicitly accept a `partial`
1593
1789
  // contract's outstanding warnings so the `contract` gate can pass. Deliberately no wildcard
1594
1790
  // waiver: `--all` expands to the SPECIFIC code+subject pairs present right now, recorded as
@@ -3617,6 +3813,132 @@ async function resolveNewParams(stack, flags) {
3617
3813
  };
3618
3814
  }
3619
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
+
3620
3942
  async function cmdNew(args) {
3621
3943
  const flags = parseCommand('new', args);
3622
3944
  if (flags.help) { console.log(renderCommandHelp('new')); process.exit(0); }
@@ -3628,6 +3950,21 @@ async function cmdNew(args) {
3628
3950
  }
3629
3951
  requireValidSlug(flags.slug);
3630
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
+ }
3631
3968
 
3632
3969
  let stackParams;
3633
3970
  let warnings;
@@ -3671,6 +4008,27 @@ async function cmdNew(args) {
3671
4008
  }
3672
4009
  execFileSync('git', commitArgs, { cwd: dir });
3673
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
+
3674
4032
  const { postScaffoldNotes = [], ...resultRest } = result;
3675
4033
  if (flags.json) {
3676
4034
  console.log(JSON.stringify({ stack: flags.stack, dir, ...resultRest, warnings, postScaffoldNotes }, null, 2));
@@ -3779,6 +4137,7 @@ async function dispatchCommand(cmd, rest) {
3779
4137
  const subArgs = rest.slice(1);
3780
4138
  if (sub === 'emit') return cmdContractEmit(subArgs);
3781
4139
  if (sub === 'export') return cmdContractExport(subArgs);
4140
+ if (sub === 'export-csv') return cmdContractExportCsv(subArgs);
3782
4141
  if (sub === 'history') return cmdContractHistory(subArgs);
3783
4142
  if (sub === 'validate') return cmdContractValidate(subArgs);
3784
4143
  if (sub === 'tool-schema') return cmdContractToolSchema(subArgs);
@@ -3871,6 +4230,20 @@ async function dispatchCommand(cmd, rest) {
3871
4230
  return cmdServe(rest);
3872
4231
  case 'new':
3873
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
+ }
3874
4247
  default:
3875
4248
  usage();
3876
4249
  process.exit(cmd ? 14 : 0);