backend-skeleton 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ // D-contract-csv: a spreadsheet-shaped projection of a feature contract -- one row per operation,
2
+ // opened by someone who will never read a JSON Schema. Pure module, no I/O, no gate awareness, no
3
+ // process -- mirrors contracts/export.mjs's own contract (see that file's header comment) exactly,
4
+ // so this file can be unit-tested with hand-built `contract` objects and nothing else.
5
+ //
6
+ // C1: a fixed 14-column header, always -- see C2 in DECISIONS.md for why a column is never
7
+ // dropped just because every operation leaves it blank (a scan-only contract's `summary`/`tags`/
8
+ // `security` columns ARE the finding "nobody stated these", not something to hide by omitting the
9
+ // column). C3: `description` is deliberately NOT a column (measured average 2,442.7 bytes/op,
10
+ // larger than every other copied field combined -- see D-openapi-description).
11
+ export const CSV_COLUMNS = Object.freeze([
12
+ { name: 'operation_id', extract: (op, operationId) => operationId },
13
+ { name: 'verb', extract: (op) => op.verb },
14
+ { name: 'path', extract: (op) => op.path },
15
+ { name: 'path_params', extract: (op) => sortedKeys(op.pathParams?.properties).join(', ') },
16
+ { name: 'path_params_unverified', extract: (op) => (op.pathParamsHeuristic ?? []).join(', ') },
17
+ { name: 'body', extract: (op) => String(op.body) },
18
+ { name: 'request_body_required', extract: (op) => (op.requestBodyRequired == null ? '' : String(op.requestBodyRequired)) },
19
+ { name: 'request_body_fields', extract: (op) => sortedKeys(op.requestBodySchema?.properties).join(', ') },
20
+ { name: 'response_fields', extract: (op) => sortedKeys(op.responseSchema?.properties).join(', ') },
21
+ { name: 'error_fields', extract: (op) => sortedKeys(op.errorSchema?.properties).join(', ') },
22
+ { name: 'provenance', extract: (op) => op.provenance },
23
+ { name: 'summary', extract: (op) => op.sourceSummary ?? '' },
24
+ { name: 'tags', extract: (op) => (op.sourceTags ?? []).join(', ') },
25
+ { name: 'security', extract: (op) => extractSecurity(op.sourceSecurity) },
26
+ ]);
27
+
28
+ function sortedKeys(properties) {
29
+ return properties ? Object.keys(properties).sort() : [];
30
+ }
31
+
32
+ // `security: []` is a genuine positive claim ("this operation declared no auth requirement") --
33
+ // see schemas/feature-contract.schema.json's own sourceSecurity description. An ABSENT
34
+ // sourceSecurity means the source document said nothing about security for this operation at all.
35
+ // The two must render distinguishably, or a reviewer cannot tell "confirmed open" from "unknown".
36
+ function extractSecurity(sourceSecurity) {
37
+ if (sourceSecurity == null) return '';
38
+ if (sourceSecurity.length === 0) return '(source-declared: none)';
39
+ const schemeNames = new Set();
40
+ for (const requirement of sourceSecurity) {
41
+ for (const scheme of Object.keys(requirement)) schemeNames.add(scheme);
42
+ }
43
+ return [...schemeNames].sort().join(', ');
44
+ }
45
+
46
+ // C4: RFC 4180, hand-rolled -- quote a field iff it contains a double quote, comma, CR, or LF;
47
+ // an embedded double quote is escaped by doubling it. Deliberately does NOT quote a field that
48
+ // needs none of this (the negative case is what catches an over-eager escaper in tests). No new
49
+ // dependency: this is the entire rule CSV has needed since RFC 4180 (2005).
50
+ export function escapeCsvField(value) {
51
+ const str = String(value);
52
+ if (/["\n\r,]/.test(str)) {
53
+ return `"${str.replaceAll('"', '""')}"`;
54
+ }
55
+ return str;
56
+ }
57
+
58
+ // C4: LF line terminator (not RFC 4180's CRLF) -- every other artifact this project writes is
59
+ // LF, and these files get committed/diffed. No comment/provenance preamble: line 1 is always the
60
+ // header row, because a leading `#`/comment line breaks `pandas.read_csv`/`csv.DictReader`/every
61
+ // spreadsheet importer's default settings -- provenance goes to stderr/`--json`, never into the
62
+ // file itself.
63
+ export function toCsv(header, rows) {
64
+ const lines = [header, ...rows].map((row) => row.map(escapeCsvField).join(','));
65
+ return `${lines.join('\n')}\n`;
66
+ }
67
+
68
+ // C5: deliberately does NOT check gate state, does NOT read a scan report, does NOT look at
69
+ // path-prefix signals -- this is a pure projection of an already-loaded `contract` object. The
70
+ // caller (bin/bskel.mjs's cmdContractExportCsv) owns every refusal/warning decision; this
71
+ // function only ever succeeds or reports "there's nothing to export" for a genuinely empty
72
+ // contract.
73
+ export function buildContractCsv({ contract }) {
74
+ const operationIds = Object.keys(contract.operations).sort((a, b) => a.localeCompare(b));
75
+ if (operationIds.length === 0) {
76
+ return { ok: false, error: 'contract has zero operations' };
77
+ }
78
+
79
+ const header = CSV_COLUMNS.map((c) => c.name);
80
+ const rows = operationIds.map((operationId) => {
81
+ const op = contract.operations[operationId];
82
+ return CSV_COLUMNS.map((c) => c.extract(op, operationId));
83
+ });
84
+
85
+ // C2: coverage is reported alongside the file, never inferred by a reader staring at blank
86
+ // cells wondering whether the tool is broken.
87
+ const columns = CSV_COLUMNS.map((c, i) => ({
88
+ name: c.name,
89
+ populatedRows: rows.filter((row) => row[i] !== '').length,
90
+ }));
91
+ const emptyColumns = columns.filter((c) => c.populatedRows === 0).map((c) => c.name);
92
+
93
+ return {
94
+ ok: true,
95
+ csv: toCsv(header, rows),
96
+ rowCount: rows.length,
97
+ columns,
98
+ emptyColumns,
99
+ };
100
+ }
package/lib/cli.mjs CHANGED
@@ -241,6 +241,33 @@ export const COMMANDS = {
241
241
  json: { type: 'boolean', default: false },
242
242
  },
243
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
+ },
244
271
  // D-contract-history: read-only, so no --module/--openapi-file/etc -- it only ever reads what
245
272
  // git already recorded for this feature's own contract file.
246
273
  'contract history': {
@@ -425,12 +452,18 @@ export const COMMANDS = {
425
452
  // nobody actually asked for. The real defaults live in new/spring.mjs / new/fastapi.mjs, one
426
453
  // place each.
427
454
  new: {
428
- 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>]',
429
456
  options: {
430
457
  stack: { type: 'string', default: null, required: true },
431
458
  slug: { type: 'string', default: null, required: true },
432
459
  dir: { type: 'string', default: null },
433
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 },
434
467
 
435
468
  // Both stacks.
436
469
  name: { type: 'string', default: null },
@@ -466,6 +499,34 @@ export const COMMANDS = {
466
499
  'boot-version': { type: 'string', default: null, hidden: true },
467
500
  },
468
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
+ },
469
530
  'handles patch approve': {
470
531
  usage: 'bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]',
471
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() {
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.2.0",
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
@@ -12,7 +12,7 @@
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
- import { lineNumberAt, listRgFiles as sharedListRgFiles, byShallowestThenName } from '../text-util.mjs';
15
+ import { lineNumberAt, listRgFiles as sharedListRgFiles, byShallowestThenName, binaryAvailable } from '../text-util.mjs';
16
16
 
17
17
  const PROJECT_FILE_GLOBS = ['pyproject.toml', 'requirements*.txt'];
18
18
  const EXCLUDE_GLOBS = ['!**/.venv/**', '!**/site-packages/**', '!**/node_modules/**', '!**/__pycache__/**'];
@@ -489,14 +489,12 @@ export const adapter = {
489
489
  })) {
490
490
  messages.push({ level: 'info', code: 'fastapi-not-a-dependency', message: `found ${depFiles.length} Python project file(s), but none declare a fastapi dependency` });
491
491
  }
492
- let rgOk = true;
493
- try {
494
- execFileSync('rg', ['--version'], { stdio: 'pipe' });
495
- } catch {
496
- rgOk = false;
497
- }
498
- if (!rgOk) {
499
- 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' });
492
+ // D-zero-config-scan: traced live -- this does NOT throw at scan time as the message used to
493
+ // claim. detectPythonFastApiRoot()'s own rg shell-out is wrapped in a blanket try/catch that
494
+ // returns [] on failure, so a missing `rg` makes this adapter silently detect nothing
495
+ // (degrades to generic-grep) rather than crashing.
496
+ if (!binaryAvailable('rg')) {
497
+ 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' });
500
498
  }
501
499
  // D-openapi-extraction-hint: unlike java-spring, this adapter's own capabilities already
502
500
  // declare `api.operations: false` (FastAPI assigns operationIds at runtime) -- --openapi-file
Binary file