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.
- package/README.md +127 -1
- package/bin/bskel.mjs +526 -20
- package/contracts/csv.mjs +100 -0
- package/handles/providers/java-spring/observe.mjs +15 -1
- package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +38 -0
- package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/ReceiptSigner.java.tmpl +174 -0
- package/handles/providers/python-fastapi/observe.mjs +5 -0
- package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +19 -0
- package/handles/providers/python-fastapi/templates/receipt_sign.py.tmpl +68 -0
- package/handles/providers/typescript-express/observe.mjs +5 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +19 -0
- package/handles/providers/typescript-express/templates/receiptSign.ts.tmpl +65 -0
- package/lib/cli.mjs +78 -2
- package/lib/doctor.mjs +11 -10
- package/lib/field-dependencies.mjs +2 -1
- package/lib/gate-definitions.mjs +8 -4
- package/lib/scan-report-paths.mjs +47 -0
- 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 +68 -11
- package/scanners/db/erd.mjs +0 -0
- package/scanners/index.mjs +71 -18
- package/scanners/render.mjs +12 -3
- package/scanners/text-util.mjs +22 -0
- package/schemas/conformance-report.schema.json +12 -1
- package/schemas/observe-receipt.schema.json +10 -1
- package/schemas/pattern-record.schema.json +19 -0
- package/schemas/scan-report.schema.json +7 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
|
|
2
|
+
// template and regenerate.
|
|
3
|
+
//
|
|
4
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
|
|
5
|
+
// time so `bskel observe import --pubkey <path>` can prove it genuinely came from this running
|
|
6
|
+
// app, not a hand-fabricated file. A self-contained PORT of lib/attest.mjs's own canonicalize()/
|
|
7
|
+
// signPayload() -- never imported directly. This file runs inside a DEPLOYED target app, a
|
|
8
|
+
// foreign process from this CLI's own even though both happen to be Node -- same "port, don't
|
|
9
|
+
// cross-import into generated code" rule codec.ts.tmpl already established for handles/codec.mjs.
|
|
10
|
+
// node:crypto only, zero new dependency, matching this project's own "no new dependency where the
|
|
11
|
+
// runtime already provides the primitive" discipline (the same reasoning java-spring's own
|
|
12
|
+
// ReceiptSigner.java uses java.security.Signature instead of a Bouncy Castle dependency).
|
|
13
|
+
//
|
|
14
|
+
// Canonicalization must be byte-identical to `bskel observe import`'s own verification side (Node's
|
|
15
|
+
// lib/attest.mjs canonicalize()) -- proven cross-language-compatible by direct execution against a
|
|
16
|
+
// real receipt containing a forward slash, a quote, and non-ASCII text (see DECISIONS.md
|
|
17
|
+
// D-runtime-conformance-receipts). Deep-sorted object keys, array order preserved, compact JSON,
|
|
18
|
+
// non-ASCII left unescaped -- exactly Node's own JSON.stringify default behavior once keys are
|
|
19
|
+
// pre-sorted, so no special-casing is needed here the way Python's ensure_ascii=False is.
|
|
20
|
+
|
|
21
|
+
import { sign as cryptoSign } from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
let privateKeyPem: string | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* NOT called by any generated code -- a human calls this once, themselves, at application
|
|
27
|
+
* startup, passing a PKCS#8 PEM private key string -- the exact format `bskel attest keygen`
|
|
28
|
+
* already writes. Never hardcode a real key value in source; read it from wherever you already
|
|
29
|
+
* keep secrets (an env var, a mounted file, a secrets manager). Unconfigured (the default,
|
|
30
|
+
* including null/blank) means every receipt stays unsigned -- backward compatible with every
|
|
31
|
+
* already-deployed app using this feature before signing existed.
|
|
32
|
+
*/
|
|
33
|
+
export function setSigningKey(pem: string | null | undefined): void {
|
|
34
|
+
privateKeyPem = pem && pem.trim().length > 0 ? pem : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isConfigured(): boolean {
|
|
38
|
+
return privateKeyPem !== null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
42
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
43
|
+
if (value !== null && typeof value === 'object') {
|
|
44
|
+
const sorted: Record<string, unknown> = {};
|
|
45
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
46
|
+
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
|
47
|
+
}
|
|
48
|
+
return sorted;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function canonicalize(value: unknown): string {
|
|
54
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass an
|
|
59
|
+
* object that does not yet carry a "signature" key -- this module never strips one itself.
|
|
60
|
+
*/
|
|
61
|
+
export function sign(receiptWithoutSignature: unknown): string {
|
|
62
|
+
if (!privateKeyPem) throw new Error('receiptSign.sign() called before setSigningKey()');
|
|
63
|
+
const canonical = canonicalize(receiptWithoutSignature);
|
|
64
|
+
return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
|
|
65
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -148,6 +148,13 @@ export const COMMANDS = {
|
|
|
148
148
|
},
|
|
149
149
|
allowPositionals: true,
|
|
150
150
|
},
|
|
151
|
+
'scan repair': {
|
|
152
|
+
usage: 'bskel scan repair --feature <id> [--json]',
|
|
153
|
+
options: {
|
|
154
|
+
feature: { type: 'string', default: null, required: true },
|
|
155
|
+
json: { type: 'boolean', default: false },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
151
158
|
'scan cross-feature-check': {
|
|
152
159
|
usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
|
|
153
160
|
options: {
|
|
@@ -234,6 +241,33 @@ export const COMMANDS = {
|
|
|
234
241
|
json: { type: 'boolean', default: false },
|
|
235
242
|
},
|
|
236
243
|
},
|
|
244
|
+
// D-contract-csv: deliberately fewer flags than `contract export` -- no `--allow-unprefixed`
|
|
245
|
+
// (there is no hard refusal here to override, only a warning that always fires), no
|
|
246
|
+
// `--status-codes` (not an OpenAPI-shaped concept). `--bom` is CSV-specific: opt-in UTF-8 BOM
|
|
247
|
+
// for Excel-on-Windows, see D-contract-csv in DECISIONS.md.
|
|
248
|
+
'contract export-csv': {
|
|
249
|
+
usage: 'bskel contract export-csv --feature <id> [--out <path>] [--bom] [--json]',
|
|
250
|
+
options: {
|
|
251
|
+
feature: { type: 'string', default: null, required: true },
|
|
252
|
+
out: { type: 'string', default: null },
|
|
253
|
+
bom: { type: 'boolean', default: false },
|
|
254
|
+
json: { type: 'boolean', default: false },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
// D-db-erd: the other "recognizable artifact" export, read-only, repo-independent like
|
|
258
|
+
// `bskel new` (no --feature -- the database plane is not feature-scoped, see E6 in D-db-erd,
|
|
259
|
+
// DECISIONS.md). Deliberately no `--db` flag: `db` IS the verb, so it's implied -- internally
|
|
260
|
+
// reuses `resolveDbSchemaOrExit(root, {...flags, db:true})` unchanged, so env-var handling and
|
|
261
|
+
// error strings stay byte-identical to `scan --db`.
|
|
262
|
+
'db erd': {
|
|
263
|
+
usage: 'bskel db erd [--database-url-env <NAME>] [--schema public] [--out <path>] [--json]',
|
|
264
|
+
options: {
|
|
265
|
+
'database-url-env': { type: 'string', default: null },
|
|
266
|
+
schema: { type: 'string', default: 'public' },
|
|
267
|
+
out: { type: 'string', default: null },
|
|
268
|
+
json: { type: 'boolean', default: false },
|
|
269
|
+
},
|
|
270
|
+
},
|
|
237
271
|
// D-contract-history: read-only, so no --module/--openapi-file/etc -- it only ever reads what
|
|
238
272
|
// git already recorded for this feature's own contract file.
|
|
239
273
|
'contract history': {
|
|
@@ -394,12 +428,20 @@ export const COMMANDS = {
|
|
|
394
428
|
json: { type: 'boolean', default: false },
|
|
395
429
|
},
|
|
396
430
|
},
|
|
431
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): --pubkey alone has real
|
|
432
|
+
// standalone meaning (verify signatures where present, tolerate unsigned receipts) -- mirrors
|
|
433
|
+
// `serve`'s own `--sign-key` (opt-in signing, no `--require-sign-key` needed), NOT `gate export
|
|
434
|
+
// --sign`/`--key`'s mutual-requirement (a bare `--key` there is meaningless without `--sign`).
|
|
435
|
+
// --require-signature without --pubkey is refused -- mirrors `serve`'s own "mandatory signing,
|
|
436
|
+
// opt-in-to-more-strictness" `--require-sign-key`-without-`--sign-key` precedent exactly.
|
|
397
437
|
'observe import': {
|
|
398
|
-
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
|
|
438
|
+
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]',
|
|
399
439
|
options: {
|
|
400
440
|
feature: { type: 'string', default: null, required: true },
|
|
401
441
|
receipts: { type: 'string', default: null, required: true },
|
|
402
442
|
'fail-on-violation': { type: 'boolean', default: false },
|
|
443
|
+
pubkey: { type: 'string', default: null },
|
|
444
|
+
'require-signature': { type: 'boolean', default: false },
|
|
403
445
|
json: { type: 'boolean', default: false },
|
|
404
446
|
},
|
|
405
447
|
},
|
|
@@ -410,12 +452,18 @@ export const COMMANDS = {
|
|
|
410
452
|
// nobody actually asked for. The real defaults live in new/spring.mjs / new/fastapi.mjs, one
|
|
411
453
|
// place each.
|
|
412
454
|
new: {
|
|
413
|
-
usage: 'bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none]',
|
|
455
|
+
usage: 'bskel new --stack spring|fastapi --slug <name> [--dir <path>] [--offline] [--json] [--name <text>] [--description <text>] [--project-version <v>] [--group-id <pkg>] [--artifact-id <id>] [--package-name <pkg>] [--java-version <n>] [--packaging jar|war] [--dependencies a,b,c] [--add-dependencies a,b,c] [--python-version <spec>] [--port N] [--license <spdx>] [--database postgres|sqlite|none] [--record-pattern --pattern-database-url-env <NAME>]',
|
|
414
456
|
options: {
|
|
415
457
|
stack: { type: 'string', default: null, required: true },
|
|
416
458
|
slug: { type: 'string', default: null, required: true },
|
|
417
459
|
dir: { type: 'string', default: null },
|
|
418
460
|
offline: { type: 'boolean', default: false },
|
|
461
|
+
// D-pattern-accrual: both opt-in, both required together -- omitting either leaves
|
|
462
|
+
// `bskel new` byte-identical to today (no recording, no DB connection attempted). The
|
|
463
|
+
// actual write is best-effort (never fails the scaffold); only the "flag given but the
|
|
464
|
+
// other half missing / env var unset" usage mistake is checked before scaffold.
|
|
465
|
+
'record-pattern': { type: 'boolean', default: false },
|
|
466
|
+
'pattern-database-url-env': { type: 'string', default: null },
|
|
419
467
|
|
|
420
468
|
// Both stacks.
|
|
421
469
|
name: { type: 'string', default: null },
|
|
@@ -451,6 +499,34 @@ export const COMMANDS = {
|
|
|
451
499
|
'boot-version': { type: 'string', default: null, hidden: true },
|
|
452
500
|
},
|
|
453
501
|
},
|
|
502
|
+
// D-pattern-accrual: all three read-only, all requiring --pattern-database-url-env --
|
|
503
|
+
// there is no meaningful "run without a live connection" mode, same posture as `handles audit`
|
|
504
|
+
// (O7). Distinct flag name from every other command's --database-url-env: that flag always means
|
|
505
|
+
// the TARGET APPLICATION's database; this one is the user's own cross-project pattern store.
|
|
506
|
+
'pattern list': {
|
|
507
|
+
usage: 'bskel pattern list --pattern-database-url-env <NAME> [--stack spring|fastapi] [--json]',
|
|
508
|
+
options: {
|
|
509
|
+
'pattern-database-url-env': { type: 'string', default: null, required: true },
|
|
510
|
+
stack: { type: 'string', default: null },
|
|
511
|
+
json: { type: 'boolean', default: false },
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
'pattern show': {
|
|
515
|
+
usage: 'bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]',
|
|
516
|
+
options: {
|
|
517
|
+
'pattern-database-url-env': { type: 'string', default: null, required: true },
|
|
518
|
+
json: { type: 'boolean', default: false },
|
|
519
|
+
},
|
|
520
|
+
allowPositionals: true,
|
|
521
|
+
},
|
|
522
|
+
'pattern suggest': {
|
|
523
|
+
usage: 'bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]',
|
|
524
|
+
options: {
|
|
525
|
+
stack: { type: 'string', default: null, required: true },
|
|
526
|
+
'pattern-database-url-env': { type: 'string', default: null, required: true },
|
|
527
|
+
json: { type: 'boolean', default: false },
|
|
528
|
+
},
|
|
529
|
+
},
|
|
454
530
|
'handles patch approve': {
|
|
455
531
|
usage: 'bskel handles patch approve --feature <id> [--module <name>] --resource <Type> --field <name> --strategy patch-wrapper|null-means-unchanged --reason "..." [--json]',
|
|
456
532
|
options: {
|
package/lib/doctor.mjs
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
// D1's lib/workflow.mjs separates `computeWorkflowState()` from its cmdStatus/cmdNext callers --
|
|
3
3
|
// this stays pure enough to unit test without spawning the CLI, and bin/bskel.mjs just renders
|
|
4
4
|
// whatever this returns.
|
|
5
|
-
import { execFileSync } from 'node:child_process';
|
|
6
5
|
import { detectBuildCommand } from './verify.mjs';
|
|
7
6
|
import { listCatalogChoices, loadCatalogEntry } from '../stack/apply.mjs';
|
|
8
7
|
import { detectAstHelperAvailable } from '../handles/providers/java-spring/ast-bridge.mjs';
|
|
8
|
+
// D-zero-config-scan: re-exported from scanners/text-util.mjs (a cycle-free leaf module), NOT
|
|
9
|
+
// defined here -- this file transitively imports scanners/registry.mjs (via ./verify.mjs), which
|
|
10
|
+
// dynamically import()s every scanner adapter; an adapter importing binaryAvailable() FROM here
|
|
11
|
+
// would close that cycle. See scanners/text-util.mjs's own comment on binaryAvailable() for the
|
|
12
|
+
// real hang this caused before the fix. Every existing caller of `binaryAvailable` from this file
|
|
13
|
+
// (bin/bskel.mjs) needs no change -- it's still available at this same import path.
|
|
14
|
+
import { binaryAvailable } from '../scanners/text-util.mjs';
|
|
15
|
+
|
|
16
|
+
export { binaryAvailable };
|
|
9
17
|
|
|
10
18
|
// D5: the three workflows that have tool requirements beyond "git + a supported Node runtime"
|
|
11
19
|
// (every workflow needs those two -- preflight/contract don't need anything ELSE, so they're
|
|
@@ -34,15 +42,8 @@ function nodeVersionOk(versionString) {
|
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
function binaryCheck(name, { required, remediation }) {
|
|
37
|
-
|
|
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() {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// that gets passed and the token later required can never diverge.
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
|
|
14
|
+
import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
|
|
14
15
|
import { specPath } from './paths.mjs';
|
|
15
16
|
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
16
17
|
import { listFeatures, loadFeatureFile } from './featurelifecycle.mjs';
|
|
@@ -119,7 +120,7 @@ export function dependencyKey(dep) {
|
|
|
119
120
|
// slice doesn't address.
|
|
120
121
|
export function resolveClassFile(root, featureId, resourceType) {
|
|
121
122
|
const reportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
122
|
-
const report = readJsonIfExists(reportPath);
|
|
123
|
+
const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
|
|
123
124
|
if (!report) return { file: null, reason: 'no_scan_report' };
|
|
124
125
|
const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
|
|
125
126
|
if (!moduleName) return { file: null, reason: 'no_disposition' };
|
package/lib/gate-definitions.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { specPath, sbfPath } from './paths.mjs';
|
|
|
23
23
|
import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
|
|
24
24
|
import { loadManifest } from './handles-manifest.mjs';
|
|
25
25
|
import { dependenciesPath, resolveClassFile } from './field-dependencies.mjs';
|
|
26
|
+
import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
|
|
26
27
|
import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
|
|
27
28
|
import { listTransactions, transactionPath } from './patch-transactions.mjs';
|
|
28
29
|
|
|
@@ -243,7 +244,7 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
243
244
|
verifyPolicy: VERIFY_POLICY.REQUIRED,
|
|
244
245
|
recompute: (root, featureId) => {
|
|
245
246
|
const reportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
246
|
-
const report = readJsonIfExists(reportPath);
|
|
247
|
+
const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
|
|
247
248
|
const inputs = {
|
|
248
249
|
scan_report_hash: sha256File(reportPath),
|
|
249
250
|
contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
|
|
@@ -257,9 +258,12 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
257
258
|
// (part 3)" in DECISIONS.md.
|
|
258
259
|
for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? []), ...(mod?.dtos ?? [])]) {
|
|
259
260
|
if (!item.file) continue;
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
261
|
+
// D-scan-report-portable-paths: `.file` is repo-relative on disk (adapters write
|
|
262
|
+
// it that way now); `hydrateScanReportFilePaths()` above already re-anchored it to
|
|
263
|
+
// THIS root, so a plain path.relative(root, item.file) is correct regardless of
|
|
264
|
+
// where `bskel scan` originally ran -- no longer sensitive to a stale baked-in
|
|
265
|
+
// absolute path from a different worktree/clone. See DECISIONS.md for the real
|
|
266
|
+
// bug this closes (found via a real second-worktree pilot re-verification).
|
|
263
267
|
const rel = path.relative(root, item.file);
|
|
264
268
|
inputs[`${MODULE_FILE_PREFIX}${rel}`] = sha256File(item.file);
|
|
265
269
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// D-scan-report-portable-paths: every scanner adapter builds `related_modules[].{controllers,
|
|
2
|
+
// entities,enums,dtos}[].file` ABSOLUTE (this is deliberate, unchanged -- it is the shape every
|
|
3
|
+
// in-memory/direct-API consumer has always expected and still does: `runScan()` called directly
|
|
4
|
+
// and handed straight to `provider.plan()`/`planHandles()` is a real, widely-used pattern across
|
|
5
|
+
// this project's own test suite, not just the CLI). The ONLY place an absolute `.file` is a real
|
|
6
|
+
// problem is once it gets COMMITTED to git as part of `specs/<feature>/brownfield-scan.json`
|
|
7
|
+
// (confirmed NOT gitignored for real feature work) -- a value baked in from wherever `bskel scan`
|
|
8
|
+
// originally ran is meaningless once that same committed branch is checked out somewhere else (a
|
|
9
|
+
// second worktree, a different clone, CI). So the fix lives at exactly the disk-persistence
|
|
10
|
+
// boundary, not in the adapters or in every downstream consumer:
|
|
11
|
+
// - `dehydrateScanReportFilePaths()` converts absolute -> repo-relative, called ONCE, right
|
|
12
|
+
// before `bin/bskel.mjs`'s `cmdScan` writes the report to disk (mirrors the adapters' own
|
|
13
|
+
// pre-existing `filesRead` convention -- one `path.relative(repoRoot, f)` call, just applied
|
|
14
|
+
// at the write boundary instead of duplicated across 4 adapters).
|
|
15
|
+
// - `hydrateScanReportFilePaths()` converts back, called at every point something RE-LOADS the
|
|
16
|
+
// on-disk report (`loadHydratedScanReportOrExit`, the `contract` gate, `resolveClassFile`) --
|
|
17
|
+
// `path.isAbsolute(item.file)` doubles as a legacy-shape guard, so a report committed BEFORE
|
|
18
|
+
// this fix (already absolute on disk) passes through as a correct no-op rather than a bug;
|
|
19
|
+
// `bskel scan repair` is the real remedy for a stale-but-still-absolute value from a moved
|
|
20
|
+
// worktree, not this function.
|
|
21
|
+
//
|
|
22
|
+
// Both functions return a NEW object (via structuredClone), never mutate their input -- `cmdScan`
|
|
23
|
+
// needs the SAME in-memory `report` for both the disk write (dehydrated) and the `--json` stdout
|
|
24
|
+
// print (left absolute, untouched) from ONE scan; mutating in place would make whichever happened
|
|
25
|
+
// first corrupt the other.
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
function mapScanReportFiles(report, transform) {
|
|
29
|
+
if (!report) return report;
|
|
30
|
+
const next = structuredClone(report);
|
|
31
|
+
for (const mod of next.related_modules ?? []) {
|
|
32
|
+
for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
|
|
33
|
+
for (const item of mod[key] ?? []) {
|
|
34
|
+
if (item.file) item.file = transform(item.file);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hydrateScanReportFilePaths(report, repoRoot) {
|
|
42
|
+
return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? file : path.join(repoRoot, file)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function dehydrateScanReportFilePaths(report, repoRoot) {
|
|
46
|
+
return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? path.relative(repoRoot, file) : file));
|
|
47
|
+
}
|
package/lib/workflow.mjs
CHANGED
|
@@ -78,6 +78,12 @@ function awaitingDispositionCommand(gateName, featureId) {
|
|
|
78
78
|
// been exercised through `next` recommending the real `conformance`-gate command, so a real
|
|
79
79
|
// `mutating: false` misclassification on a command that actually writes files + passes a gate went
|
|
80
80
|
// unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
|
|
81
|
+
// D-pattern-accrual: `bskel new`/`bskel pattern list|show|suggest` were deliberately checked
|
|
82
|
+
// against this same gap (per this comment's own warning above) and found NOT to need an entry
|
|
83
|
+
// here or in ESTABLISH_COMMAND -- none of the four corresponds to a GATE_NAMES gate (`new` predates
|
|
84
|
+
// any feature/gate existing at all; the three `pattern` verbs are a repo-independent, cross-project
|
|
85
|
+
// read surface `next`/`status` never has reason to recommend). `handles audit` (O7) is the existing
|
|
86
|
+
// precedent for a read-only command correctly staying out of this list.
|
|
81
87
|
const MUTATING_PREFIXES = [
|
|
82
88
|
'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
|
|
83
89
|
'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
|
package/new/index.mjs
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
// parameters it accepts and which it explicitly refuses), and deliberately still a plain object.
|
|
10
10
|
// The reason two first-party stacks don't justify a registry hasn't changed just because each entry
|
|
11
11
|
// grew three fields.
|
|
12
|
+
//
|
|
13
|
+
// D-pattern-accrual: a FOURTH field, `reusableParams` -- the subset of `acceptedParams` worth
|
|
14
|
+
// persisting to a user-owned pattern store when they opt in via `--record-pattern`. Deliberately a
|
|
15
|
+
// subset, not `acceptedParams` itself: per-project identity (`name`/`description`/`project-version`,
|
|
16
|
+
// spring's `artifact-id`/`package-name`) describes THIS project, not a reusable convention, and
|
|
17
|
+
// storing project names in a database buys nothing (see D-pattern-accrual's own EXIT for the full
|
|
18
|
+
// list and why each exclusion is drawn).
|
|
12
19
|
import { scaffoldSpring } from './spring.mjs';
|
|
13
20
|
import { scaffoldFastapi } from './fastapi.mjs';
|
|
14
21
|
|
|
@@ -39,6 +46,10 @@ export const STACKS = Object.freeze({
|
|
|
39
46
|
'dependencies', 'add-dependencies',
|
|
40
47
|
]),
|
|
41
48
|
refusedParams: SPRING_REFUSED_PARAMS,
|
|
49
|
+
// D-pattern-accrual: `group-id` is the one per-project-identity-shaped field kept in
|
|
50
|
+
// (unlike `artifact-id`/`package-name`) -- an organization's group id is itself a reusable
|
|
51
|
+
// convention (`com.ourco`, applied to every project), not a name unique to this one project.
|
|
52
|
+
reusableParams: Object.freeze(['java-version', 'packaging', 'dependencies', 'add-dependencies', 'group-id']),
|
|
42
53
|
}),
|
|
43
54
|
fastapi: Object.freeze({
|
|
44
55
|
id: 'fastapi',
|
|
@@ -46,9 +57,18 @@ export const STACKS = Object.freeze({
|
|
|
46
57
|
requiresNetwork: false,
|
|
47
58
|
acceptedParams: Object.freeze([...COMMON_PARAMS, 'python-version', 'port', 'license', 'database']),
|
|
48
59
|
refusedParams: Object.freeze({}),
|
|
60
|
+
reusableParams: Object.freeze(['python-version', 'port', 'license', 'database']),
|
|
49
61
|
}),
|
|
50
62
|
});
|
|
51
63
|
|
|
64
|
+
// D-pattern-accrual: the one place `bskel new`'s best-effort recording call and `bskel pattern
|
|
65
|
+
// suggest` both look up which flags are worth persisting for a stack -- never re-derived, never a
|
|
66
|
+
// hand-copied list at either call site.
|
|
67
|
+
export function reusableParamsFor(stackId) {
|
|
68
|
+
const stack = STACKS[stackId];
|
|
69
|
+
return stack ? stack.reusableParams : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
// Every parameter any stack knows about -- the set cmdNew checks a passed flag against to decide
|
|
53
73
|
// "wrong stack" vs. "not a stack parameter at all". Derived, never a hand-maintained third list.
|
|
54
74
|
export const ALL_STACK_PARAMS = Object.freeze([...new Set(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
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
|