backend-skeleton 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +127 -1
  2. package/bin/bskel.mjs +526 -20
  3. package/contracts/csv.mjs +100 -0
  4. package/handles/providers/java-spring/observe.mjs +15 -1
  5. package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +38 -0
  6. package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +13 -7
  7. package/handles/providers/java-spring/templates/ReceiptSigner.java.tmpl +174 -0
  8. package/handles/providers/python-fastapi/observe.mjs +5 -0
  9. package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +19 -0
  10. package/handles/providers/python-fastapi/templates/receipt_sign.py.tmpl +68 -0
  11. package/handles/providers/typescript-express/observe.mjs +5 -0
  12. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +19 -0
  13. package/handles/providers/typescript-express/templates/receiptSign.ts.tmpl +65 -0
  14. package/lib/cli.mjs +78 -2
  15. package/lib/doctor.mjs +11 -10
  16. package/lib/field-dependencies.mjs +2 -1
  17. package/lib/gate-definitions.mjs +8 -4
  18. package/lib/scan-report-paths.mjs +47 -0
  19. package/lib/workflow.mjs +6 -0
  20. package/new/index.mjs +20 -0
  21. package/package.json +5 -2
  22. package/patterns/schema.sql +18 -0
  23. package/patterns/store.mjs +122 -0
  24. package/scanners/adapters/_express-shared.mjs +7 -9
  25. package/scanners/adapters/java-spring.mjs +7 -9
  26. package/scanners/adapters/python-fastapi.mjs +68 -11
  27. package/scanners/db/erd.mjs +0 -0
  28. package/scanners/index.mjs +71 -18
  29. package/scanners/render.mjs +12 -3
  30. package/scanners/text-util.mjs +22 -0
  31. package/schemas/conformance-report.schema.json +12 -1
  32. package/schemas/observe-receipt.schema.json +10 -1
  33. package/schemas/pattern-record.schema.json +19 -0
  34. package/schemas/scan-report.schema.json +7 -3
@@ -0,0 +1,65 @@
1
+ // Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
2
+ // template and regenerate.
3
+ //
4
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
5
+ // time so `bskel observe import --pubkey <path>` can prove it genuinely came from this running
6
+ // app, not a hand-fabricated file. A self-contained PORT of lib/attest.mjs's own canonicalize()/
7
+ // signPayload() -- never imported directly. This file runs inside a DEPLOYED target app, a
8
+ // foreign process from this CLI's own even though both happen to be Node -- same "port, don't
9
+ // cross-import into generated code" rule codec.ts.tmpl already established for handles/codec.mjs.
10
+ // node:crypto only, zero new dependency, matching this project's own "no new dependency where the
11
+ // runtime already provides the primitive" discipline (the same reasoning java-spring's own
12
+ // ReceiptSigner.java uses java.security.Signature instead of a Bouncy Castle dependency).
13
+ //
14
+ // Canonicalization must be byte-identical to `bskel observe import`'s own verification side (Node's
15
+ // lib/attest.mjs canonicalize()) -- proven cross-language-compatible by direct execution against a
16
+ // real receipt containing a forward slash, a quote, and non-ASCII text (see DECISIONS.md
17
+ // D-runtime-conformance-receipts). Deep-sorted object keys, array order preserved, compact JSON,
18
+ // non-ASCII left unescaped -- exactly Node's own JSON.stringify default behavior once keys are
19
+ // pre-sorted, so no special-casing is needed here the way Python's ensure_ascii=False is.
20
+
21
+ import { sign as cryptoSign } from 'node:crypto';
22
+
23
+ let privateKeyPem: string | null = null;
24
+
25
+ /**
26
+ * NOT called by any generated code -- a human calls this once, themselves, at application
27
+ * startup, passing a PKCS#8 PEM private key string -- the exact format `bskel attest keygen`
28
+ * already writes. Never hardcode a real key value in source; read it from wherever you already
29
+ * keep secrets (an env var, a mounted file, a secrets manager). Unconfigured (the default,
30
+ * including null/blank) means every receipt stays unsigned -- backward compatible with every
31
+ * already-deployed app using this feature before signing existed.
32
+ */
33
+ export function setSigningKey(pem: string | null | undefined): void {
34
+ privateKeyPem = pem && pem.trim().length > 0 ? pem : null;
35
+ }
36
+
37
+ export function isConfigured(): boolean {
38
+ return privateKeyPem !== null;
39
+ }
40
+
41
+ function sortKeysDeep(value: unknown): unknown {
42
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
43
+ if (value !== null && typeof value === 'object') {
44
+ const sorted: Record<string, unknown> = {};
45
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
46
+ sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
47
+ }
48
+ return sorted;
49
+ }
50
+ return value;
51
+ }
52
+
53
+ function canonicalize(value: unknown): string {
54
+ return JSON.stringify(sortKeysDeep(value));
55
+ }
56
+
57
+ /**
58
+ * Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass an
59
+ * object that does not yet carry a "signature" key -- this module never strips one itself.
60
+ */
61
+ export function sign(receiptWithoutSignature: unknown): string {
62
+ if (!privateKeyPem) throw new Error('receiptSign.sign() called before setSigningKey()');
63
+ const canonical = canonicalize(receiptWithoutSignature);
64
+ return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
65
+ }
package/lib/cli.mjs CHANGED
@@ -148,6 +148,13 @@ export const COMMANDS = {
148
148
  },
149
149
  allowPositionals: true,
150
150
  },
151
+ 'scan repair': {
152
+ usage: 'bskel scan repair --feature <id> [--json]',
153
+ options: {
154
+ feature: { type: 'string', default: null, required: true },
155
+ json: { type: 'boolean', default: false },
156
+ },
157
+ },
151
158
  'scan cross-feature-check': {
152
159
  usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
153
160
  options: {
@@ -234,6 +241,33 @@ export const COMMANDS = {
234
241
  json: { type: 'boolean', default: false },
235
242
  },
236
243
  },
244
+ // D-contract-csv: deliberately fewer flags than `contract export` -- no `--allow-unprefixed`
245
+ // (there is no hard refusal here to override, only a warning that always fires), no
246
+ // `--status-codes` (not an OpenAPI-shaped concept). `--bom` is CSV-specific: opt-in UTF-8 BOM
247
+ // for Excel-on-Windows, see D-contract-csv in DECISIONS.md.
248
+ 'contract export-csv': {
249
+ usage: 'bskel contract export-csv --feature <id> [--out <path>] [--bom] [--json]',
250
+ options: {
251
+ feature: { type: 'string', default: null, required: true },
252
+ out: { type: 'string', default: null },
253
+ bom: { type: 'boolean', default: false },
254
+ json: { type: 'boolean', default: false },
255
+ },
256
+ },
257
+ // D-db-erd: the other "recognizable artifact" export, read-only, repo-independent like
258
+ // `bskel new` (no --feature -- the database plane is not feature-scoped, see E6 in D-db-erd,
259
+ // DECISIONS.md). Deliberately no `--db` flag: `db` IS the verb, so it's implied -- internally
260
+ // reuses `resolveDbSchemaOrExit(root, {...flags, db:true})` unchanged, so env-var handling and
261
+ // error strings stay byte-identical to `scan --db`.
262
+ 'db erd': {
263
+ usage: 'bskel db erd [--database-url-env <NAME>] [--schema public] [--out <path>] [--json]',
264
+ options: {
265
+ 'database-url-env': { type: 'string', default: null },
266
+ schema: { type: 'string', default: 'public' },
267
+ out: { type: 'string', default: null },
268
+ json: { type: 'boolean', default: false },
269
+ },
270
+ },
237
271
  // D-contract-history: read-only, so no --module/--openapi-file/etc -- it only ever reads what
238
272
  // git already recorded for this feature's own contract file.
239
273
  'contract history': {
@@ -394,12 +428,20 @@ export const COMMANDS = {
394
428
  json: { type: 'boolean', default: false },
395
429
  },
396
430
  },
431
+ // D-runtime-conformance-receipts (cryptographic receipt attestation): --pubkey alone has real
432
+ // standalone meaning (verify signatures where present, tolerate unsigned receipts) -- mirrors
433
+ // `serve`'s own `--sign-key` (opt-in signing, no `--require-sign-key` needed), NOT `gate export
434
+ // --sign`/`--key`'s mutual-requirement (a bare `--key` there is meaningless without `--sign`).
435
+ // --require-signature without --pubkey is refused -- mirrors `serve`'s own "mandatory signing,
436
+ // opt-in-to-more-strictness" `--require-sign-key`-without-`--sign-key` precedent exactly.
397
437
  'observe import': {
398
- usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
438
+ usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]',
399
439
  options: {
400
440
  feature: { type: 'string', default: null, required: true },
401
441
  receipts: { type: 'string', default: null, required: true },
402
442
  'fail-on-violation': { type: 'boolean', default: false },
443
+ pubkey: { type: 'string', default: null },
444
+ 'require-signature': { type: 'boolean', default: false },
403
445
  json: { type: 'boolean', default: false },
404
446
  },
405
447
  },
@@ -410,12 +452,18 @@ export const COMMANDS = {
410
452
  // nobody actually asked for. The real defaults live in new/spring.mjs / new/fastapi.mjs, one
411
453
  // place each.
412
454
  new: {
413
- usage: '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]',
455
+ usage: '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>]',
414
456
  options: {
415
457
  stack: { type: 'string', default: null, required: true },
416
458
  slug: { type: 'string', default: null, required: true },
417
459
  dir: { type: 'string', default: null },
418
460
  offline: { type: 'boolean', default: false },
461
+ // D-pattern-accrual: both opt-in, both required together -- omitting either leaves
462
+ // `bskel new` byte-identical to today (no recording, no DB connection attempted). The
463
+ // actual write is best-effort (never fails the scaffold); only the "flag given but the
464
+ // other half missing / env var unset" usage mistake is checked before scaffold.
465
+ 'record-pattern': { type: 'boolean', default: false },
466
+ 'pattern-database-url-env': { type: 'string', default: null },
419
467
 
420
468
  // Both stacks.
421
469
  name: { type: 'string', default: null },
@@ -451,6 +499,34 @@ export const COMMANDS = {
451
499
  'boot-version': { type: 'string', default: null, hidden: true },
452
500
  },
453
501
  },
502
+ // D-pattern-accrual: all three read-only, all requiring --pattern-database-url-env --
503
+ // there is no meaningful "run without a live connection" mode, same posture as `handles audit`
504
+ // (O7). Distinct flag name from every other command's --database-url-env: that flag always means
505
+ // the TARGET APPLICATION's database; this one is the user's own cross-project pattern store.
506
+ 'pattern list': {
507
+ usage: 'bskel pattern list --pattern-database-url-env <NAME> [--stack spring|fastapi] [--json]',
508
+ options: {
509
+ 'pattern-database-url-env': { type: 'string', default: null, required: true },
510
+ stack: { type: 'string', default: null },
511
+ json: { type: 'boolean', default: false },
512
+ },
513
+ },
514
+ 'pattern show': {
515
+ usage: 'bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]',
516
+ options: {
517
+ 'pattern-database-url-env': { type: 'string', default: null, required: true },
518
+ json: { type: 'boolean', default: false },
519
+ },
520
+ allowPositionals: true,
521
+ },
522
+ 'pattern suggest': {
523
+ usage: 'bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]',
524
+ options: {
525
+ stack: { type: 'string', default: null, required: true },
526
+ 'pattern-database-url-env': { type: 'string', default: null, required: true },
527
+ json: { type: 'boolean', default: false },
528
+ },
529
+ },
454
530
  'handles patch approve': {
455
531
  usage: 'bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]',
456
532
  options: {
package/lib/doctor.mjs CHANGED
@@ -2,10 +2,18 @@
2
2
  // D1's lib/workflow.mjs separates `computeWorkflowState()` from its cmdStatus/cmdNext callers --
3
3
  // this stays pure enough to unit test without spawning the CLI, and bin/bskel.mjs just renders
4
4
  // whatever this returns.
5
- import { execFileSync } from 'node:child_process';
6
5
  import { detectBuildCommand } from './verify.mjs';
7
6
  import { listCatalogChoices, loadCatalogEntry } from '../stack/apply.mjs';
8
7
  import { detectAstHelperAvailable } from '../handles/providers/java-spring/ast-bridge.mjs';
8
+ // D-zero-config-scan: re-exported from scanners/text-util.mjs (a cycle-free leaf module), NOT
9
+ // defined here -- this file transitively imports scanners/registry.mjs (via ./verify.mjs), which
10
+ // dynamically import()s every scanner adapter; an adapter importing binaryAvailable() FROM here
11
+ // would close that cycle. See scanners/text-util.mjs's own comment on binaryAvailable() for the
12
+ // real hang this caused before the fix. Every existing caller of `binaryAvailable` from this file
13
+ // (bin/bskel.mjs) needs no change -- it's still available at this same import path.
14
+ import { binaryAvailable } from '../scanners/text-util.mjs';
15
+
16
+ export { binaryAvailable };
9
17
 
10
18
  // D5: the three workflows that have tool requirements beyond "git + a supported Node runtime"
11
19
  // (every workflow needs those two -- preflight/contract don't need anything ELSE, so they're
@@ -34,15 +42,8 @@ function nodeVersionOk(versionString) {
34
42
  }
35
43
 
36
44
  function binaryCheck(name, { required, remediation }) {
37
- let ok = true;
38
- let detail = '';
39
- try {
40
- execFileSync(name, ['--version'], { stdio: 'pipe' });
41
- } catch {
42
- ok = false;
43
- detail = 'not found on PATH';
44
- }
45
- return { name: `binary: ${name}`, required, ok, detail, remediation: ok ? null : remediation };
45
+ const ok = binaryAvailable(name);
46
+ return { name: `binary: ${name}`, required, ok, detail: ok ? '' : 'not found on PATH', remediation: ok ? null : remediation };
46
47
  }
47
48
 
48
49
  function nodeVersionCheck() {
@@ -11,6 +11,7 @@
11
11
  // that gets passed and the token later required can never diverge.
12
12
  import path from 'node:path';
13
13
  import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
14
+ import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
14
15
  import { specPath } from './paths.mjs';
15
16
  import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
16
17
  import { listFeatures, loadFeatureFile } from './featurelifecycle.mjs';
@@ -119,7 +120,7 @@ export function dependencyKey(dep) {
119
120
  // slice doesn't address.
120
121
  export function resolveClassFile(root, featureId, resourceType) {
121
122
  const reportPath = specPath(root, featureId, 'brownfield-scan.json');
122
- const report = readJsonIfExists(reportPath);
123
+ const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
123
124
  if (!report) return { file: null, reason: 'no_scan_report' };
124
125
  const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
125
126
  if (!moduleName) return { file: null, reason: 'no_disposition' };
@@ -23,6 +23,7 @@ import { specPath, sbfPath } from './paths.mjs';
23
23
  import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
24
24
  import { loadManifest } from './handles-manifest.mjs';
25
25
  import { dependenciesPath, resolveClassFile } from './field-dependencies.mjs';
26
+ import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
26
27
  import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
27
28
  import { listTransactions, transactionPath } from './patch-transactions.mjs';
28
29
 
@@ -243,7 +244,7 @@ export const GATE_DEFINITIONS = Object.freeze({
243
244
  verifyPolicy: VERIFY_POLICY.REQUIRED,
244
245
  recompute: (root, featureId) => {
245
246
  const reportPath = specPath(root, featureId, 'brownfield-scan.json');
246
- const report = readJsonIfExists(reportPath);
247
+ const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
247
248
  const inputs = {
248
249
  scan_report_hash: sha256File(reportPath),
249
250
  contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
@@ -257,9 +258,12 @@ export const GATE_DEFINITIONS = Object.freeze({
257
258
  // (part 3)" in DECISIONS.md.
258
259
  for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? []), ...(mod?.dtos ?? [])]) {
259
260
  if (!item.file) continue;
260
- // related_modules[].{controllers,entities,enums}[].file are stored ABSOLUTE
261
- // (unlike Part 1's own repo-relative files_read) -- confirmed live against a real
262
- // scan report before writing this.
261
+ // D-scan-report-portable-paths: `.file` is repo-relative on disk (adapters write
262
+ // it that way now); `hydrateScanReportFilePaths()` above already re-anchored it to
263
+ // THIS root, so a plain path.relative(root, item.file) is correct regardless of
264
+ // where `bskel scan` originally ran -- no longer sensitive to a stale baked-in
265
+ // absolute path from a different worktree/clone. See DECISIONS.md for the real
266
+ // bug this closes (found via a real second-worktree pilot re-verification).
263
267
  const rel = path.relative(root, item.file);
264
268
  inputs[`${MODULE_FILE_PREFIX}${rel}`] = sha256File(item.file);
265
269
  }
@@ -0,0 +1,47 @@
1
+ // D-scan-report-portable-paths: every scanner adapter builds `related_modules[].{controllers,
2
+ // entities,enums,dtos}[].file` ABSOLUTE (this is deliberate, unchanged -- it is the shape every
3
+ // in-memory/direct-API consumer has always expected and still does: `runScan()` called directly
4
+ // and handed straight to `provider.plan()`/`planHandles()` is a real, widely-used pattern across
5
+ // this project's own test suite, not just the CLI). The ONLY place an absolute `.file` is a real
6
+ // problem is once it gets COMMITTED to git as part of `specs/<feature>/brownfield-scan.json`
7
+ // (confirmed NOT gitignored for real feature work) -- a value baked in from wherever `bskel scan`
8
+ // originally ran is meaningless once that same committed branch is checked out somewhere else (a
9
+ // second worktree, a different clone, CI). So the fix lives at exactly the disk-persistence
10
+ // boundary, not in the adapters or in every downstream consumer:
11
+ // - `dehydrateScanReportFilePaths()` converts absolute -> repo-relative, called ONCE, right
12
+ // before `bin/bskel.mjs`'s `cmdScan` writes the report to disk (mirrors the adapters' own
13
+ // pre-existing `filesRead` convention -- one `path.relative(repoRoot, f)` call, just applied
14
+ // at the write boundary instead of duplicated across 4 adapters).
15
+ // - `hydrateScanReportFilePaths()` converts back, called at every point something RE-LOADS the
16
+ // on-disk report (`loadHydratedScanReportOrExit`, the `contract` gate, `resolveClassFile`) --
17
+ // `path.isAbsolute(item.file)` doubles as a legacy-shape guard, so a report committed BEFORE
18
+ // this fix (already absolute on disk) passes through as a correct no-op rather than a bug;
19
+ // `bskel scan repair` is the real remedy for a stale-but-still-absolute value from a moved
20
+ // worktree, not this function.
21
+ //
22
+ // Both functions return a NEW object (via structuredClone), never mutate their input -- `cmdScan`
23
+ // needs the SAME in-memory `report` for both the disk write (dehydrated) and the `--json` stdout
24
+ // print (left absolute, untouched) from ONE scan; mutating in place would make whichever happened
25
+ // first corrupt the other.
26
+ import path from 'node:path';
27
+
28
+ function mapScanReportFiles(report, transform) {
29
+ if (!report) return report;
30
+ const next = structuredClone(report);
31
+ for (const mod of next.related_modules ?? []) {
32
+ for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
33
+ for (const item of mod[key] ?? []) {
34
+ if (item.file) item.file = transform(item.file);
35
+ }
36
+ }
37
+ }
38
+ return next;
39
+ }
40
+
41
+ export function hydrateScanReportFilePaths(report, repoRoot) {
42
+ return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? file : path.join(repoRoot, file)));
43
+ }
44
+
45
+ export function dehydrateScanReportFilePaths(report, repoRoot) {
46
+ return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? path.relative(repoRoot, file) : file));
47
+ }
package/lib/workflow.mjs CHANGED
@@ -78,6 +78,12 @@ function awaitingDispositionCommand(gateName, featureId) {
78
78
  // been exercised through `next` recommending the real `conformance`-gate command, so a real
79
79
  // `mutating: false` misclassification on a command that actually writes files + passes a gate went
80
80
  // unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
81
+ // D-pattern-accrual: `bskel new`/`bskel pattern list|show|suggest` were deliberately checked
82
+ // against this same gap (per this comment's own warning above) and found NOT to need an entry
83
+ // here or in ESTABLISH_COMMAND -- none of the four corresponds to a GATE_NAMES gate (`new` predates
84
+ // any feature/gate existing at all; the three `pattern` verbs are a repo-independent, cross-project
85
+ // read surface `next`/`status` never has reason to recommend). `handles audit` (O7) is the existing
86
+ // precedent for a read-only command correctly staying out of this list.
81
87
  const MUTATING_PREFIXES = [
82
88
  'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
83
89
  'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
package/new/index.mjs CHANGED
@@ -9,6 +9,13 @@
9
9
  // parameters it accepts and which it explicitly refuses), and deliberately still a plain object.
10
10
  // The reason two first-party stacks don't justify a registry hasn't changed just because each entry
11
11
  // grew three fields.
12
+ //
13
+ // D-pattern-accrual: a FOURTH field, `reusableParams` -- the subset of `acceptedParams` worth
14
+ // persisting to a user-owned pattern store when they opt in via `--record-pattern`. Deliberately a
15
+ // subset, not `acceptedParams` itself: per-project identity (`name`/`description`/`project-version`,
16
+ // spring's `artifact-id`/`package-name`) describes THIS project, not a reusable convention, and
17
+ // storing project names in a database buys nothing (see D-pattern-accrual's own EXIT for the full
18
+ // list and why each exclusion is drawn).
12
19
  import { scaffoldSpring } from './spring.mjs';
13
20
  import { scaffoldFastapi } from './fastapi.mjs';
14
21
 
@@ -39,6 +46,10 @@ export const STACKS = Object.freeze({
39
46
  'dependencies', 'add-dependencies',
40
47
  ]),
41
48
  refusedParams: SPRING_REFUSED_PARAMS,
49
+ // D-pattern-accrual: `group-id` is the one per-project-identity-shaped field kept in
50
+ // (unlike `artifact-id`/`package-name`) -- an organization's group id is itself a reusable
51
+ // convention (`com.ourco`, applied to every project), not a name unique to this one project.
52
+ reusableParams: Object.freeze(['java-version', 'packaging', 'dependencies', 'add-dependencies', 'group-id']),
42
53
  }),
43
54
  fastapi: Object.freeze({
44
55
  id: 'fastapi',
@@ -46,9 +57,18 @@ export const STACKS = Object.freeze({
46
57
  requiresNetwork: false,
47
58
  acceptedParams: Object.freeze([...COMMON_PARAMS, 'python-version', 'port', 'license', 'database']),
48
59
  refusedParams: Object.freeze({}),
60
+ reusableParams: Object.freeze(['python-version', 'port', 'license', 'database']),
49
61
  }),
50
62
  });
51
63
 
64
+ // D-pattern-accrual: the one place `bskel new`'s best-effort recording call and `bskel pattern
65
+ // suggest` both look up which flags are worth persisting for a stack -- never re-derived, never a
66
+ // hand-copied list at either call site.
67
+ export function reusableParamsFor(stackId) {
68
+ const stack = STACKS[stackId];
69
+ return stack ? stack.reusableParams : [];
70
+ }
71
+
52
72
  // Every parameter any stack knows about -- the set cmdNew checks a passed flag against to decide
53
73
  // "wrong stack" vs. "not a stack parameter at all". Derived, never a hand-maintained third list.
54
74
  export const ALL_STACK_PARAMS = Object.freeze([...new Set(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -26,6 +26,7 @@
26
26
  "handles/",
27
27
  "new/",
28
28
  "stack/",
29
+ "patterns/",
29
30
  "schemas/",
30
31
  "scripts/preflight-base-ref.sh",
31
32
  "LICENSE",
@@ -38,6 +39,8 @@
38
39
  "test:java-compile": "node scripts/java-compile-smoke.mjs",
39
40
  "test:python-import": "node scripts/python-import-smoke.mjs",
40
41
  "test:db-introspect": "node scripts/db-introspect-smoke.mjs",
42
+ "test:pattern-db": "node scripts/pattern-db-smoke.mjs",
43
+ "test:db-erd": "node scripts/db-erd-smoke.mjs",
41
44
  "test:registry-coverage": "node scripts/handles-registry-coverage-smoke.mjs",
42
45
  "test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
43
46
  "test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
@@ -48,7 +51,7 @@
48
51
  "test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
49
52
  "test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
50
53
  "test:oracle-corpus": "node scripts/shadow-validation-smoke.mjs --manifest test/fixtures/oracle-manifest.json",
51
- "test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:registry-coverage && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
54
+ "test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:pattern-db && npm run test:db-erd && npm run test:registry-coverage && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
52
55
  },
53
56
  "dependencies": {
54
57
  "ajv": "^8.20.0",
@@ -0,0 +1,18 @@
1
+ -- D-pattern-accrual: the sbf_pattern table `bskel new --record-pattern` writes to and
2
+ -- `bskel pattern list/show/suggest` read from. This is a database YOU own for YOUR OWN
3
+ -- projects' conventions -- bskel never creates it automatically (D-migration-scope: the same
4
+ -- "detect the missing table, name this exact file, never auto-DDL" posture handles/migration.sql.tmpl
5
+ -- already established for sbf_handle/sbf_handle_snapshot). Run this by hand, once, against
6
+ -- whatever Postgres database --pattern-database-url-env will point at.
7
+ --
8
+ -- `params` is JSONB, not one column per parameter, because the accepted parameter set differs
9
+ -- per stack (new/index.mjs's reusableParams) and grows independently of this table's own schema --
10
+ -- adding a stack, or widening a stack's reusableParams, needs zero migration here.
11
+ CREATE TABLE IF NOT EXISTS sbf_pattern (
12
+ pattern_id UUID PRIMARY KEY,
13
+ stack TEXT NOT NULL,
14
+ params JSONB NOT NULL,
15
+ recorded_at TIMESTAMPTZ NOT NULL
16
+ );
17
+
18
+ CREATE INDEX IF NOT EXISTS sbf_pattern_stack_idx ON sbf_pattern (stack);
@@ -0,0 +1,122 @@
1
+ // D-pattern-accrual: a database the USER owns for THEIR OWN past `bskel new` invocations --
2
+ // never bskel's own repo state, never a shared/external source (see D-pattern-accrual's own WHY
3
+ // for the line this draws against `.bskel/config.yml`'s standing refusal). Reuses A4/D-db-schema-plane's
4
+ // own `pg`/connection conventions unchanged: one `pg.Client`, sequential queries, `BEGIN TRANSACTION
5
+ // READ ONLY` on every read path (structural defense-in-depth, same as scanners/db/introspect.mjs and
6
+ // handles/audit.mjs). The one write path here (recordPattern) is a plain INSERT, no transaction
7
+ // needed for a single statement.
8
+ import pg from 'pg';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
11
+
12
+ const { Client } = pg;
13
+
14
+ // Postgres error code for "relation does not exist" -- the real, expected shape when
15
+ // patterns/schema.sql was never applied to this database (D-migration-scope: bskel never applies
16
+ // it automatically). Byte-identical convention to handles/audit.mjs's own isMissingHandleTables.
17
+ const UNDEFINED_TABLE = '42P01';
18
+
19
+ export function isMissingPatternTable(err) {
20
+ return err?.code === UNDEFINED_TABLE;
21
+ }
22
+
23
+ function rowToRecord(row) {
24
+ return {
25
+ schema: 'sbf.pattern/1',
26
+ pattern_id: row.pattern_id,
27
+ stack: row.stack,
28
+ params: row.params,
29
+ recorded_at: row.recorded_at instanceof Date ? row.recorded_at.toISOString() : row.recorded_at,
30
+ };
31
+ }
32
+
33
+ function validateRecord(record) {
34
+ const { ok, errors } = validateAgainstSchema('pattern-record.schema.json', record);
35
+ if (!ok) {
36
+ throw new Error(`refusing to write invalid pattern record:\n${formatSchemaErrors(errors).join('\n')}`);
37
+ }
38
+ }
39
+
40
+ // `params` here is already the raw, user-typed CLI flag values for whatever
41
+ // `new/index.mjs`'s reusableParamsFor(stack) names -- the caller (cmdNew) owns filtering down to
42
+ // that set; this function only validates the resulting record's shape and writes it. Best-effort
43
+ // from the CALLER's perspective (bin/bskel.mjs never lets a failure here fail `bskel new` itself) --
44
+ // this function itself throws on any real failure, same as every other patterns/store.mjs function.
45
+ export async function recordPattern({ connectionString, stack, params }) {
46
+ const record = {
47
+ schema: 'sbf.pattern/1',
48
+ pattern_id: randomUUID(),
49
+ stack,
50
+ params,
51
+ recorded_at: new Date().toISOString(),
52
+ };
53
+ validateRecord(record);
54
+ const client = new Client({ connectionString });
55
+ await client.connect();
56
+ try {
57
+ await client.query(
58
+ 'INSERT INTO sbf_pattern (pattern_id, stack, params, recorded_at) VALUES ($1, $2, $3, $4)',
59
+ [record.pattern_id, record.stack, JSON.stringify(record.params), record.recorded_at],
60
+ );
61
+ } finally {
62
+ await client.end();
63
+ }
64
+ return record;
65
+ }
66
+
67
+ export async function listPatterns({ connectionString, stack = null }) {
68
+ const client = new Client({ connectionString });
69
+ await client.connect();
70
+ try {
71
+ await client.query('BEGIN TRANSACTION READ ONLY');
72
+ const sql = stack
73
+ ? 'SELECT pattern_id, stack, params, recorded_at FROM sbf_pattern WHERE stack = $1 ORDER BY recorded_at DESC'
74
+ : 'SELECT pattern_id, stack, params, recorded_at FROM sbf_pattern ORDER BY recorded_at DESC';
75
+ const res = await client.query(sql, stack ? [stack] : []);
76
+ await client.query('COMMIT');
77
+ return res.rows.map(rowToRecord);
78
+ } finally {
79
+ await client.end();
80
+ }
81
+ }
82
+
83
+ export async function getPattern({ connectionString, patternId }) {
84
+ const client = new Client({ connectionString });
85
+ await client.connect();
86
+ try {
87
+ await client.query('BEGIN TRANSACTION READ ONLY');
88
+ const res = await client.query(
89
+ 'SELECT pattern_id, stack, params, recorded_at FROM sbf_pattern WHERE pattern_id = $1',
90
+ [patternId],
91
+ );
92
+ await client.query('COMMIT');
93
+ return res.rows.length > 0 ? rowToRecord(res.rows[0]) : null;
94
+ } finally {
95
+ await client.end();
96
+ }
97
+ }
98
+
99
+ // Pure -- no DB access, unit-testable directly (test/pattern-store.test.mjs). For each name in
100
+ // `reusableParams`, ranks every distinct value actually seen across `records` by how many records
101
+ // carried it, denominator always `records.length` (the full recorded-run count for this stack,
102
+ // NOT just the runs that happened to set this particular param -- an omitted param is real
103
+ // information, not missing data). Never collapses to one "best" value -- see D-pattern-accrual's
104
+ // WHY for the confidence-label posture this mirrors (D-cross-feature-collision).
105
+ export function summarizePatternFrequency(records, reusableParams) {
106
+ const total = records.length;
107
+ const summary = [];
108
+ for (const param of reusableParams) {
109
+ const counts = new Map();
110
+ for (const record of records) {
111
+ const value = record.params[param];
112
+ if (value == null) continue;
113
+ counts.set(value, (counts.get(value) ?? 0) + 1);
114
+ }
115
+ if (counts.size === 0) continue;
116
+ const values = [...counts.entries()]
117
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
118
+ .map(([value, count]) => ({ value, count, total }));
119
+ summary.push({ param, values });
120
+ }
121
+ return summary;
122
+ }
@@ -15,7 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { execFileSync } from 'node:child_process';
18
- import { listRgFiles as sharedListRgFiles, byShallowestThenName } from '../text-util.mjs';
18
+ import { listRgFiles as sharedListRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
19
19
 
20
20
  export const EXCLUDE_GLOBS = ['!**/node_modules/**', '!**/dist/**', '!**/build/**'];
21
21
  export const VERBS = ['get', 'post', 'put', 'patch', 'delete'];
@@ -220,14 +220,12 @@ export function expressDiagnostics(repoRoot) {
220
220
  } else if (!pkgFiles.some((f) => declaresExpress(f))) {
221
221
  messages.push({ level: 'info', code: 'express-not-a-dependency', message: `found ${pkgFiles.length} package.json file(s), but none declare an express dependency` });
222
222
  }
223
- let rgOk = true;
224
- try {
225
- execFileSync('rg', ['--version'], { stdio: 'pipe' });
226
- } catch {
227
- rgOk = false;
228
- }
229
- if (!rgOk) {
230
- messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
223
+ // D-zero-config-scan: traced live -- this does NOT throw at scan time as the message used to
224
+ // claim. detect()'s own rg shell-outs (e.g. listCandidatePackageFiles()) are wrapped in a
225
+ // blanket try/catch that returns [] on failure, so a missing `rg` makes this adapter silently
226
+ // detect nothing (degrades to generic-grep) rather than crashing. Corrected below.
227
+ if (!binaryAvailable('rg')) {
228
+ messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it for file discovery and silently detects nothing without it (degrades to the generic-grep fallback), it does not throw' });
231
229
  }
232
230
  // D-openapi-extraction-hint: like FastAPI, both Express adapters declare `api.operations:
233
231
  // false` -- --openapi-file is load-bearing for `contract emit` to adopt any operation, not just
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { execFileSync } from 'node:child_process';
8
- import { lineNumberAt, listRgFiles, byShallowestThenName } from '../text-util.mjs';
8
+ import { lineNumberAt, listRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
9
9
  import { maskNonCode, findClassOrRecordDeclaration, findClassLevelMappingArgs, findMappingAnnotations } from './_java-spring-analyzer.mjs';
10
10
 
11
11
  const JAVA_BUILD_FILE_GLOBS = ['build.gradle', 'build.gradle.kts', 'pom.xml'];
@@ -423,14 +423,12 @@ export const adapter = {
423
423
  if (!srcRoot) {
424
424
  messages.push({ level: 'info', code: 'no-src-main-java', message: buildFiles.length > 0 ? 'found a build file, but none has a sibling src/main/java' : 'src/main/java does not exist' });
425
425
  } else {
426
- let rgOk = true;
427
- try {
428
- execFileSync('rg', ['--version'], { stdio: 'pipe' });
429
- } catch {
430
- rgOk = false;
431
- }
432
- if (!rgOk) {
433
- messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it and will throw, not degrade, if it is missing' });
426
+ // D-zero-config-scan: traced live -- this does NOT throw at scan time as the message used
427
+ // to claim. detectJavaSpringRoot() itself (and every other rg shell-out at detect() time)
428
+ // is wrapped in a blanket try/catch that returns [] on failure, so a missing `rg` makes
429
+ // this adapter silently detect nothing (degrades to generic-grep) rather than crashing.
430
+ if (!binaryAvailable('rg')) {
431
+ messages.push({ level: 'warn', code: 'rg-missing', message: 'ripgrep (rg) is not on PATH -- this adapter shells out to it for file discovery and silently detects nothing without it (degrades to the generic-grep fallback), it does not throw' });
434
432
  }
435
433
  }
436
434
  // D-openapi-extraction-hint: `contract emit --openapi-file` (A1-A12) is where real accuracy