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.
@@ -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) {
@@ -271,6 +323,7 @@ export function runScan({ repoRoot, terms, includeDb = false, dbSchema = null, a
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
+ }
@@ -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
+ }
@@ -12,13 +12,17 @@
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" },