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.
- package/README.md +100 -1
- package/bin/bskel.mjs +379 -6
- package/contracts/csv.mjs +100 -0
- package/lib/cli.mjs +62 -1
- package/lib/doctor.mjs +11 -10
- package/lib/workflow.mjs +6 -0
- package/new/index.mjs +20 -0
- package/package.json +5 -2
- package/patterns/schema.sql +18 -0
- package/patterns/store.mjs +122 -0
- package/scanners/adapters/_express-shared.mjs +7 -9
- package/scanners/adapters/java-spring.mjs +7 -9
- package/scanners/adapters/python-fastapi.mjs +7 -9
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +70 -17
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +6 -2
|
@@ -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
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
493
|
-
try
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|