backend-skeleton 1.0.0-beta.9 → 1.0.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 +122 -12
- package/bin/bskel.mjs +564 -15
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +26 -3
- package/contracts/openapi.mjs +29 -3
- package/handles/_engine.mjs +79 -29
- package/handles/providers/java-spring/plan.mjs +22 -9
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
- package/handles/providers/typescript-express/emit.mjs +23 -23
- package/handles/providers/typescript-express/observe.mjs +101 -0
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +125 -3
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/workflow.mjs +31 -3
- package/package.json +5 -2
- package/scanners/adapters/java-spring.mjs +6 -0
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +6 -0
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/stack/apply.mjs +4 -1
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
|
@@ -36,6 +36,12 @@ const FOREIGN_KEYS_SQL = `
|
|
|
36
36
|
ORDER BY tc.table_name, kcu.column_name`;
|
|
37
37
|
const INDEXES_SQL = `SELECT tablename AS table_name, indexname AS index_name FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`;
|
|
38
38
|
const RLS_POLICIES_SQL = `SELECT tablename AS table_name, policyname AS policy_name FROM pg_policies WHERE schemaname = $1 ORDER BY tablename, policyname`;
|
|
39
|
+
// D-ddl-apply (INDEX/SCHEMA postcondition precision): NOT schema-scoped, deliberately -- a
|
|
40
|
+
// `CREATE SCHEMA X` statement creates a schema that is NOT the `schema` param every query above is
|
|
41
|
+
// scoped to (typically `public`), so none of those queries can ever observe "does schema X now
|
|
42
|
+
// exist". information_schema.schemata lists every schema in the database, which is exactly what's
|
|
43
|
+
// needed here.
|
|
44
|
+
const SCHEMA_NAMES_SQL = `SELECT schema_name FROM information_schema.schemata`;
|
|
39
45
|
|
|
40
46
|
function groupByTable(rows, tableKey = 'table_name') {
|
|
41
47
|
const map = new Map();
|
|
@@ -60,49 +66,72 @@ export function describeConnectionError(err) {
|
|
|
60
66
|
return err.code ?? String(err);
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
// D-ddl-apply (INDEX/SCHEMA postcondition precision): used by executeDdlApply() to verify a
|
|
70
|
+
// CREATE/DROP SCHEMA statement's real effect against a live re-introspection -- the same open,
|
|
71
|
+
// uncommitted client/transaction its DDL just ran in, mirroring introspectWithClient()'s own
|
|
72
|
+
// contract exactly (caller owns connect/BEGIN/COMMIT/ROLLBACK/end).
|
|
73
|
+
export async function listSchemaNames(client) {
|
|
74
|
+
const res = await client.query(SCHEMA_NAMES_SQL);
|
|
75
|
+
return new Set(res.rows.map((r) => r.schema_name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// D-ddl-apply: split out of introspectSchema() so scanners/db/ddl-apply.mjs's write path can
|
|
79
|
+
// re-introspect using the SAME open, uncommitted `pg.Client`/transaction its DDL just ran in --
|
|
80
|
+
// observing the not-yet-committed effect of its own statements, without a second connection and
|
|
81
|
+
// without ever exposing a way to introspect outside of a transaction. Callers own connect()/
|
|
82
|
+
// BEGIN.../COMMIT/ROLLBACK/end() -- this function only ever runs the six read queries and shapes
|
|
83
|
+
// the result, identically regardless of which transaction mode the caller opened.
|
|
84
|
+
export async function introspectWithClient(client, schema = 'public') {
|
|
85
|
+
// Sequential, not Promise.all -- a single pg.Client processes one query at a time over one
|
|
86
|
+
// connection; issuing several concurrently on the same client is deprecated (pg queues them
|
|
87
|
+
// internally today, but warns, and that queuing behavior is going away in pg 9). A Pool
|
|
88
|
+
// would allow real concurrency, but this is a one-shot CLI invocation, not a long-lived
|
|
89
|
+
// server -- the simplicity of one client, one connection, sequential queries is the right
|
|
90
|
+
// trade-off here, not premature optimization for concurrency nothing needs.
|
|
91
|
+
const tablesRes = await client.query(TABLES_SQL, [schema]);
|
|
92
|
+
const columnsRes = await client.query(COLUMNS_SQL, [schema]);
|
|
93
|
+
const pkRes = await client.query(PRIMARY_KEYS_SQL, [schema]);
|
|
94
|
+
const fkRes = await client.query(FOREIGN_KEYS_SQL, [schema]);
|
|
95
|
+
const indexesRes = await client.query(INDEXES_SQL, [schema]);
|
|
96
|
+
const policiesRes = await client.query(RLS_POLICIES_SQL, [schema]);
|
|
97
|
+
|
|
98
|
+
const columnsByTable = groupByTable(columnsRes.rows);
|
|
99
|
+
const pkByTable = groupByTable(pkRes.rows);
|
|
100
|
+
const fkByTable = groupByTable(fkRes.rows);
|
|
101
|
+
const indexesByTable = groupByTable(indexesRes.rows);
|
|
102
|
+
const policiesByTable = groupByTable(policiesRes.rows);
|
|
103
|
+
|
|
104
|
+
const tables = tablesRes.rows.map(({ table_name: name }) => ({
|
|
105
|
+
name,
|
|
106
|
+
columns: (columnsByTable.get(name) ?? []).map((c) => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })),
|
|
107
|
+
primary_key: (pkByTable.get(name) ?? []).map((r) => r.column_name),
|
|
108
|
+
foreign_keys: (fkByTable.get(name) ?? []).map((r) => ({ column: r.column_name, references_table: r.foreign_table_name, references_column: r.foreign_column_name })),
|
|
109
|
+
indexes: (indexesByTable.get(name) ?? []).map((r) => r.index_name),
|
|
110
|
+
rls_policies: (policiesByTable.get(name) ?? []).map((r) => r.policy_name),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
// D-cross-feature-fk-inference (staleness/freshness token): stamped AFTER the queries complete,
|
|
114
|
+
// reflecting when introspection actually ran -- never included in schema_hash's own input (that
|
|
115
|
+
// hashes `tables` alone), so adding this field cannot change what schema_hash means or perturb
|
|
116
|
+
// any existing hash-equality check.
|
|
117
|
+
return { schema, tables, schema_hash: sha256String(JSON.stringify(tables)), generated_at: new Date().toISOString() };
|
|
118
|
+
}
|
|
119
|
+
|
|
63
120
|
// Entry point. `connectionString` is whatever `process.env[databaseUrlEnv]` resolved to -- the
|
|
64
121
|
// caller (scanners/index.mjs) owns reading that env var and failing loudly if it's unset; this
|
|
65
122
|
// function only ever receives an already-resolved string. `BEGIN TRANSACTION READ ONLY` is
|
|
66
123
|
// structural defense-in-depth -- every query here is already a SELECT, but a read-only
|
|
67
124
|
// transaction means the database itself refuses any write this connection could ever attempt,
|
|
68
|
-
// not just "we didn't write any queries that would".
|
|
125
|
+
// not just "we didn't write any queries that would". A thin connect/BEGIN-READ-ONLY/COMMIT/end
|
|
126
|
+
// wrapper around introspectWithClient() -- the query/shaping logic itself lives there now.
|
|
69
127
|
export async function introspectSchema({ connectionString, schema = 'public' }) {
|
|
70
128
|
const client = new Client({ connectionString });
|
|
71
129
|
await client.connect();
|
|
72
130
|
try {
|
|
73
131
|
await client.query('BEGIN TRANSACTION READ ONLY');
|
|
74
|
-
|
|
75
|
-
// Sequential, not Promise.all -- a single pg.Client processes one query at a time over one
|
|
76
|
-
// connection; issuing several concurrently on the same client is deprecated (pg queues them
|
|
77
|
-
// internally today, but warns, and that queuing behavior is going away in pg 9). A Pool
|
|
78
|
-
// would allow real concurrency, but this is a one-shot CLI invocation, not a long-lived
|
|
79
|
-
// server -- the simplicity of one client, one connection, sequential queries is the right
|
|
80
|
-
// trade-off here, not premature optimization for concurrency nothing needs.
|
|
81
|
-
const tablesRes = await client.query(TABLES_SQL, [schema]);
|
|
82
|
-
const columnsRes = await client.query(COLUMNS_SQL, [schema]);
|
|
83
|
-
const pkRes = await client.query(PRIMARY_KEYS_SQL, [schema]);
|
|
84
|
-
const fkRes = await client.query(FOREIGN_KEYS_SQL, [schema]);
|
|
85
|
-
const indexesRes = await client.query(INDEXES_SQL, [schema]);
|
|
86
|
-
const policiesRes = await client.query(RLS_POLICIES_SQL, [schema]);
|
|
87
|
-
|
|
132
|
+
const result = await introspectWithClient(client, schema);
|
|
88
133
|
await client.query('COMMIT');
|
|
89
|
-
|
|
90
|
-
const columnsByTable = groupByTable(columnsRes.rows);
|
|
91
|
-
const pkByTable = groupByTable(pkRes.rows);
|
|
92
|
-
const fkByTable = groupByTable(fkRes.rows);
|
|
93
|
-
const indexesByTable = groupByTable(indexesRes.rows);
|
|
94
|
-
const policiesByTable = groupByTable(policiesRes.rows);
|
|
95
|
-
|
|
96
|
-
const tables = tablesRes.rows.map(({ table_name: name }) => ({
|
|
97
|
-
name,
|
|
98
|
-
columns: (columnsByTable.get(name) ?? []).map((c) => ({ name: c.column_name, type: c.data_type, nullable: c.is_nullable === 'YES' })),
|
|
99
|
-
primary_key: (pkByTable.get(name) ?? []).map((r) => r.column_name),
|
|
100
|
-
foreign_keys: (fkByTable.get(name) ?? []).map((r) => ({ column: r.column_name, references_table: r.foreign_table_name, references_column: r.foreign_column_name })),
|
|
101
|
-
indexes: (indexesByTable.get(name) ?? []).map((r) => r.index_name),
|
|
102
|
-
rls_policies: (policiesByTable.get(name) ?? []).map((r) => r.policy_name),
|
|
103
|
-
}));
|
|
104
|
-
|
|
105
|
-
return { schema, tables, schema_hash: sha256String(JSON.stringify(tables)) };
|
|
134
|
+
return result;
|
|
106
135
|
} finally {
|
|
107
136
|
await client.end();
|
|
108
137
|
}
|
|
@@ -59,8 +59,38 @@ const ALTER_ADD_COLUMN_RE = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+COLUMN\s+(?:IF\s+
|
|
|
59
59
|
const CONSTRAINT_LEAD_RE = /^(PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK|CONSTRAINT)\b/i;
|
|
60
60
|
const COLUMN_NAME_RE = /^"?(\w+)"?/;
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// D-cross-feature-fk-inference (Plane A FK extraction): closes this project's own named EXIT item
|
|
63
|
+
// ("Plane A (migration-file) FK extraction is out of scope") -- `--db` alone (no
|
|
64
|
+
// `--database-url-env`) now contributes real FK data to the `db_foreign_key` cross-feature signal
|
|
65
|
+
// via a real, disposable Postgres connection's absence, using nothing but migration-file text.
|
|
66
|
+
// Same "good-enough regex, not a real parser" restraint as everything else in this module --
|
|
67
|
+
// single-column FKs only; a composite `FOREIGN KEY (a, b) REFERENCES ...` segment simply doesn't
|
|
68
|
+
// match either regex below and is silently skipped, the same fail-safe-by-omission behavior
|
|
69
|
+
// CONSTRAINT_LEAD_RE's own unmatched segments already have.
|
|
70
|
+
//
|
|
71
|
+
// Table-level: `FOREIGN KEY (col) REFERENCES other_table (ocol)` -- ocol is optional (references
|
|
72
|
+
// the parent's PK when omitted; genuinely rare in real migrations, still handled).
|
|
73
|
+
const TABLE_LEVEL_FK_RE = /^FOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)\s+REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?/i;
|
|
74
|
+
// Inline column-level: applied to a column-definition segment ALREADY matched by COLUMN_NAME_RE --
|
|
75
|
+
// `col_name TYPE ... REFERENCES other_table(ocol)` or bare `REFERENCES other_table`. When ocol is
|
|
76
|
+
// omitted, references_column is left `null` -- the referenced PK's real column name is genuinely
|
|
77
|
+
// unknowable from the migration file alone, and guessing "id" would be a false-confidence
|
|
78
|
+
// fabrication this project has repeatedly refused to ship elsewhere.
|
|
79
|
+
const INLINE_REFERENCES_RE = /REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?/i;
|
|
80
|
+
// `ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY (col) REFERENCES other_table (ocol)` -- a very
|
|
81
|
+
// common Flyway pattern for adding a constraint in a LATER migration than the table's own CREATE.
|
|
82
|
+
const ALTER_ADD_FK_RE = /ALTER\s+TABLE\s+"?(\w+)"?\s+ADD\s+CONSTRAINT\s+"?\w+"?\s+FOREIGN\s+KEY\s*\(\s*"?(\w+)"?\s*\)\s+REFERENCES\s+"?(\w+)"?\s*(?:\(\s*"?(\w+)"?\s*\))?/gi;
|
|
83
|
+
|
|
84
|
+
function newTableEntry() {
|
|
85
|
+
return { columns: new Set(), foreignKeys: [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// D-ddl-apply: exported (was module-private) so scanners/db/ddl-apply.mjs's postcondition check
|
|
89
|
+
// can reuse this exact extraction instead of a second copy -- it needs to know which table
|
|
90
|
+
// name(s) a proposed CREATE TABLE/ALTER TABLE ADD COLUMN statement declares, to assert the live,
|
|
91
|
+
// re-introspected schema actually reflects them after apply.
|
|
92
|
+
export function extractTablesFromSql(sqlText, sourceFile) {
|
|
93
|
+
const tables = new Map(); // name -> {columns: Set, foreignKeys: Array}
|
|
64
94
|
|
|
65
95
|
CREATE_TABLE_RE.lastIndex = 0;
|
|
66
96
|
let m;
|
|
@@ -70,37 +100,62 @@ function extractTablesFromSql(sqlText, sourceFile) {
|
|
|
70
100
|
const closeParen = matchBalancedParens(sqlText, openParen);
|
|
71
101
|
if (closeParen === -1) continue; // malformed -- skip, don't misattribute
|
|
72
102
|
const body = sqlText.slice(openParen + 1, closeParen);
|
|
73
|
-
|
|
103
|
+
if (!tables.has(tableName)) tables.set(tableName, newTableEntry());
|
|
104
|
+
const entry = tables.get(tableName);
|
|
74
105
|
for (const segment of splitTopLevelCommas(body)) {
|
|
75
|
-
if (CONSTRAINT_LEAD_RE.test(segment))
|
|
106
|
+
if (CONSTRAINT_LEAD_RE.test(segment)) {
|
|
107
|
+
const fkMatch = segment.match(TABLE_LEVEL_FK_RE);
|
|
108
|
+
if (fkMatch) {
|
|
109
|
+
entry.foreignKeys.push({ column: fkMatch[1], references_table: fkMatch[2], references_column: fkMatch[3] ?? null });
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
76
113
|
const colMatch = segment.match(COLUMN_NAME_RE);
|
|
77
|
-
if (colMatch)
|
|
114
|
+
if (!colMatch) continue;
|
|
115
|
+
entry.columns.add(colMatch[1]);
|
|
116
|
+
const inlineMatch = segment.match(INLINE_REFERENCES_RE);
|
|
117
|
+
if (inlineMatch) {
|
|
118
|
+
entry.foreignKeys.push({ column: colMatch[1], references_table: inlineMatch[1], references_column: inlineMatch[2] ?? null });
|
|
119
|
+
}
|
|
78
120
|
}
|
|
79
|
-
if (!tables.has(tableName)) tables.set(tableName, new Set());
|
|
80
|
-
for (const c of columns) tables.get(tableName).add(c);
|
|
81
121
|
}
|
|
82
122
|
|
|
83
123
|
ALTER_ADD_COLUMN_RE.lastIndex = 0;
|
|
84
124
|
while ((m = ALTER_ADD_COLUMN_RE.exec(sqlText))) {
|
|
85
125
|
const [, tableName, columnName] = m;
|
|
86
|
-
if (!tables.has(tableName)) tables.set(tableName,
|
|
87
|
-
tables.get(tableName).add(columnName);
|
|
126
|
+
if (!tables.has(tableName)) tables.set(tableName, newTableEntry());
|
|
127
|
+
tables.get(tableName).columns.add(columnName);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ALTER_ADD_FK_RE.lastIndex = 0;
|
|
131
|
+
while ((m = ALTER_ADD_FK_RE.exec(sqlText))) {
|
|
132
|
+
const [, tableName, column, referencesTable, referencesColumn] = m;
|
|
133
|
+
if (!tables.has(tableName)) tables.set(tableName, newTableEntry());
|
|
134
|
+
tables.get(tableName).foreignKeys.push({ column, references_table: referencesTable, references_column: referencesColumn ?? null });
|
|
88
135
|
}
|
|
89
136
|
|
|
90
|
-
return [...tables.entries()].map(([name,
|
|
137
|
+
return [...tables.entries()].map(([name, entry]) => ({
|
|
138
|
+
name,
|
|
139
|
+
columns: [...entry.columns].sort(),
|
|
140
|
+
foreign_keys: entry.foreignKeys,
|
|
141
|
+
source_file: sourceFile,
|
|
142
|
+
}));
|
|
91
143
|
}
|
|
92
144
|
|
|
93
|
-
// Entry point. Returns `{ tool, files, tables }` -- `tool: 'none'` (empty
|
|
94
|
-
// expected, and reported outcome, not an error -- most real repos
|
|
95
|
-
// itself) have no migration-file convention at all; their schema lives
|
|
96
|
-
// or -- the oracle repo's actual case -- entirely outside this repo, in
|
|
97
|
-
// project).
|
|
145
|
+
// Entry point. Returns `{ tool, files, tables, generated_at }` -- `tool: 'none'` (empty
|
|
146
|
+
// files/tables) is a real, expected, and reported outcome, not an error -- most real repos
|
|
147
|
+
// (including the oracle repo itself) have no migration-file convention at all; their schema lives
|
|
148
|
+
// elsewhere (JPA ddl-auto, or -- the oracle repo's actual case -- entirely outside this repo, in
|
|
149
|
+
// an external Supabase project). `generated_at` (D-cross-feature-fk-inference, staleness/freshness
|
|
150
|
+
// token) reflects when THIS scan ran -- stamped once, reused across all 3 return sites below, so a
|
|
151
|
+
// single call always reports one consistent timestamp regardless of which branch it takes.
|
|
98
152
|
export function scanMigrations(repoRoot) {
|
|
153
|
+
const generatedAt = new Date().toISOString();
|
|
99
154
|
const flywayFiles = listRgFiles(repoRoot, '**/db/migration/**/*.sql');
|
|
100
155
|
const liquibaseFiles = listRgFiles(repoRoot, '**/db/changelog/**/*.{xml,yaml,yml,sql}');
|
|
101
156
|
|
|
102
157
|
if (flywayFiles.length === 0 && liquibaseFiles.length === 0) {
|
|
103
|
-
return { tool: 'none', files: [], tables: [] };
|
|
158
|
+
return { tool: 'none', files: [], tables: [], generated_at: generatedAt };
|
|
104
159
|
}
|
|
105
160
|
|
|
106
161
|
if (flywayFiles.length > 0) {
|
|
@@ -109,7 +164,7 @@ export function scanMigrations(repoRoot) {
|
|
|
109
164
|
const text = fs.readFileSync(file, 'utf8');
|
|
110
165
|
tables.push(...extractTablesFromSql(text, path.relative(repoRoot, file)));
|
|
111
166
|
}
|
|
112
|
-
return { tool: 'flyway', files: flywayFiles.map((f) => path.relative(repoRoot, f)), tables };
|
|
167
|
+
return { tool: 'flyway', files: flywayFiles.map((f) => path.relative(repoRoot, f)), tables, generated_at: generatedAt };
|
|
113
168
|
}
|
|
114
169
|
|
|
115
170
|
// Liquibase changelogs are DETECTED (filenames recorded) but not deep-parsed in this first
|
|
@@ -122,5 +177,5 @@ export function scanMigrations(repoRoot) {
|
|
|
122
177
|
const text = fs.readFileSync(file, 'utf8');
|
|
123
178
|
tables.push(...extractTablesFromSql(text, path.relative(repoRoot, file)));
|
|
124
179
|
}
|
|
125
|
-
return { tool: 'liquibase', files: liquibaseFiles.map((f) => path.relative(repoRoot, f)), tables };
|
|
180
|
+
return { tool: 'liquibase', files: liquibaseFiles.map((f) => path.relative(repoRoot, f)), tables, generated_at: generatedAt };
|
|
126
181
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:cross-feature-report:1",
|
|
4
|
+
"title": "backend-skeleton cross-feature identity-collision report",
|
|
5
|
+
"description": "Validates specs/<feature_id>/cross-feature-report.json, written by `bskel scan cross-feature-check`. Mirrors feature-contract.schema.json's own report-artifact precedent (always written, even when it finds nothing) -- see D-cross-feature-collision in DECISIONS.md. `findings` were originally NAME-identity collisions only; D-cross-feature-fk-inference added a 4th signal, `db_foreign_key`, a REAL live-DB foreign-key edge correlated against declared table ownership (see `fk_check`/`unknowns` below) -- resource_type/table/operation_id remain pure NAME-identity, never a dependency-direction claim (see lib/field-dependencies.mjs's own declared-dependency system for that).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "feature_id", "generated_at", "findings", "fk_check", "unknowns"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.cross-feature-report/1" },
|
|
11
|
+
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
|
+
"generated_at": { "type": "string", "format": "date-time" },
|
|
13
|
+
"findings": {
|
|
14
|
+
"type": "array",
|
|
15
|
+
"items": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"required": ["signal", "identifier", "other_feature", "confidence"],
|
|
19
|
+
"properties": {
|
|
20
|
+
"signal": { "enum": ["resource_type", "table", "operation_id", "db_foreign_key"] },
|
|
21
|
+
"identifier": {
|
|
22
|
+
"description": "The colliding string itself -- a resourceType/DTO className, a DB table name, an operationId, or (for db_foreign_key) a 'child.column -> parent.column' edge description -- copied verbatim from whichever side this finding was computed FROM (this feature's own scan report/contract/live schema).",
|
|
23
|
+
"type": "string"
|
|
24
|
+
},
|
|
25
|
+
"other_feature": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
26
|
+
"confidence": {
|
|
27
|
+
"description": "'high': the identifier was copied verbatim from a real source annotation on both sides (resource_type/operation_id always; table/db_foreign_key only when BOTH sides had an explicit @Table/__tablename__-equivalent). 'medium': at least one side's table name was INFERRED (an adapter's classname-lowercase fallback guess, not a real annotation) -- reported, but does not by itself block the gate. For db_foreign_key, the FK edge itself is never in doubt (a live, Postgres-enforced constraint) -- confidence scores only which FEATURE each side was attributed to, the same table-name-inference risk the table signal already scores.",
|
|
28
|
+
"enum": ["high", "medium"]
|
|
29
|
+
},
|
|
30
|
+
"direction": {
|
|
31
|
+
"description": "db_foreign_key only, absent for the other 3 signals. 'references': THIS feature's table has the FK column, pointing at other_feature's table. 'referenced_by': other_feature's table has the FK column, pointing at THIS feature's table.",
|
|
32
|
+
"enum": ["references", "referenced_by"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"fk_check": {
|
|
38
|
+
"description": "D-cross-feature-fk-inference: always present, names how (if at all) live FK data was obtained for this check -- never silent about the difference between a fresh live connection and a possibly-stale persisted snapshot.",
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": false,
|
|
41
|
+
"required": ["mode", "schema", "source_feature", "generated_at"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"mode": {
|
|
44
|
+
"description": "'live': --db --database-url-env <NAME> was given, this run opened a fresh connection. 'persisted': no live connection this run -- reused a db_schema.live snapshot already on disk from a PRIOR `bskel scan --db --database-url-env` run (possibly stale -- see generated_at). 'migrations': no Plane C (live or persisted) data at all -- fell back to Plane A migration-file-derived FK data (a migration FILE existing is not proof it was ever actually applied, a materially lower-trust source than any live/persisted introspection). 'unavailable': no live connection, no persisted snapshot, and no migration-file data found anywhere -- zero db_foreign_key findings are possible this run (see unknowns).",
|
|
45
|
+
"enum": ["live", "persisted", "migrations", "unavailable"]
|
|
46
|
+
},
|
|
47
|
+
"schema": { "type": ["string", "null"] },
|
|
48
|
+
"source_feature": {
|
|
49
|
+
"description": "Which feature's OWN persisted brownfield-scan.json the snapshot came from, when mode is 'persisted' or 'migrations' (sourced from a persisted snapshot rather than a fresh --db pass). Null for 'live' and 'unavailable'.",
|
|
50
|
+
"type": ["string", "null"],
|
|
51
|
+
"pattern": "^([0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*)?$"
|
|
52
|
+
},
|
|
53
|
+
"generated_at": {
|
|
54
|
+
"description": "D-cross-feature-fk-inference (staleness/freshness token): when the underlying Plane A/C data this check used was actually captured -- lets a human bound how stale a 'persisted'/'migrations'-mode correlation is, rather than only knowing WHICH snapshot was used. Null only for 'unavailable'.",
|
|
55
|
+
"type": ["string", "null"],
|
|
56
|
+
"format": "date-time"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"unknowns": {
|
|
61
|
+
"description": "Plain-string notes for real gaps this check found but could not turn into a finding -- an FK edge whose other side matches no active feature's declared table, or (fk_check.mode === 'unavailable') a single note explaining no live FK data was available at all. Same 'detect and warn, never silently omit' discipline scan-report.schema.json's own unknowns already establishes.",
|
|
62
|
+
"type": "array",
|
|
63
|
+
"items": { "type": "string" }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:cross-feature-resolution:1",
|
|
4
|
+
"title": "backend-skeleton cross-feature collision waiver resolution",
|
|
5
|
+
"description": "Documentation only, like contract-resolution.schema.json -- nothing loads this at runtime beyond a direct JSON parse (see the analogous contracts/completeness.mjs's loadResolution). Validates specs/<feature_id>/cross-feature-resolution.json, written by `bskel scan cross-feature-waive`. Deliberately separate from cross-feature-report.schema.json's own artifact, mirroring D-contract-completeness's own reasoning for keeping waivers out of the report they apply to. Each waiver is a specific {signal, identifier, other_feature} triple, never a wildcard -- a collision that doesn't exist yet is never silently covered, same discipline contract-resolution.schema.json's own {code, subject} waivers already establish.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "feature_id", "waivers"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.cross-feature-resolution/1" },
|
|
11
|
+
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
|
+
"waivers": {
|
|
13
|
+
"type": "array",
|
|
14
|
+
"items": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"additionalProperties": false,
|
|
17
|
+
"required": ["signal", "identifier", "other_feature", "reason", "at"],
|
|
18
|
+
"properties": {
|
|
19
|
+
"signal": { "enum": ["resource_type", "table", "operation_id", "db_foreign_key"] },
|
|
20
|
+
"identifier": { "type": "string" },
|
|
21
|
+
"other_feature": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
22
|
+
"reason": { "type": "string" },
|
|
23
|
+
"at": { "type": "string", "format": "date-time" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:gate-attestation:1",
|
|
4
|
+
"title": "backend-skeleton signed gate attestation (bskel gate export --sign / bskel attest verify)",
|
|
5
|
+
"description": "D-gate-attestation-signing: the combined envelope `bskel gate export --sign` writes -- a gate-export report (schemas/gate-export.schema.json's own shape) plus a detached Ed25519 signature over that report's canonical (deep-key-sorted, whitespace-free) JSON serialization. Signature validity is the ONLY thing `bskel attest verify`'s exit code reflects -- whether the gates inside `report` themselves passed is a separate, printed-but-not-exit-code-driving question (a legitimately-signed, all-failing report must not look like a tool error).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "report", "signature"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.gate-attestation/1" },
|
|
11
|
+
"report": { "type": "object" },
|
|
12
|
+
"signature": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"required": ["algorithm", "value"],
|
|
16
|
+
"properties": {
|
|
17
|
+
"algorithm": { "const": "ed25519" },
|
|
18
|
+
"value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over canonicalize(report)." }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:gate-export:1",
|
|
4
|
+
"title": "backend-skeleton gate export report (bskel gate export)",
|
|
5
|
+
"description": "D-gate-attestation-signing: this schema was written FROM cmdGateExport's real, already-shipped report shape (bin/bskel.mjs) -- it did not exist before this item and the report itself is unchanged by adding it. `current` mirrors schemas/state.schema.json's own per-gate record shape exactly (the same raw, stored record getGate() returns -- status is constrained to the 3 values ever WRITTEN to disk, never the read-time-derived not_run/stale/pass (forced)). `additionalProperties` is intentionally open on `gates` itself (keyed by gate name) so this schema never needs editing when a new gate is added -- GATE_NAMES in lib/gate-definitions.mjs is the one place that list lives.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "feature_id", "generated_at", "git", "gates"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.gate-export/1" },
|
|
11
|
+
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
12
|
+
"generated_at": { "type": "string", "format": "date-time" },
|
|
13
|
+
"git": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"required": ["branch", "head_sha", "dirty"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"branch": { "type": ["string", "null"], "description": "lib/repo.mjs's currentBranch() -- null on failure, or the literal string \"HEAD\" for a detached HEAD (not a real branch name)." },
|
|
19
|
+
"head_sha": { "type": "string" },
|
|
20
|
+
"dirty": { "type": ["boolean", "null"], "description": "lib/repo.mjs's isDirty() -- null on failure. true covers BOTH modified tracked files and untracked files (git status --porcelain, no -uno)." }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"gates": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"required": ["scope", "current", "history"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"scope": { "type": "string", "description": "the resolved scope id this gate was read from -- REPO_GATE_ID (\"_repo\") for a repo-scoped gate, or the feature_id itself." },
|
|
31
|
+
"current": {
|
|
32
|
+
"anyOf": [
|
|
33
|
+
{ "type": "null" },
|
|
34
|
+
{
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"required": ["status", "token", "at"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"status": { "enum": ["pass", "awaiting_disposition", "revoked"] },
|
|
40
|
+
"token": { "type": "string" },
|
|
41
|
+
"at": { "type": "string" },
|
|
42
|
+
"forced": { "type": "boolean" },
|
|
43
|
+
"reason": { "type": "string" },
|
|
44
|
+
"evidence": { "type": "object" },
|
|
45
|
+
"inputs": { "type": "object" }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"history": {
|
|
51
|
+
"type": "array",
|
|
52
|
+
"items": { "type": "object" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:sbf:patch-transaction:1",
|
|
4
|
+
"title": "backend-skeleton content-addressed patch transaction (specs/<feature_id>/patch-transactions/<transaction_id>.json)",
|
|
5
|
+
"description": "D-patch-transactions: generalizes A3/D-patch-strategy's per-{resource,field} patch-approvals.json shape into a kind-agnostic propose/approve/apply/rollback transaction. One file per transaction (not one growing array) -- each record carries a lifecycle with real state transitions and references a content-addressed rollback blob under this same feature's patch-transactions/blobs/ directory. D-ddl-apply added a second kind ('ddl-apply') -- kind-specific source/target/postcondition/apply shapes are enforced by the allOf/if-then branches below, keyed on `kind`. The config-apply branch is byte-identical to this schema's pre-D-ddl-apply shape (existing config-apply records validate unchanged).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["schema", "transaction_id", "feature_id", "kind", "source", "target", "preimage", "proposed_value", "postcondition", "status", "created_at"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema": { "const": "sbf.patch-transaction/1" },
|
|
11
|
+
"transaction_id": { "type": "string", "pattern": "^pt-[0-9a-f-]{36}$" },
|
|
12
|
+
"feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
|
|
13
|
+
"kind": { "enum": ["config-apply", "ddl-apply"] },
|
|
14
|
+
"source": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"description": "Kind-specific opaque context needed to re-plan this transaction fresh at approve/apply time. Shape enforced per-kind by the allOf/if-then branches below."
|
|
17
|
+
},
|
|
18
|
+
"target": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"description": "Kind-specific description of what this transaction acts on. Shape enforced per-kind by the allOf/if-then branches below."
|
|
21
|
+
},
|
|
22
|
+
"preimage": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"additionalProperties": false,
|
|
25
|
+
"required": ["region_hash", "file_hash"],
|
|
26
|
+
"properties": {
|
|
27
|
+
"region_hash": { "type": "string" },
|
|
28
|
+
"file_hash": { "type": "string" }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"current_value": { "type": "string" },
|
|
32
|
+
"proposed_value": { "type": "string" },
|
|
33
|
+
"postcondition": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"description": "Kind-specific re-check applied before the record is allowed to transition to 'applied'. Shape enforced per-kind by the allOf/if-then branches below."
|
|
36
|
+
},
|
|
37
|
+
"status": { "enum": ["proposed", "approved", "applied", "rolled_back"] },
|
|
38
|
+
"created_at": { "type": "string", "format": "date-time" },
|
|
39
|
+
"approval": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"required": ["reason", "at"],
|
|
43
|
+
"properties": {
|
|
44
|
+
"reason": { "type": "string" },
|
|
45
|
+
"at": { "type": "string", "format": "date-time" }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"apply": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"description": "Kind-specific record of what actually happened at apply time. Shape enforced per-kind by the allOf/if-then branches below."
|
|
51
|
+
},
|
|
52
|
+
"rollback": {
|
|
53
|
+
"type": "object",
|
|
54
|
+
"additionalProperties": false,
|
|
55
|
+
"required": ["reason", "at"],
|
|
56
|
+
"properties": {
|
|
57
|
+
"reason": { "type": "string" },
|
|
58
|
+
"at": { "type": "string", "format": "date-time" },
|
|
59
|
+
"forced": { "type": "boolean" }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"allOf": [
|
|
64
|
+
{
|
|
65
|
+
"if": { "properties": { "kind": { "const": "config-apply" } } },
|
|
66
|
+
"then": {
|
|
67
|
+
"properties": {
|
|
68
|
+
"source": {
|
|
69
|
+
"additionalProperties": false,
|
|
70
|
+
"required": ["choice"],
|
|
71
|
+
"properties": {
|
|
72
|
+
"choice": { "type": "string" }
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
"target": {
|
|
76
|
+
"additionalProperties": false,
|
|
77
|
+
"required": ["file", "key_path"],
|
|
78
|
+
"properties": {
|
|
79
|
+
"file": { "type": "string" },
|
|
80
|
+
"key_path": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"postcondition": {
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"required": ["kind", "pattern"],
|
|
86
|
+
"properties": {
|
|
87
|
+
"kind": { "const": "regex-match" },
|
|
88
|
+
"pattern": { "type": "string" }
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"apply": {
|
|
92
|
+
"additionalProperties": false,
|
|
93
|
+
"required": ["at", "postimage_file_hash"],
|
|
94
|
+
"properties": {
|
|
95
|
+
"at": { "type": "string", "format": "date-time" },
|
|
96
|
+
"postimage_file_hash": { "type": "string" }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"if": { "properties": { "kind": { "const": "ddl-apply" } } },
|
|
104
|
+
"then": {
|
|
105
|
+
"properties": {
|
|
106
|
+
"source": {
|
|
107
|
+
"additionalProperties": false,
|
|
108
|
+
"required": ["database_url_env", "schema"],
|
|
109
|
+
"properties": {
|
|
110
|
+
"database_url_env": { "type": "string" },
|
|
111
|
+
"schema": { "type": "string" }
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"target": {
|
|
115
|
+
"additionalProperties": false,
|
|
116
|
+
"required": ["database_url_env", "schema", "sql_text"],
|
|
117
|
+
"properties": {
|
|
118
|
+
"database_url_env": { "type": "string" },
|
|
119
|
+
"schema": { "type": "string" },
|
|
120
|
+
"sql_text": { "type": "string" }
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"postcondition": {
|
|
124
|
+
"additionalProperties": false,
|
|
125
|
+
"required": ["kind", "schema", "expected_tables"],
|
|
126
|
+
"properties": {
|
|
127
|
+
"kind": { "const": "db-schema-diff" },
|
|
128
|
+
"schema": { "type": "string" },
|
|
129
|
+
"expected_tables": {
|
|
130
|
+
"type": "array",
|
|
131
|
+
"items": {
|
|
132
|
+
"type": "object",
|
|
133
|
+
"additionalProperties": false,
|
|
134
|
+
"required": ["name", "expect"],
|
|
135
|
+
"properties": {
|
|
136
|
+
"name": { "type": "string" },
|
|
137
|
+
"expect": { "enum": ["present", "absent"] }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
"expected_indexes": {
|
|
142
|
+
"description": "D-ddl-apply (INDEX/SCHEMA postcondition precision): optional, not required -- absent on any ddl-apply record persisted before this field existed, and empty for a statement batch with no CREATE/DROP INDEX in it.",
|
|
143
|
+
"type": "array",
|
|
144
|
+
"items": {
|
|
145
|
+
"type": "object",
|
|
146
|
+
"additionalProperties": false,
|
|
147
|
+
"required": ["name", "expect"],
|
|
148
|
+
"properties": {
|
|
149
|
+
"name": { "type": "string" },
|
|
150
|
+
"expect": { "enum": ["present", "absent"] }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"expected_schemas": {
|
|
155
|
+
"description": "D-ddl-apply (INDEX/SCHEMA postcondition precision): optional, not required -- same reasoning as expected_indexes, for CREATE/DROP SCHEMA.",
|
|
156
|
+
"type": "array",
|
|
157
|
+
"items": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"required": ["name", "expect"],
|
|
161
|
+
"properties": {
|
|
162
|
+
"name": { "type": "string" },
|
|
163
|
+
"expect": { "enum": ["present", "absent"] }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
"apply": {
|
|
170
|
+
"additionalProperties": false,
|
|
171
|
+
"required": ["at", "postimage_schema_hash", "executed_statements"],
|
|
172
|
+
"properties": {
|
|
173
|
+
"at": { "type": "string", "format": "date-time" },
|
|
174
|
+
"postimage_schema_hash": { "type": "string" },
|
|
175
|
+
"executed_statements": { "type": "array", "items": { "type": "string" } }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
]
|
|
182
|
+
}
|