backend-skeleton 1.2.0 → 1.4.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 (39) hide show
  1. package/README.md +100 -1
  2. package/bin/bskel.mjs +687 -6
  3. package/contracts/csv.mjs +100 -0
  4. package/handles/providers/java-spring/rules.mjs +143 -0
  5. package/handles/providers/java-spring/templates/EnforceRules.java.tmpl +44 -0
  6. package/handles/providers/java-spring/templates/RuleCheck.java.tmpl +215 -0
  7. package/handles/providers/java-spring/templates/RuleEnforcementAspect.java.tmpl +132 -0
  8. package/handles/providers/java-spring/templates/RuleSetLoader.java.tmpl +138 -0
  9. package/handles/providers/python-fastapi/rules.mjs +133 -0
  10. package/handles/providers/python-fastapi/templates/enforce_rules.py.tmpl +128 -0
  11. package/handles/providers/python-fastapi/templates/rule_check.py.tmpl +178 -0
  12. package/handles/providers/python-fastapi/templates/rule_set.py.tmpl +59 -0
  13. package/handles/providers/typescript-express/rules.mjs +129 -0
  14. package/handles/providers/typescript-express/templates/enforceRules.ts.tmpl +93 -0
  15. package/handles/providers/typescript-express/templates/ruleCheck.ts.tmpl +209 -0
  16. package/handles/providers/typescript-express/templates/ruleSet.ts.tmpl +61 -0
  17. package/lib/cli.mjs +99 -1
  18. package/lib/doctor.mjs +11 -10
  19. package/lib/gate-definitions.mjs +27 -1
  20. package/lib/workflow.mjs +15 -0
  21. package/new/index.mjs +20 -0
  22. package/package.json +6 -2
  23. package/patterns/schema.sql +18 -0
  24. package/patterns/store.mjs +122 -0
  25. package/rules/compile.mjs +433 -0
  26. package/rules/derived.mjs +87 -0
  27. package/rules/diagnostics.mjs +147 -0
  28. package/rules/store.mjs +141 -0
  29. package/rules/vocabulary.mjs +172 -0
  30. package/scanners/adapters/_express-shared.mjs +7 -9
  31. package/scanners/adapters/java-spring.mjs +7 -9
  32. package/scanners/adapters/python-fastapi.mjs +7 -9
  33. package/scanners/db/erd.mjs +0 -0
  34. package/scanners/index.mjs +70 -17
  35. package/scanners/render.mjs +12 -3
  36. package/scanners/text-util.mjs +22 -0
  37. package/schemas/feature-rules.schema.json +139 -0
  38. package/schemas/pattern-record.schema.json +19 -0
  39. package/schemas/scan-report.schema.json +6 -2
@@ -295,6 +295,32 @@ export const GATE_DEFINITIONS = Object.freeze({
295
295
  return inputs;
296
296
  },
297
297
  },
298
+ // D-business-rules (R7): a feature's compiled business rules. Its own gate rather than a
299
+ // widening of `dependencies` -- the same call cross_feature/dependencies/patch_transactions/
300
+ // conformance each made rather than overloading a neighbour, and the two genuinely differ:
301
+ // a dependency is an edge between two fields, a rule is a constraint checked against one
302
+ // feature's own contract. REQUIRED_WHEN_PRESENT because most features will never declare one.
303
+ //
304
+ // Positioned between `dependencies` and `handles`: after dependencies because a derived-field
305
+ // rule references a declared dependency edge, before handles because that is where generated
306
+ // code appears.
307
+ //
308
+ // contract_hash is an input, not just the two rules files: every pointer, scalar type, and
309
+ // enum state in a compiled rule was verified against the contract at compile time, so
310
+ // re-emitting the contract can invalidate a rule that still looks fine on its own. Without
311
+ // this token a contract change could silently leave a rule enforcing against a field that no
312
+ // longer exists. (`contract` is itself a REQUIRED gate, so this is defence in depth, not the
313
+ // only line -- the same relationship `handles`'s own contract_hash token already has.)
314
+ rules: {
315
+ name: 'rules',
316
+ scope: SCOPE.FEATURE,
317
+ verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
318
+ recompute: (root, featureId) => ({
319
+ rules_source_hash: sha256File(specPath(root, featureId, 'rules.yaml')),
320
+ rules_compiled_hash: sha256File(specPath(root, featureId, 'rules', `${featureId}.rules.json`)),
321
+ contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
322
+ }),
323
+ },
298
324
  // Staleness = the generated Java (or the contract it was generated from) has moved since
299
325
  // emit -- NOT "does the migration still match the DB schema" (unknowable without a live DB
300
326
  // connection this tool deliberately never opens on its own, see D-migration-scope). Note
@@ -403,7 +429,7 @@ export const GATE_DEFINITIONS = Object.freeze({
403
429
  // test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
404
430
  // key set, so a gate added to one and not the other fails loudly instead of silently vanishing
405
431
  // from `bskel verify` the way `stack` did before this module existed.
406
- export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'handles', 'stack', 'patch_transactions', 'conformance']);
432
+ export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'cross_feature', 'contract', 'dependencies', 'rules', 'handles', 'stack', 'patch_transactions', 'conformance']);
407
433
 
408
434
  export function getGateDefinition(name) {
409
435
  return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;
package/lib/workflow.mjs CHANGED
@@ -44,6 +44,11 @@ const ESTABLISH_COMMAND = {
44
44
  // directly on the first real stale occurrence and throws a raw TypeError instead of a clean
45
45
  // stale report.
46
46
  dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
47
+ // D-business-rules (R7): wired in the SAME commit as the `rules` gate itself, per the repeated
48
+ // warnings above -- this exact gap has now been found live three times (dependencies,
49
+ // conformance, and the cross_feature near-miss) and is the single most reliably-recurring
50
+ // mistake in this file.
51
+ rules: (id) => `bskel rules check --feature ${id}`,
47
52
  handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
48
53
  stack: () => 'bskel stack apply --choice <id> --apply',
49
54
  // D-patch-transactions: added in the SAME commit as the gate itself -- per the
@@ -78,11 +83,21 @@ function awaitingDispositionCommand(gateName, featureId) {
78
83
  // been exercised through `next` recommending the real `conformance`-gate command, so a real
79
84
  // `mutating: false` misclassification on a command that actually writes files + passes a gate went
80
85
  // unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
86
+ // D-pattern-accrual: `bskel new`/`bskel pattern list|show|suggest` were deliberately checked
87
+ // against this same gap (per this comment's own warning above) and found NOT to need an entry
88
+ // here or in ESTABLISH_COMMAND -- none of the four corresponds to a GATE_NAMES gate (`new` predates
89
+ // any feature/gate existing at all; the three `pattern` verbs are a repo-independent, cross-project
90
+ // read surface `next`/`status` never has reason to recommend). `handles audit` (O7) is the existing
91
+ // precedent for a read-only command correctly staying out of this list.
81
92
  const MUTATING_PREFIXES = [
82
93
  'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
83
94
  'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
84
95
  'bskel handles plan', 'bskel stack apply', 'bskel observe emit', 'bskel observe import',
85
96
  'bskel patch propose', 'bskel patch approve', 'bskel patch apply', 'bskel patch rollback',
97
+ // D-business-rules (R7): `rules check` writes the compiled artifact and passes its gate;
98
+ // `rules emit` writes generated source. `rules list`/`rules explain` are read-only and
99
+ // correctly absent -- the same per-verb split `pattern list|show|suggest` was checked against.
100
+ 'bskel rules check', 'bskel rules emit',
86
101
  ];
87
102
 
88
103
  // Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
package/new/index.mjs CHANGED
@@ -9,6 +9,13 @@
9
9
  // parameters it accepts and which it explicitly refuses), and deliberately still a plain object.
10
10
  // The reason two first-party stacks don't justify a registry hasn't changed just because each entry
11
11
  // grew three fields.
12
+ //
13
+ // D-pattern-accrual: a FOURTH field, `reusableParams` -- the subset of `acceptedParams` worth
14
+ // persisting to a user-owned pattern store when they opt in via `--record-pattern`. Deliberately a
15
+ // subset, not `acceptedParams` itself: per-project identity (`name`/`description`/`project-version`,
16
+ // spring's `artifact-id`/`package-name`) describes THIS project, not a reusable convention, and
17
+ // storing project names in a database buys nothing (see D-pattern-accrual's own EXIT for the full
18
+ // list and why each exclusion is drawn).
12
19
  import { scaffoldSpring } from './spring.mjs';
13
20
  import { scaffoldFastapi } from './fastapi.mjs';
14
21
 
@@ -39,6 +46,10 @@ export const STACKS = Object.freeze({
39
46
  'dependencies', 'add-dependencies',
40
47
  ]),
41
48
  refusedParams: SPRING_REFUSED_PARAMS,
49
+ // D-pattern-accrual: `group-id` is the one per-project-identity-shaped field kept in
50
+ // (unlike `artifact-id`/`package-name`) -- an organization's group id is itself a reusable
51
+ // convention (`com.ourco`, applied to every project), not a name unique to this one project.
52
+ reusableParams: Object.freeze(['java-version', 'packaging', 'dependencies', 'add-dependencies', 'group-id']),
42
53
  }),
43
54
  fastapi: Object.freeze({
44
55
  id: 'fastapi',
@@ -46,9 +57,18 @@ export const STACKS = Object.freeze({
46
57
  requiresNetwork: false,
47
58
  acceptedParams: Object.freeze([...COMMON_PARAMS, 'python-version', 'port', 'license', 'database']),
48
59
  refusedParams: Object.freeze({}),
60
+ reusableParams: Object.freeze(['python-version', 'port', 'license', 'database']),
49
61
  }),
50
62
  });
51
63
 
64
+ // D-pattern-accrual: the one place `bskel new`'s best-effort recording call and `bskel pattern
65
+ // suggest` both look up which flags are worth persisting for a stack -- never re-derived, never a
66
+ // hand-copied list at either call site.
67
+ export function reusableParamsFor(stackId) {
68
+ const stack = STACKS[stackId];
69
+ return stack ? stack.reusableParams : [];
70
+ }
71
+
52
72
  // Every parameter any stack knows about -- the set cmdNew checks a passed flag against to decide
53
73
  // "wrong stack" vs. "not a stack parameter at all". Derived, never a hand-maintained third list.
54
74
  export const ALL_STACK_PARAMS = Object.freeze([...new Set(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.2.0",
3
+ "version": "1.4.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,8 @@
26
26
  "handles/",
27
27
  "new/",
28
28
  "stack/",
29
+ "patterns/",
30
+ "rules/",
29
31
  "schemas/",
30
32
  "scripts/preflight-base-ref.sh",
31
33
  "LICENSE",
@@ -38,6 +40,8 @@
38
40
  "test:java-compile": "node scripts/java-compile-smoke.mjs",
39
41
  "test:python-import": "node scripts/python-import-smoke.mjs",
40
42
  "test:db-introspect": "node scripts/db-introspect-smoke.mjs",
43
+ "test:pattern-db": "node scripts/pattern-db-smoke.mjs",
44
+ "test:db-erd": "node scripts/db-erd-smoke.mjs",
41
45
  "test:registry-coverage": "node scripts/handles-registry-coverage-smoke.mjs",
42
46
  "test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
43
47
  "test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
@@ -48,7 +52,7 @@
48
52
  "test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
49
53
  "test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
50
54
  "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"
55
+ "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
56
  },
53
57
  "dependencies": {
54
58
  "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
+ }