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
@@ -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__/**'];
@@ -25,6 +25,12 @@ const FASTAPI_DEP_RE = /(?:^|[\s"'[])fastapi(?:\[[^\]]*\])?(?:[\s"',\]=<>~!;]|$)
25
25
 
26
26
  const VERB_DECORATOR_RE = /@(\w+)\.(get|post|put|patch|delete)\s*\(/gi;
27
27
  const ROUTER_DECL_RE = /(\w+)\s*=\s*APIRouter\s*\(/g;
28
+
29
+ // D-fastapi-generic-module-name: real evidence only (polarsource/polar), not a speculative list --
30
+ // same "validated against a real oracle" precedent as KNOWN_DTO_SUFFIXES below. 57 real files
31
+ // literally named `endpoints.py`, 1 named `router.py`; no other generic stem (`routes`, `views`,
32
+ // `api`) has ever been observed.
33
+ const GENERIC_ROUTER_STEMS = new Set(['endpoints', 'router']);
28
34
  const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
29
35
  const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
30
36
 
@@ -287,8 +293,59 @@ const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve
287
293
  'document via `bskel contract emit --openapi-file <path> --path-prefix <prefix>` for trustworthy ' +
288
294
  'operation identity and schemas.';
289
295
 
296
+ // D-fastapi-generic-module-name: real polarsource/polar dogfooding found `path.basename(file,
297
+ // '.py')` (the module-name rule below) collapses to the literal string "endpoints"/"router" for
298
+ // every file using that repo's own real, common convention -- name every router file generically,
299
+ // carry the real domain identity in the PARENT DIRECTORY instead (`organization/endpoints.py`,
300
+ // `member/endpoints.py`). Two CLOSED passes, not interleaved -- a generic file's directory-derived
301
+ // candidate must never be adopted before every non-generic file's own literal name is known, or a
302
+ // later-discovered collision could arrive too late (the wrong merge would already be written).
303
+ // Pass 1: every non-generic-stem file's own literal name is `reserved`, unchanged from today.
304
+ // Pass 2: a generic-stem file's candidate (its own parent directory's basename) is adopted only if
305
+ // it collides with neither `reserved` nor another generic file's already-`claimed` candidate --
306
+ // real evidence found 9 such real collisions in polar alone (e.g. `subscription/endpoints.py`'s
307
+ // candidate "subscription" collides with the real, unrelated, non-generic
308
+ // `customer_portal/endpoints/subscription.py`). On any collision, the file keeps its old literal
309
+ // generic name -- never guess into a silent wrong merge. `files` is already sorted by full path
310
+ // (`listRgFiles`), so processing order (and therefore first-claim-wins) is deterministic.
311
+ function resolveGenericModuleNames(files, readFile, projectRoot) {
312
+ const routerFiles = [];
313
+ const reserved = new Set();
314
+ for (const file of files) {
315
+ if (!/APIRouter\s*\(/.test(readFile(file))) continue;
316
+ const stem = path.basename(file, '.py');
317
+ routerFiles.push(file);
318
+ if (!GENERIC_ROUTER_STEMS.has(stem)) reserved.add(stem);
319
+ }
320
+
321
+ const moduleNameByFile = new Map();
322
+ const claimed = new Set();
323
+ for (const file of routerFiles) {
324
+ const stem = path.basename(file, '.py');
325
+ if (!GENERIC_ROUTER_STEMS.has(stem)) {
326
+ moduleNameByFile.set(file, stem);
327
+ continue;
328
+ }
329
+ const dir = path.dirname(file);
330
+ const candidate = dir !== projectRoot ? path.basename(dir) : null;
331
+ if (candidate && !reserved.has(candidate) && !claimed.has(candidate)) {
332
+ claimed.add(candidate);
333
+ moduleNameByFile.set(file, candidate);
334
+ } else {
335
+ moduleNameByFile.set(file, stem); // collision or no meaningful parent -- keep the old literal name
336
+ }
337
+ }
338
+ return moduleNameByFile;
339
+ }
340
+
290
341
  export function scanPythonFastApi(repoRoot, projectRoot) {
291
342
  const files = listPythonFiles(projectRoot);
343
+ const fileTextCache = new Map();
344
+ const readFile = (f) => {
345
+ if (!fileTextCache.has(f)) fileTextCache.set(f, fs.readFileSync(f, 'utf8'));
346
+ return fileTextCache.get(f);
347
+ };
348
+ const moduleNameByFile = resolveGenericModuleNames(files, readFile, projectRoot);
292
349
  const modules = new Map();
293
350
  const moduleEntry = (name) => {
294
351
  if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
@@ -298,13 +355,15 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
298
355
  const allEntities = [];
299
356
  const allDtos = [];
300
357
  for (const file of files) {
301
- const text = fs.readFileSync(file, 'utf8');
358
+ const text = readFile(file);
302
359
 
303
360
  if (/APIRouter\s*\(/.test(text)) {
304
361
  // module = filename stem, NOT the router's own prefix -- verified against the real oracle's
305
362
  // login.py, which declares `APIRouter(tags=["login"])` with no prefix at all, so a
306
363
  // prefix-derived name fails on a real file while the filename stem works for every one.
307
- const moduleName = path.basename(file, '.py');
364
+ // D-fastapi-generic-module-name: for a GENERIC stem (`endpoints`/`router`), this is the
365
+ // collision-safe, directory-derived name instead -- see resolveGenericModuleNames() above.
366
+ const moduleName = moduleNameByFile.get(file);
308
367
  const routerPrefixes = extractRouterPrefixes(text);
309
368
  const rawEndpoints = extractEndpoints(text);
310
369
 
@@ -430,14 +489,12 @@ export const adapter = {
430
489
  })) {
431
490
  messages.push({ level: 'info', code: 'fastapi-not-a-dependency', message: `found ${depFiles.length} Python project file(s), but none declare a fastapi dependency` });
432
491
  }
433
- let rgOk = true;
434
- try {
435
- execFileSync('rg', ['--version'], { stdio: 'pipe' });
436
- } catch {
437
- rgOk = false;
438
- }
439
- if (!rgOk) {
440
- 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' });
441
498
  }
442
499
  // D-openapi-extraction-hint: unlike java-spring, this adapter's own capabilities already
443
500
  // declare `api.operations: false` (FastAPI assigns operationIds at runtime) -- --openapi-file
Binary file
@@ -182,7 +182,12 @@ export function computeDbDrift(liveTables, relatedModules) {
182
182
  // every other env-var-driven input in this codebase is resolved at the bin/bskel.mjs layer, never
183
183
  // inside a "pure" lib/scanner function), and keeps this function synchronous (Plane C's real I/O
184
184
  // is `await`ed by the caller before ever calling this).
185
- export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, adapters = ADAPTERS }) {
185
+ //
186
+ // D-zero-config-scan: `rgAvailable` is the SAME kind of CLI-boundary-resolved input as `dbSchema`
187
+ // -- bin/bskel.mjs computes it once via lib/doctor.mjs's binaryAvailable('rg') and hands it in as
188
+ // plain data. Defaults to `true` so every existing call site (this whole test suite included)
189
+ // stays byte-for-byte unchanged.
190
+ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, adapters = ADAPTERS, rgAvailable = true }) {
186
191
  const detections = adapters
187
192
  .map((a) => ({ a, d: a.detect(repoRoot) }))
188
193
  .filter(({ d }) => d != null)
@@ -221,26 +226,73 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
221
226
  // than crashing -- the gate token still falls back to hashing the report itself.
222
227
  const filesRead = result.filesRead ?? [];
223
228
 
224
- // O6: score alone isn't a deterministic sort key -- two modules tying on score fall back to
225
- // whatever order they were already in, which traces back to non-deterministic rg discovery
226
- // order in the adapters above (mitigated there too, but a second determinism layer here is
227
- // cheap and doesn't depend on every adapter getting it right). Module name is a stable,
228
- // meaningful secondary key.
229
- const scored = modules
230
- .map((m) => {
231
- const { score, evidence, cappedSignals } = scoreModule(m, terms);
232
- return { ...m, score, evidence, capped_signals: cappedSignals };
233
- })
234
- .sort((a, b) => b.score - a.score || a.module.localeCompare(b.module));
229
+ // D-zero-config-scan: an empty `terms` array is how bin/bskel.mjs's cmdScan signals its new
230
+ // zero-flag "inventory" mode (neither --terms nor --feature was given) -- every detected module
231
+ // is listed exactly as the adapter found it, with NO term-matching/scoring/collision-detection
232
+ // performed. Faking `score: 0` here would be actively misleading -- 0 already means "scored,
233
+ // found nothing" in the existing contract -- so each module is built as an explicit allowlist
234
+ // (never `{...m}`), which guarantees `score`/`evidence`/`capped_signals` are genuinely ABSENT
235
+ // from the object, not merely `undefined`, and stops any future adapter-internal field from
236
+ // leaking into this report shape by accident.
237
+ const isInventory = terms.length === 0;
238
+ let relatedModules;
239
+ let collisions;
240
+ let verdict;
241
+ if (isInventory) {
242
+ relatedModules = modules
243
+ .map((m) => ({ module: m.module, controllers: m.controllers, entities: m.entities, enums: m.enums, dtos: m.dtos }))
244
+ .sort((a, b) => a.module.localeCompare(b.module)); // deterministic -- no score to sort by
245
+ collisions = []; // not "nothing collides" -- nothing was CHECKED. See the disclaimer below.
246
+ verdict = 'inventory';
247
+ } else {
248
+ // O6: score alone isn't a deterministic sort key -- two modules tying on score fall back to
249
+ // whatever order they were already in, which traces back to non-deterministic rg discovery
250
+ // order in the adapters above (mitigated there too, but a second determinism layer here is
251
+ // cheap and doesn't depend on every adapter getting it right). Module name is a stable,
252
+ // meaningful secondary key.
253
+ const scored = modules
254
+ .map((m) => {
255
+ const { score, evidence, cappedSignals } = scoreModule(m, terms);
256
+ return { ...m, score, evidence, capped_signals: cappedSignals };
257
+ })
258
+ .sort((a, b) => b.score - a.score || a.module.localeCompare(b.module));
235
259
 
236
- const relatedModules = scored.filter((m) => m.score > 0);
237
- const collisions = relatedModules.filter((m) => m.score >= COLLISION_THRESHOLD);
260
+ relatedModules = scored.filter((m) => m.score > 0);
261
+ collisions = relatedModules.filter((m) => m.score >= COLLISION_THRESHOLD);
238
262
 
239
- let verdict = 'greenfield';
240
- if (collisions.length > 0) verdict = 'collision';
241
- else if (relatedModules.length > 0) verdict = 'adjacent';
263
+ verdict = 'greenfield';
264
+ if (collisions.length > 0) verdict = 'collision';
265
+ else if (relatedModules.length > 0) verdict = 'adjacent';
266
+ }
242
267
 
243
268
  const unknowns = [];
269
+ // D-zero-config-scan: the FIRST unknowns entry in inventory mode -- visible in --json too, not
270
+ // just prose a human might skim past -- so nothing downstream (human or agent) backfills a
271
+ // relevance/collision judgment this report never made. See D-greenfield-parameters's safe/
272
+ // unsafe line: every module/controller/entity listed below is a real, observed fact about this
273
+ // repo; this disclaimer is what keeps "here's what exists" from being read as "here's what's
274
+ // safe to build".
275
+ if (isInventory) {
276
+ unknowns.push(
277
+ 'this is an unscored inventory of every module this adapter found -- no term-matching, ' +
278
+ 'relevance scoring, or collision/greenfield classification was performed against any ' +
279
+ 'specific feature idea. Re-run with --terms <keywords> or --feature <id> to check a ' +
280
+ 'specific idea against this repo before treating anything here as "safe" or "colliding".',
281
+ );
282
+ }
283
+ // D-zero-config-scan: without `rg`, every real adapter's detect() silently degrades (a blanket
284
+ // try/catch around the rg shell-out returns [] on failure -- see scanners/text-util.mjs's
285
+ // listRgFiles()), so a real Spring/FastAPI/Express repo can look indistinguishable from one
286
+ // this tool genuinely doesn't recognize. `rg_available` (always present on the returned report,
287
+ // both modes) is the machine-checkable signal; this is the human-readable one.
288
+ if (!rgAvailable) {
289
+ unknowns.push(
290
+ 'ripgrep (`rg`) was not found on PATH -- every real scanner adapter depends on it for file ' +
291
+ 'discovery, so a low-confidence or apparently-empty result here may simply mean `rg` is ' +
292
+ 'missing, not that this repo lacks recognizable backend structure. Install it (`brew ' +
293
+ 'install ripgrep`) and re-run, or run `bskel doctor` for full diagnostics.',
294
+ );
295
+ }
244
296
  if (!includeDb) {
245
297
  unknowns.push('DB not scanned (Plane C is opt-in via --db --database-url-env <NAME>) -- pass --db to scan migration files, add --database-url-env for live introspection too. See A4 in CATALOG.md.');
246
298
  } else if (!dbSchema?.live) {
@@ -265,12 +317,13 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
265
317
  }
266
318
 
267
319
  return {
268
- schema: 'sbf.scan-report/1',
320
+ schema: 'sbf.scan-report/2',
269
321
  terms,
270
322
  adapter,
271
323
  confidence,
272
324
  api_surface_source: apiSurfaceSource,
273
325
  verdict,
326
+ rg_available: rgAvailable,
274
327
  path_prefix_signals: pathPrefixSignals,
275
328
  related_modules: relatedModules,
276
329
  collisions,
@@ -50,13 +50,22 @@ export function renderScanMarkdown(report) {
50
50
  lines.push(`**Verdict**: \`${report.verdict}\``);
51
51
  lines.push('');
52
52
 
53
+ // D-zero-config-scan: inventory mode has no term/collision semantics -- "greenfield for these
54
+ // terms" would be a lie when there were no terms to begin with, and a scored module's heading
55
+ // would dangle a `bskel scan explain` pointer that mode can never actually serve (it requires
56
+ // --feature, structurally unreachable here).
57
+ const isInventory = report.verdict === 'inventory';
53
58
  if (report.related_modules.length === 0) {
54
- lines.push('No related modules found -- greenfield for these terms.');
59
+ lines.push(isInventory
60
+ ? `No modules detected by the \`${report.adapter}\` adapter in this repo.`
61
+ : 'No related modules found -- greenfield for these terms.');
55
62
  } else {
56
- lines.push('## Related modules');
63
+ lines.push(isInventory ? '## Modules found' : '## Related modules');
57
64
  lines.push('');
58
65
  for (const mod of report.related_modules) {
59
- lines.push(`### \`${mod.module}\` (score: ${mod.score} -- run \`bskel scan explain ${mod.module}\` for the evidence breakdown)`);
66
+ lines.push(isInventory
67
+ ? `### \`${mod.module}\``
68
+ : `### \`${mod.module}\` (score: ${mod.score} -- run \`bskel scan explain ${mod.module}\` for the evidence breakdown)`);
60
69
  for (const c of mod.controllers) {
61
70
  lines.push(`- Controller \`${c.className}\` (base path \`${c.basePath}\`), ${c.endpoints.length} endpoint(s):`);
62
71
  for (const ep of c.endpoints) {
@@ -31,3 +31,25 @@ export function byShallowestThenName(a, b) {
31
31
  const depthB = b.split(path.sep).length;
32
32
  return depthA !== depthB ? depthA - depthB : a.localeCompare(b);
33
33
  }
34
+
35
+ // D-zero-config-scan: the pure "is this binary on PATH" check, deliberately living HERE (a
36
+ // leaf module with zero project-internal imports) rather than in lib/doctor.mjs, where it was
37
+ // first written. lib/doctor.mjs imports lib/verify.mjs, which imports scanners/registry.mjs --
38
+ // and every adapter file is dynamically import()ed BY registry.mjs's own top-level await. An
39
+ // adapter importing lib/doctor.mjs would close that cycle (adapter -> lib/doctor.mjs ->
40
+ // lib/verify.mjs -> scanners/registry.mjs -> [import()s the adapter]) and registry.mjs's OWN
41
+ // header comment already flags exactly this risk ("no adapter imports anything from this
42
+ // module" -- true of registry.mjs itself, but lib/doctor.mjs transitively reaches it). Found
43
+ // live: the first version of this change put binaryAvailable() in lib/doctor.mjs and every
44
+ // adapter import of it hung the whole CLI (a real "Detected unsettled top-level await" Node
45
+ // warning, reproduced against a real fixture repo, not a hypothetical). lib/doctor.mjs now
46
+ // re-exports this for its own existing callers (bin/bskel.mjs, its own binaryCheck()) --
47
+ // nothing outside this file needs to know it moved.
48
+ export function binaryAvailable(name, execFn = execFileSync) {
49
+ try {
50
+ execFn(name, ['--version'], { stdio: 'pipe' });
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
@@ -30,7 +30,9 @@
30
30
  "matched": { "type": "integer", "minimum": 0, "description": "receipts whose contract_ref equals the CURRENT contract hash -- these count as evidence for the current contract." },
31
31
  "stale_contract_ref": { "type": "integer", "minimum": 0, "description": "receipts recorded against a contract_ref that no longer matches -- kept on record for audit/trend purposes, not counted as current evidence." },
32
32
  "violations": { "type": "integer", "minimum": 0 },
33
- "unsupported": { "type": "integer", "minimum": 0 }
33
+ "unsupported": { "type": "integer", "minimum": 0 },
34
+ "unsigned": { "type": "integer", "minimum": 0, "description": "receipts with no signature field -- computed regardless of --pubkey, for forward visibility." },
35
+ "signature_invalid": { "type": "integer", "minimum": 0, "description": "receipts whose signature field failed verification -- only ever non-zero when --pubkey was given; meaningless (always 0) otherwise. Excluded from matched/violations, same as stale_contract_ref." }
34
36
  }
35
37
  },
36
38
  "by_operation": {
@@ -44,6 +46,15 @@
44
46
  "violations": { "type": "integer", "minimum": 0 }
45
47
  }
46
48
  }
49
+ },
50
+ "verification": {
51
+ "type": "object",
52
+ "additionalProperties": false,
53
+ "description": "Whether signature verification was even attempted at generation time -- without this, signature_invalid: 0 is ambiguous between 'verified, zero invalid' and 'never checked'. See D-runtime-conformance-receipts.",
54
+ "properties": {
55
+ "pubkey_given": { "type": "boolean" },
56
+ "require_signature": { "type": "boolean" }
57
+ }
47
58
  }
48
59
  }
49
60
  }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "urn:sbf:observe-receipt:1",
4
4
  "title": "backend-skeleton runtime contract-conformance receipt",
5
- "description": "One JSONL line ContractObservationAspect logs per @ObserveContract-annotated call. Verdict-only, by design -- never carries an observed request/response VALUE, only pointers + constraint kinds. See DECISIONS.md D-runtime-conformance-receipts. `bskel observe import --receipts <path>` reads a stream of these.",
5
+ "description": "One JSONL line ContractObservationAspect logs per @ObserveContract-annotated call. Verdict-only, by design -- never carries an observed request/response VALUE, only pointers + constraint kinds. See DECISIONS.md D-runtime-conformance-receipts. `bskel observe import --receipts <path>` reads a stream of these. `signature` is optional -- unsigned receipts stay valid; `bskel observe import --pubkey <path>` opts into verifying it where present. See the D-runtime-conformance-receipts 'cryptographic receipt attestation' Update note.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["feature_id", "feature_uid", "operation_id", "contract_ref", "verb", "recorded_at", "violations"],
@@ -27,6 +27,15 @@
27
27
  "message": { "type": "string", "maxLength": 300 }
28
28
  }
29
29
  }
30
+ },
31
+ "signature": {
32
+ "type": "object",
33
+ "additionalProperties": false,
34
+ "required": ["algorithm", "value"],
35
+ "properties": {
36
+ "algorithm": { "const": "ed25519" },
37
+ "value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over the canonicalized receipt (this field excluded)." }
38
+ }
30
39
  }
31
40
  }
32
41
  }
@@ -0,0 +1,19 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:pattern-record:1",
4
+ "title": "backend-skeleton pattern-accrual record (sbf_pattern.params, one row)",
5
+ "description": "D-pattern-accrual: one real `bskel new --record-pattern` invocation, recorded into a database the user owns and named via --pattern-database-url-env -- never bskel's own repo state. `params` holds ONLY the flag values new/index.mjs's reusableParamsFor(stack) names for that stack (the user-typed CLI value, not a resolved/defaulted one) -- keyed by the exact flag name (e.g. `java-version`, not `javaVersion`), so `bskel pattern suggest`'s output is always a literal, paste-ready `--flag value` pair. Validated on both write (patterns/store.mjs's recordPattern) and read (listPatterns/getPattern) -- the patch-approvals.mjs doctrine.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "pattern_id", "stack", "params", "recorded_at"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.pattern/1" },
11
+ "pattern_id": { "type": "string", "format": "uuid" },
12
+ "stack": { "enum": ["spring", "fastapi"] },
13
+ "params": {
14
+ "type": "object",
15
+ "additionalProperties": { "type": "string" }
16
+ },
17
+ "recorded_at": { "type": "string", "format": "date-time" }
18
+ }
19
+ }
@@ -6,19 +6,23 @@
6
6
  "additionalProperties": false,
7
7
  "required": ["schema", "terms", "adapter", "verdict", "related_modules", "collisions", "unknowns", "files_read"],
8
8
  "properties": {
9
- "schema": { "const": "sbf.scan-report/1" },
9
+ "schema": { "const": "sbf.scan-report/2" },
10
10
  "feature_id": { "type": "string" },
11
11
  "terms": { "type": "array", "items": { "type": "string" } },
12
12
  "adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
13
13
  "confidence": { "enum": ["high", "low"] },
14
14
  "api_surface_source": { "type": "string" },
15
- "verdict": { "enum": ["greenfield", "adjacent", "collision"] },
15
+ "verdict": { "enum": ["greenfield", "adjacent", "collision", "inventory"] },
16
+ "rg_available": {
17
+ "description": "D-zero-config-scan: whether `rg` (ripgrep) was found on PATH when this scan ran -- every real adapter's detect() silently degrades without it (see scanners/text-util.mjs's listRgFiles()), so this is the machine-checkable signal that distinguishes 'this repo genuinely has no recognizable backend structure' from 'rg is just missing'. Optional (not in the top-level `required` array below) purely because older/hand-written report objects predate this field -- every report `runScan()` itself produces always sets it.",
18
+ "type": "boolean"
19
+ },
16
20
  "path_prefix_signals": { "type": "array" },
17
21
  "related_modules": {
18
22
  "type": "array",
19
23
  "items": {
20
24
  "type": "object",
21
- "required": ["module", "score"],
25
+ "required": ["module"],
22
26
  "properties": {
23
27
  "module": { "type": "string" },
24
28
  "score": { "type": "number" },