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.
Files changed (39) hide show
  1. package/README.md +100 -1
  2. package/bin/bskel.mjs +687 -6
  3. package/contracts/csv.mjs +100 -0
  4. package/handles/providers/java-spring/rules.mjs +143 -0
  5. package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
  6. package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
  7. package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
  8. package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
  9. package/handles/providers/python-fastapi/rules.mjs +133 -0
  10. package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
  11. package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
  12. package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
  13. package/handles/providers/typescript-express/rules.mjs +129 -0
  14. package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
  15. package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
  16. package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
  17. package/lib/cli.mjs +99 -1
  18. package/lib/doctor.mjs +11 -10
  19. package/lib/gate-definitions.mjs +27 -1
  20. package/lib/workflow.mjs +15 -0
  21. package/new/index.mjs +20 -0
  22. package/package.json +6 -2
  23. package/patterns/schema.sql +18 -0
  24. package/patterns/store.mjs +122 -0
  25. package/rules/compile.mjs +433 -0
  26. package/rules/derived.mjs +87 -0
  27. package/rules/diagnostics.mjs +147 -0
  28. package/rules/store.mjs +141 -0
  29. package/rules/vocabulary.mjs +172 -0
  30. package/scanners/adapters/_express-shared.mjs +7 -9
  31. package/scanners/adapters/java-spring.mjs +7 -9
  32. package/scanners/adapters/python-fastapi.mjs +7 -9
  33. package/scanners/db/erd.mjs +0 -0
  34. package/scanners/index.mjs +70 -17
  35. package/scanners/render.mjs +12 -3
  36. package/scanners/text-util.mjs +22 -0
  37. package/schemas/feature-rules.schema.json +139 -0
  38. package/schemas/pattern-record.schema.json +19 -0
  39. 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
- 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)');
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);