backend-skeleton 1.0.0-beta.9 → 1.1.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 +185 -13
- package/bin/bskel.mjs +689 -33
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +65 -7
- package/contracts/openapi.mjs +321 -30
- package/contracts/validate.mjs +23 -4
- package/handles/_engine.mjs +123 -30
- package/handles/capability-codec.mjs +94 -0
- package/handles/codec.mjs +13 -3
- package/handles/providers/java-spring/emit.mjs +78 -33
- package/handles/providers/java-spring/observe.mjs +4 -3
- package/handles/providers/java-spring/plan.mjs +73 -16
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
- package/handles/providers/java-spring.mjs +8 -0
- package/handles/providers/python-fastapi/emit.mjs +21 -26
- package/handles/providers/python-fastapi/observe.mjs +6 -5
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
- package/handles/providers/python-fastapi.mjs +3 -3
- package/handles/providers/typescript-express/emit.mjs +144 -55
- package/handles/providers/typescript-express/observe.mjs +102 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
- package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
- package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
- package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
- package/handles/providers/typescript-express.mjs +7 -4
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +136 -5
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/exit-codes.mjs +21 -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/verify.mjs +23 -6
- package/lib/workflow.mjs +31 -3
- package/package.json +8 -2
- package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
- package/scanners/adapters/java-spring.mjs +114 -10
- package/scanners/adapters/javascript-express.mjs +46 -13
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +19 -2
- 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/feature-contract.schema.json +3 -3
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/handles-plan.schema.json +2 -0
- package/schemas/oracle-manifest.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/schemas/stack-record.schema.json +6 -1
- package/stack/apply.mjs +51 -7
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
package/lib/serve-ui.html
CHANGED
|
@@ -44,6 +44,34 @@
|
|
|
44
44
|
</form>
|
|
45
45
|
<div id="formStatus"></div>
|
|
46
46
|
|
|
47
|
+
<h2>Live DB schema</h2>
|
|
48
|
+
<div id="dbSchemaPanel">
|
|
49
|
+
<p class="muted" id="dbSchemaNote">loading...</p>
|
|
50
|
+
<table id="dbSchemaTable" hidden><thead><tr><th>table</th><th>columns</th><th>primary key</th><th>foreign keys</th><th>indexes</th></tr></thead><tbody></tbody></table>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<h2>DDL patch transactions</h2>
|
|
54
|
+
<div id="ddlPanel">
|
|
55
|
+
<form id="ddlFeatureForm" style="grid-template-columns: 1fr auto;">
|
|
56
|
+
<label>feature <input name="feature" id="ddlFeatureInput" required></label>
|
|
57
|
+
<button type="submit">Load transactions</button>
|
|
58
|
+
</form>
|
|
59
|
+
<table id="ddlTable"><thead><tr><th>transaction</th><th>kind</th><th>status</th><th>sql</th><th>created</th><th>action</th></tr></thead><tbody></tbody></table>
|
|
60
|
+
<div id="ddlActionStatus"></div>
|
|
61
|
+
|
|
62
|
+
<h3 style="font-size: 0.9rem; margin-top: 1.2rem;">Propose a DDL change</h3>
|
|
63
|
+
<form id="ddlProposeForm">
|
|
64
|
+
<label>feature <input name="feature" id="ddlProposeFeature" required></label>
|
|
65
|
+
<label>database_url_env <input name="databaseUrlEnv" placeholder="e.g. BSKEL_DDL_DATABASE_URL" required></label>
|
|
66
|
+
<label>schema <input name="schema" value="public" required></label>
|
|
67
|
+
<label style="grid-column: 1 / -1">sql_text
|
|
68
|
+
<textarea name="sqlText" rows="4" required style="font: inherit; padding: 0.4rem;" placeholder="CREATE TABLE widgets (id uuid PRIMARY KEY);"></textarea>
|
|
69
|
+
</label>
|
|
70
|
+
<button type="submit">Propose</button>
|
|
71
|
+
</form>
|
|
72
|
+
<div id="ddlProposeStatus"></div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
47
75
|
<script>
|
|
48
76
|
// D-http-serving-layer: `reason`/`memo` (and every other string field) are free text a human types
|
|
49
77
|
// via `bskel dependency declare --reason/--memo` or this page's own POST form -- never assumed safe
|
|
@@ -106,6 +134,189 @@ document.getElementById('declareForm').addEventListener('submit', async (ev) =>
|
|
|
106
134
|
}
|
|
107
135
|
});
|
|
108
136
|
|
|
137
|
+
// D-ddl-apply: mirrors td()'s own comment above -- every value rendered here (table/column names,
|
|
138
|
+
// raw sql_text, error messages) is free text this tool never assumes is safe to inject as markup.
|
|
139
|
+
// All via td()/textContent, never innerHTML.
|
|
140
|
+
function tdList(items) {
|
|
141
|
+
return td(items.length ? items.join(', ') : '(none)');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// D-ddl-apply (DROP-TABLE-specific confirmation): mirrors scanners/db/ddl-apply.mjs's own
|
|
145
|
+
// requiredConfirmValue() exactly -- the transaction id for a non-drop transaction, or the sorted,
|
|
146
|
+
// comma-joined dropped-table name(s) for one that drops a table. Computed here purely so the
|
|
147
|
+
// placeholder can SHOW the human what to type; the server independently re-derives and enforces
|
|
148
|
+
// the same value, this is not the source of truth.
|
|
149
|
+
function requiredConfirmValue(t) {
|
|
150
|
+
const droppedTables = (t.postcondition?.expected_tables ?? [])
|
|
151
|
+
.filter((x) => x.expect === 'absent')
|
|
152
|
+
.map((x) => x.name)
|
|
153
|
+
.sort();
|
|
154
|
+
return droppedTables.length > 0 ? droppedTables.join(',') : t.transaction_id;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function loadDbSchema() {
|
|
158
|
+
const note = document.getElementById('dbSchemaNote');
|
|
159
|
+
const table = document.getElementById('dbSchemaTable');
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch('/api/db/schema');
|
|
162
|
+
if (res.status === 404) {
|
|
163
|
+
note.hidden = false;
|
|
164
|
+
note.textContent = 'DDL features are disabled -- start `bskel serve --database-url-env NAME` to enable them.';
|
|
165
|
+
table.hidden = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const body = await res.json();
|
|
169
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
170
|
+
note.hidden = true;
|
|
171
|
+
table.hidden = false;
|
|
172
|
+
const tbody = table.querySelector('tbody');
|
|
173
|
+
tbody.replaceChildren();
|
|
174
|
+
for (const t of body.tables) {
|
|
175
|
+
const tr = document.createElement('tr');
|
|
176
|
+
tr.append(
|
|
177
|
+
td(t.name),
|
|
178
|
+
td(t.columns.map((c) => `${c.name} ${c.type}${c.nullable ? '' : ' NOT NULL'}`).join(', ') || '(none)'),
|
|
179
|
+
tdList(t.primary_key),
|
|
180
|
+
td(t.foreign_keys.map((f) => `${f.column} -> ${f.references_table}.${f.references_column}`).join(', ') || '(none)'),
|
|
181
|
+
tdList(t.indexes),
|
|
182
|
+
);
|
|
183
|
+
tbody.appendChild(tr);
|
|
184
|
+
}
|
|
185
|
+
if (body.tables.length === 0) {
|
|
186
|
+
const tr = document.createElement('tr');
|
|
187
|
+
tr.appendChild(td('(no tables)'));
|
|
188
|
+
tbody.appendChild(tr);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
note.hidden = false;
|
|
192
|
+
note.textContent = `failed to load /api/db/schema: ${err.message}`;
|
|
193
|
+
table.hidden = true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function loadDdlTransactions(feature) {
|
|
198
|
+
const tbody = document.querySelector('#ddlTable tbody');
|
|
199
|
+
tbody.replaceChildren();
|
|
200
|
+
if (!feature) return;
|
|
201
|
+
const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions`);
|
|
202
|
+
const body = await res.json();
|
|
203
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
204
|
+
for (const t of body.transactions) {
|
|
205
|
+
const tr = document.createElement('tr');
|
|
206
|
+
const sqlOrTarget = t.kind === 'ddl-apply' ? t.target.sql_text : `${t.target.file} @ ${(t.target.key_path || []).join('.')}`;
|
|
207
|
+
tr.append(td(t.transaction_id), td(t.kind), td(t.status), td(sqlOrTarget), td(t.created_at));
|
|
208
|
+
|
|
209
|
+
const actionCell = document.createElement('td');
|
|
210
|
+
if (t.status === 'proposed') {
|
|
211
|
+
const form = document.createElement('form');
|
|
212
|
+
form.style.gridTemplateColumns = 'none';
|
|
213
|
+
form.style.display = 'flex';
|
|
214
|
+
form.style.gap = '0.3rem';
|
|
215
|
+
const reasonInput = document.createElement('input');
|
|
216
|
+
reasonInput.placeholder = 'reason';
|
|
217
|
+
reasonInput.required = true;
|
|
218
|
+
const btn = document.createElement('button');
|
|
219
|
+
btn.type = 'submit';
|
|
220
|
+
btn.textContent = 'Approve';
|
|
221
|
+
form.append(reasonInput, btn);
|
|
222
|
+
form.addEventListener('submit', async (ev) => {
|
|
223
|
+
ev.preventDefault();
|
|
224
|
+
await runDdlAction(feature, t.transaction_id, 'approve', { reason: reasonInput.value });
|
|
225
|
+
});
|
|
226
|
+
actionCell.appendChild(form);
|
|
227
|
+
} else if (t.status === 'approved') {
|
|
228
|
+
const form = document.createElement('form');
|
|
229
|
+
form.style.gridTemplateColumns = 'none';
|
|
230
|
+
form.style.display = 'flex';
|
|
231
|
+
form.style.gap = '0.3rem';
|
|
232
|
+
const confirmInput = document.createElement('input');
|
|
233
|
+
// Deliberately starts EMPTY, never pre-filled -- a pre-filled confirm field would let a
|
|
234
|
+
// click submit this whole flow (applying a live DDL change) without the human actually
|
|
235
|
+
// re-reading/re-typing anything, defeating the entire point of this friction. The
|
|
236
|
+
// placeholder names the REQUIRED value -- the dropped table name(s), not the opaque
|
|
237
|
+
// transaction id, when this transaction drops a table (see requiredConfirmValue() above).
|
|
238
|
+
confirmInput.placeholder = `type "${requiredConfirmValue(t)}" to confirm`;
|
|
239
|
+
confirmInput.required = true;
|
|
240
|
+
const btn = document.createElement('button');
|
|
241
|
+
btn.type = 'submit';
|
|
242
|
+
btn.textContent = 'Apply';
|
|
243
|
+
form.append(confirmInput, btn);
|
|
244
|
+
form.addEventListener('submit', async (ev) => {
|
|
245
|
+
ev.preventDefault();
|
|
246
|
+
await runDdlAction(feature, t.transaction_id, 'apply', { confirm: confirmInput.value });
|
|
247
|
+
});
|
|
248
|
+
actionCell.appendChild(form);
|
|
249
|
+
} else {
|
|
250
|
+
actionCell.textContent = '(none)';
|
|
251
|
+
}
|
|
252
|
+
tr.appendChild(actionCell);
|
|
253
|
+
tbody.appendChild(tr);
|
|
254
|
+
}
|
|
255
|
+
if (body.transactions.length === 0) {
|
|
256
|
+
const tr = document.createElement('tr');
|
|
257
|
+
tr.appendChild(td('(none proposed)'));
|
|
258
|
+
tbody.appendChild(tr);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function runDdlAction(feature, transactionId, action, body) {
|
|
263
|
+
const statusEl = document.getElementById('ddlActionStatus');
|
|
264
|
+
statusEl.textContent = `${action}ing...`;
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions/${encodeURIComponent(transactionId)}/${action}`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify(body),
|
|
270
|
+
});
|
|
271
|
+
const responseBody = await res.json();
|
|
272
|
+
if (!res.ok) throw new Error(responseBody.error ?? `HTTP ${res.status}`);
|
|
273
|
+
statusEl.textContent = `${action} succeeded: ${transactionId} -> ${responseBody.status}`;
|
|
274
|
+
await loadDdlTransactions(feature);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
statusEl.textContent = `error: ${err.message}`;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
document.getElementById('ddlFeatureForm').addEventListener('submit', async (ev) => {
|
|
281
|
+
ev.preventDefault();
|
|
282
|
+
const feature = document.getElementById('ddlFeatureInput').value;
|
|
283
|
+
try {
|
|
284
|
+
await loadDdlTransactions(feature);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
document.getElementById('ddlActionStatus').textContent = `error: ${err.message}`;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
document.getElementById('ddlProposeForm').addEventListener('submit', async (ev) => {
|
|
291
|
+
ev.preventDefault();
|
|
292
|
+
const form = new FormData(ev.target);
|
|
293
|
+
const feature = form.get('feature');
|
|
294
|
+
const statusEl = document.getElementById('ddlProposeStatus');
|
|
295
|
+
statusEl.textContent = 'proposing...';
|
|
296
|
+
try {
|
|
297
|
+
const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions/propose`, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'Content-Type': 'application/json' },
|
|
300
|
+
body: JSON.stringify({
|
|
301
|
+
kind: 'ddl-apply',
|
|
302
|
+
databaseUrlEnv: form.get('databaseUrlEnv'),
|
|
303
|
+
schema: form.get('schema'),
|
|
304
|
+
sqlText: form.get('sqlText'),
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
const body = await res.json();
|
|
308
|
+
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
309
|
+
statusEl.textContent = `proposed: ${body.transaction_id}`;
|
|
310
|
+
ev.target.reset();
|
|
311
|
+
document.getElementById('ddlFeatureInput').value = feature;
|
|
312
|
+
await loadDdlTransactions(feature);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
statusEl.textContent = `error: ${err.message}`;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
loadDbSchema();
|
|
319
|
+
|
|
109
320
|
loadGraph().catch((err) => {
|
|
110
321
|
const p = document.createElement('p');
|
|
111
322
|
p.style.color = '#d33';
|
package/lib/verify.mjs
CHANGED
|
@@ -110,24 +110,33 @@ function handlesOutputsFor(root, featureId) {
|
|
|
110
110
|
// has ever run at all, independent of its current pass/stale status -- a stale or forced handles
|
|
111
111
|
// gate still implies every one of its expected outputs should exist. A provider with zero
|
|
112
112
|
// spec-scoped outputs simply produces no artifact items here at all, which is correct: there is
|
|
113
|
-
// nothing to check (no provider currently declares
|
|
114
|
-
//
|
|
115
|
-
//
|
|
113
|
+
// nothing to check (no provider currently declares a non-empty outputs.spec any more --
|
|
114
|
+
// D-write-safety-phase0 (item 1) moved java-spring/python-fastapi's migration.sql onto manifest
|
|
115
|
+
// tracking, the same mechanism handlesManifestChecks() below already covers, so this loop is now
|
|
116
|
+
// live only for the fallback case below or a future provider with a genuinely untracked output).
|
|
116
117
|
export function checkArtifacts(root, featureId, gates = []) {
|
|
117
118
|
const checks = [];
|
|
118
119
|
const contractPath = specPath(root, featureId, 'contracts', `${featureId}.schema.json`);
|
|
119
120
|
checks.push({ artifact: 'contract', path: path.relative(root, contractPath), exists: fs.existsSync(contractPath) });
|
|
120
121
|
|
|
121
122
|
const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
|
|
123
|
+
const manifestChecks = handlesManifestChecks(root, featureId, handlesRan);
|
|
124
|
+
// D-write-safety-phase0 (item 1): a path already covered by the manifest-based check above must
|
|
125
|
+
// not also get the legacy existence-only check below -- migration.sql is now BOTH manifest-
|
|
126
|
+
// tracked (owner: featureId) AND still named in DEFAULT_HANDLES_OUTPUTS's fallback list, so
|
|
127
|
+
// without this guard a scan-report-missing/corrupt repo would report it twice.
|
|
128
|
+
const manifestCheckedPaths = new Set(manifestChecks.map((c) => c.path));
|
|
122
129
|
for (const relOutput of handlesOutputsFor(root, featureId)) {
|
|
123
130
|
const outputPath = specPath(root, featureId, ...relOutput.split('/'));
|
|
131
|
+
const outputRelPath = path.relative(root, outputPath);
|
|
132
|
+
if (manifestCheckedPaths.has(outputRelPath)) continue;
|
|
124
133
|
const outputExists = fs.existsSync(outputPath);
|
|
125
134
|
if (handlesRan || outputExists) {
|
|
126
135
|
const label = path.basename(relOutput, path.extname(relOutput));
|
|
127
|
-
checks.push({ artifact: `handles ${label}`, path:
|
|
136
|
+
checks.push({ artifact: `handles ${label}`, path: outputRelPath, exists: outputExists });
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
|
-
checks.push(...
|
|
139
|
+
checks.push(...manifestChecks);
|
|
131
140
|
return checks;
|
|
132
141
|
}
|
|
133
142
|
|
|
@@ -138,6 +147,11 @@ export function checkArtifacts(root, featureId, gates = []) {
|
|
|
138
147
|
// nothing regenerates it implicitly, and the feature doesn't compile without it. Same mechanism
|
|
139
148
|
// and reasoning as the migration.sql check above (S6) -- existence only, at verify time, entirely
|
|
140
149
|
// outside the gate token.
|
|
150
|
+
// D-write-safety-phase0 (item 1): manifest 'kind' values -> the label bskel verify's report shows.
|
|
151
|
+
// Anything not listed falls back to 'handles resolver' (the historical default before 'migration'
|
|
152
|
+
// existed) -- see the artifact: line below.
|
|
153
|
+
const HANDLES_ARTIFACT_LABELS = { infra: 'handles infra', migration: 'handles migration' };
|
|
154
|
+
|
|
141
155
|
function handlesManifestChecks(root, featureId, handlesRan) {
|
|
142
156
|
let manifest;
|
|
143
157
|
try {
|
|
@@ -162,7 +176,10 @@ function handlesManifestChecks(root, featureId, handlesRan) {
|
|
|
162
176
|
.map(([relPath, e]) => {
|
|
163
177
|
const abs = resolveWithinRoot(root, relPath);
|
|
164
178
|
return {
|
|
165
|
-
|
|
179
|
+
// D-write-safety-phase0 (item 1): was a binary ternary (infra vs. everything else
|
|
180
|
+
// called "resolver") -- widened once migration.sql started manifest-tracking as its
|
|
181
|
+
// own kind: 'migration', which is neither infra (repo-owned) nor a resolver.
|
|
182
|
+
artifact: HANDLES_ARTIFACT_LABELS[e.kind] ?? 'handles resolver',
|
|
166
183
|
path: relPath,
|
|
167
184
|
exists: abs !== null && fs.existsSync(abs),
|
|
168
185
|
};
|
package/lib/workflow.mjs
CHANGED
|
@@ -31,6 +31,11 @@ export function listFeatures(root) {
|
|
|
31
31
|
const ESTABLISH_COMMAND = {
|
|
32
32
|
preflight: () => 'bskel preflight',
|
|
33
33
|
scan: (id) => `bskel scan --feature ${id} --terms <a,b,c>`,
|
|
34
|
+
// D-cross-feature-collision: added in the SAME commit as the gate itself this time -- the
|
|
35
|
+
// ESTABLISH_COMMAND/MUTATING_PREFIXES gap was found live twice already this session for other
|
|
36
|
+
// gates (dependencies/conformance below), so this one is wired up immediately rather than
|
|
37
|
+
// discovered later.
|
|
38
|
+
cross_feature: (id) => `bskel scan cross-feature-check --feature ${id}`,
|
|
34
39
|
contract: (id) => `bskel contract emit --feature ${id}`,
|
|
35
40
|
// D-field-dependency: this and `conformance` immediately below were BOTH missing before this
|
|
36
41
|
// item -- found live while adding this one, same crash class, fixed together. Neither had ever
|
|
@@ -41,6 +46,10 @@ const ESTABLISH_COMMAND = {
|
|
|
41
46
|
dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
|
|
42
47
|
handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
|
|
43
48
|
stack: () => 'bskel stack apply --choice <id> --apply',
|
|
49
|
+
// D-patch-transactions: added in the SAME commit as the gate itself -- per the
|
|
50
|
+
// `D-cross-feature-collision` entry's own explicit warning above (this exact ESTABLISH_COMMAND
|
|
51
|
+
// gap), already found live twice for other gates before it could recur a third time here.
|
|
52
|
+
patch_transactions: (id) => `bskel patch propose --feature ${id} --choice <id> --target <config_check target path> # then: bskel patch approve ... then bskel patch apply ...`,
|
|
44
53
|
conformance: (id) => `bskel observe emit --feature ${id} # then: run the target app, then bskel observe import --feature ${id} --receipts <path>`,
|
|
45
54
|
};
|
|
46
55
|
|
|
@@ -55,17 +64,36 @@ function awaitingDispositionCommand(gateName, featureId) {
|
|
|
55
64
|
if (gateName === 'contract') {
|
|
56
65
|
return `bskel contract waive --feature ${featureId} --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature ${featureId} --reason "..." if intentional`;
|
|
57
66
|
}
|
|
67
|
+
if (gateName === 'cross_feature') {
|
|
68
|
+
return `bskel scan cross-feature-waive --feature ${featureId} --signal resource_type|table|operation_id --identifier <name> --other-feature <id> --reason "..." # or: bskel gate force cross_feature --feature ${featureId} --reason "..." if intentional`;
|
|
69
|
+
}
|
|
58
70
|
return `bskel gate force ${gateName} --feature ${featureId} --reason "..."`;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
// Whether a recommended command WRITES anything (gate state, generated files, applied files) as
|
|
62
74
|
// opposed to being a pure read (bskel verify, bskel status, bskel next itself). Matched by the
|
|
63
75
|
// command's own leading "bskel <verb...>" prefix so this can't silently drift from the actual
|
|
64
|
-
// command names above.
|
|
65
|
-
|
|
76
|
+
// command names above. `bskel observe emit`/`bskel observe import` were BOTH missing until found
|
|
77
|
+
// live while fixing the identical ESTABLISH_COMMAND gap above (same bug class: neither had ever
|
|
78
|
+
// been exercised through `next` recommending the real `conformance`-gate command, so a real
|
|
79
|
+
// `mutating: false` misclassification on a command that actually writes files + passes a gate went
|
|
80
|
+
// unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
|
|
81
|
+
const MUTATING_PREFIXES = [
|
|
82
|
+
'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
|
|
83
|
+
'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
|
|
84
|
+
'bskel handles plan', 'bskel stack apply', 'bskel observe emit', 'bskel observe import',
|
|
85
|
+
'bskel patch propose', 'bskel patch approve', 'bskel patch apply', 'bskel patch rollback',
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
// Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
|
|
89
|
+
// classification directly, without needing a full end-to-end fixture that drives a feature all the
|
|
90
|
+
// way to a stale `conformance` gate just to observe one boolean.
|
|
91
|
+
export function isMutatingCommand(command) {
|
|
92
|
+
return MUTATING_PREFIXES.some((p) => command.startsWith(p));
|
|
93
|
+
}
|
|
66
94
|
|
|
67
95
|
function action(command, reason) {
|
|
68
|
-
return { command, reason, mutating:
|
|
96
|
+
return { command, reason, mutating: isMutatingCommand(command) };
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
// featureId === null means "repo scope only" -- feature-scoped gates (scan/contract/handles)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -38,11 +38,17 @@
|
|
|
38
38
|
"test:java-compile": "node scripts/java-compile-smoke.mjs",
|
|
39
39
|
"test:python-import": "node scripts/python-import-smoke.mjs",
|
|
40
40
|
"test:db-introspect": "node scripts/db-introspect-smoke.mjs",
|
|
41
|
+
"test:registry-coverage": "node scripts/handles-registry-coverage-smoke.mjs",
|
|
42
|
+
"test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
|
|
43
|
+
"test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
|
|
41
44
|
"test:java-integration": "node scripts/java-integration-smoke.mjs",
|
|
42
45
|
"test:java-ast": "node scripts/java-ast-smoke.mjs",
|
|
43
46
|
"test:python-integration": "node scripts/python-integration-smoke.mjs",
|
|
44
47
|
"test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
|
|
45
|
-
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs"
|
|
48
|
+
"test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
|
|
49
|
+
"test:shadow-validation": "node scripts/shadow-validation-smoke.mjs",
|
|
50
|
+
"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"
|
|
46
52
|
},
|
|
47
53
|
"dependencies": {
|
|
48
54
|
"ajv": "^8.20.0",
|
|
@@ -53,7 +53,15 @@ export function matchBalanced(text, openIndex, openChar, closeChar) {
|
|
|
53
53
|
return -1;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// D-entity-id-field-inheritance: `\b` before `(class|record)` -- found live, not anticipated,
|
|
57
|
+
// while verifying a real cross-file inheritance walk against `@MappedSuperclass`-annotated Java
|
|
58
|
+
// (spring-petclinic's own `BaseEntity`). Without it, `class`/`record` could match as a SUBSTRING of
|
|
59
|
+
// a preceding identifier that happens to end the same way -- `@MappedSuperclass\npublic class
|
|
60
|
+
// BaseEntity` matched "class" inside "Superclass" itself (no `\w`-to-non-`\w` transition exists
|
|
61
|
+
// between "Super" and "class", so no real word boundary there either way), then captured the next
|
|
62
|
+
// bare word ("public") as if it were the class name. `\s+` alone was never sufficient protection --
|
|
63
|
+
// it only requires whitespace AFTER the match, never a real word start before it.
|
|
64
|
+
const CLASS_OR_RECORD_RE = /(?:public\s+)?\b(class|record)\s+(\w+)/;
|
|
57
65
|
|
|
58
66
|
// Adds `record` support and makes `public` optional (a package-private class is still a legal
|
|
59
67
|
// Spring bean) in the one place both extractController() and extractEntity() already duplicate
|
|
@@ -45,10 +45,43 @@ function listJavaFiles(srcRoot) {
|
|
|
45
45
|
return out.split('\n').filter(Boolean).sort();
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// D-module-attribution-base-package: found live via a real-world corpus check (spring-projects/
|
|
49
|
+
// spring-petclinic, the canonical public Spring Boot reference app) -- `domain/<module>/...` is
|
|
50
|
+
// Team-IZ-Backend's OWN package convention, not a general Spring Boot one; petclinic's real
|
|
51
|
+
// packages (`org.springframework.samples.petclinic.owner`, `...vet`, `...system`) never contain a
|
|
52
|
+
// `domain` segment at all, so every entity there collapsed into `_unknown`. Finds the app's real
|
|
53
|
+
// base package via its `@SpringBootApplication` class -- the actual framework-defined component-
|
|
54
|
+
// scan root, not a second guessed folder name -- and returns the first path segment immediately
|
|
55
|
+
// below it as a fallback module name. A single targeted `rg` search (not a second full-file read
|
|
56
|
+
// pass), matching this file's own `listJavaFiles()` pattern for rg-invocation-with-graceful-empty-
|
|
57
|
+
// result.
|
|
58
|
+
function findBasePackage(srcRoot) {
|
|
59
|
+
let out;
|
|
60
|
+
try {
|
|
61
|
+
out = execFileSync('rg', ['-l', '--fixed-strings', '@SpringBootApplication', '-g', '*.java', srcRoot], { encoding: 'utf8' });
|
|
62
|
+
} catch {
|
|
63
|
+
return null; // rg exits 1 on "no match" -- not an error, just no @SpringBootApplication class found
|
|
64
|
+
}
|
|
65
|
+
const file = out.split('\n').filter(Boolean).sort()[0];
|
|
66
|
+
if (!file) return null;
|
|
67
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
68
|
+
const pkg = text.match(/(?:^|\n)\s*package\s+([\w.]+)\s*;/);
|
|
69
|
+
return pkg ? pkg[1].split('.') : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function moduleOf(filePath, srcRoot, basePackageParts) {
|
|
49
73
|
const parts = path.relative(srcRoot, filePath).split(path.sep);
|
|
50
74
|
const domainIdx = parts.indexOf('domain');
|
|
51
|
-
|
|
75
|
+
if (domainIdx >= 0 && parts[domainIdx + 1]) return parts[domainIdx + 1];
|
|
76
|
+
// Fallback: the first segment directly under the app's own base package (e.g. petclinic's
|
|
77
|
+
// `owner`/`vet`/`system`) -- only when that segment is itself a subpackage (a directory), never
|
|
78
|
+
// the base package's own top-level file (the `@SpringBootApplication` class itself, or any
|
|
79
|
+
// other file living directly in the base package with no feature module of its own).
|
|
80
|
+
if (basePackageParts && basePackageParts.length && parts.length > basePackageParts.length + 1) {
|
|
81
|
+
const matchesBasePackage = basePackageParts.every((seg, i) => parts[i] === seg);
|
|
82
|
+
if (matchesBasePackage) return parts[basePackageParts.length];
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
function extractQuotedOrValue(argsText) {
|
|
@@ -132,16 +165,69 @@ function extractController(text, filePath) {
|
|
|
132
165
|
return { className, basePath, operationIds, endpoints, file: filePath, line: classLine };
|
|
133
166
|
}
|
|
134
167
|
|
|
135
|
-
|
|
168
|
+
// D-entity-id-field-inheritance: found live against a real corpus check (spring-projects/
|
|
169
|
+
// spring-petclinic) -- `Owner extends Person extends BaseEntity`, and `@Id` lives on `BaseEntity`
|
|
170
|
+
// (a `@MappedSuperclass`), the standard, textbook JPA pattern for sharing an id/audit-field base
|
|
171
|
+
// across entities. A single-file-only `@Id` search misses it entirely for every entity built this
|
|
172
|
+
// way. `classIndex` (simple class name -> that file's own text, built once per scan in
|
|
173
|
+
// scanJavaSpring()) lets this walk the real `extends` chain instead of guessing.
|
|
174
|
+
function extendsClauseName(maskedText) {
|
|
175
|
+
const m = maskedText.match(/\bclass\s+\w+\s+extends\s+(\w+)/);
|
|
176
|
+
return m ? m[1] : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Depth-capped as insurance against a pathological/malformed input, not because real compilable
|
|
180
|
+
// Java can have circular inheritance (it can't) -- same "not expected to trigger, cheap insurance"
|
|
181
|
+
// reasoning javascript-express.mjs's own mount-graph cycle guard (`seen`) already uses for an
|
|
182
|
+
// analogous risk.
|
|
183
|
+
// D-write-safety-phase1 (item 4a): now also captures the declared TYPE, not just the field name --
|
|
184
|
+
// mirrors typescript-express.mjs's own already-established `idFieldIsUuid` precedent
|
|
185
|
+
// (scanners/adapters/typescript-express.mjs:216), which java-spring never had. Returns
|
|
186
|
+
// `{field, type} | null` (was a bare string | null) -- see extractEntity() below for how both
|
|
187
|
+
// existing and new consumers keep working from this.
|
|
188
|
+
function findIdField(text, classIndex, depth = 0) {
|
|
189
|
+
const direct = text.match(/@Id\b[\s\S]{0,200}?private\s+(\S+)\s+(\w+)\s*;/);
|
|
190
|
+
if (direct) return { field: direct[2], type: direct[1] };
|
|
191
|
+
if (depth >= 10) return null;
|
|
192
|
+
const superName = extendsClauseName(maskNonCode(text));
|
|
193
|
+
if (!superName || !classIndex.has(superName)) return null;
|
|
194
|
+
return findIdField(classIndex.get(superName), classIndex, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Bare and fully-qualified both count -- a real repo can `import java.util.UUID;` or spell it out
|
|
198
|
+
// inline (`private java.util.UUID id;`), and this is a text-based scan with no import resolution.
|
|
199
|
+
function isUuidType(type) {
|
|
200
|
+
return type === 'UUID' || type === 'java.util.UUID';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extractEntity(text, filePath, classIndex) {
|
|
136
204
|
if (!/@Entity\b/.test(text)) return null;
|
|
137
205
|
const masked = maskNonCode(text);
|
|
138
206
|
const classDecl = findClassOrRecordDeclaration(masked);
|
|
139
207
|
const tableMatch = text.match(/@Table\(\s*name\s*=\s*"([^"]+)"/);
|
|
140
|
-
const idFieldMatch = text.match(/@Id\b[\s\S]{0,200}?private\s+\S+\s+(\w+)\s*;/);
|
|
141
208
|
return {
|
|
142
209
|
className: classDecl ? classDecl.name : path.basename(filePath, '.java'),
|
|
143
210
|
table: tableMatch ? tableMatch[1] : null,
|
|
144
|
-
|
|
211
|
+
// D-cross-feature-collision: this adapter never GUESSES a table name -- `table: null` above
|
|
212
|
+
// already means "no explicit @Table(name=...)", so tableSource is 'explicit' whenever
|
|
213
|
+
// `table` is non-null and null otherwise. Added only for a consistent field shape with the
|
|
214
|
+
// two adapters that DO guess (python-fastapi/typescript-express) -- cross-feature collision
|
|
215
|
+
// detection reads this field, not each adapter's own null-vs-guessed convention.
|
|
216
|
+
tableSource: tableMatch ? 'explicit' : null,
|
|
217
|
+
...(() => {
|
|
218
|
+
const id = findIdField(text, classIndex);
|
|
219
|
+
return {
|
|
220
|
+
idField: id?.field ?? null,
|
|
221
|
+
// D-write-safety-phase1 (item 4a): idFieldIsUuid mirrors typescript-express.mjs's own
|
|
222
|
+
// field name exactly, for the same reason it exists there -- the handles subsystem's
|
|
223
|
+
// entire identity model is UUID-addressable (fetch(UUID resourceUid), sbf1_ handle
|
|
224
|
+
// tokens encode a UUID), so a non-UUID PK means "no resolver, and say why" rather than
|
|
225
|
+
// silence. idFieldType is the extra, java-spring-specific detail (the actual declared
|
|
226
|
+
// type string) that lets the diagnostic name it, not just say "not UUID".
|
|
227
|
+
idFieldType: id?.type ?? null,
|
|
228
|
+
idFieldIsUuid: id ? isUuidType(id.type) : null,
|
|
229
|
+
};
|
|
230
|
+
})(),
|
|
145
231
|
file: filePath,
|
|
146
232
|
line: classDecl ? lineNumberAt(text, classDecl.index) : null,
|
|
147
233
|
};
|
|
@@ -233,6 +319,24 @@ export function scanJavaSpring(repoRoot) {
|
|
|
233
319
|
const srcRoot = detectJavaSpringRoot(repoRoot);
|
|
234
320
|
if (!srcRoot) return null;
|
|
235
321
|
|
|
322
|
+
const basePackage = findBasePackage(srcRoot);
|
|
323
|
+
|
|
324
|
+
// D-entity-id-field-inheritance: built once, not per-entity -- a single pass over the same
|
|
325
|
+
// files this function was already about to read anyway (no new file I/O), so
|
|
326
|
+
// extractEntity()'s idField search can walk a real `extends` chain across files.
|
|
327
|
+
const files = listJavaFiles(srcRoot);
|
|
328
|
+
const fileTexts = new Map();
|
|
329
|
+
const classIndex = new Map();
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
332
|
+
fileTexts.set(file, text);
|
|
333
|
+
const decl = findClassOrRecordDeclaration(maskNonCode(text));
|
|
334
|
+
// First file wins on a same-simple-name collision across packages -- `files` is already
|
|
335
|
+
// sorted (listJavaFiles()'s own O6 determinism guarantee), so this is deterministic, not
|
|
336
|
+
// silently random; a documented, bounded limitation, not a general symbol resolver.
|
|
337
|
+
if (decl && !classIndex.has(decl.name)) classIndex.set(decl.name, text);
|
|
338
|
+
}
|
|
339
|
+
|
|
236
340
|
const modules = new Map();
|
|
237
341
|
const moduleEntry = (name) => {
|
|
238
342
|
const key = name ?? '_unknown';
|
|
@@ -240,16 +344,16 @@ export function scanJavaSpring(repoRoot) {
|
|
|
240
344
|
return modules.get(key);
|
|
241
345
|
};
|
|
242
346
|
|
|
243
|
-
for (const file of
|
|
244
|
-
const text =
|
|
245
|
-
const mod = moduleOf(file, srcRoot);
|
|
347
|
+
for (const file of files) {
|
|
348
|
+
const text = fileTexts.get(file);
|
|
349
|
+
const mod = moduleOf(file, srcRoot, basePackage);
|
|
246
350
|
|
|
247
351
|
if (/@RestController\b/.test(text)) {
|
|
248
352
|
const controller = extractController(text, file);
|
|
249
353
|
if (controller) moduleEntry(mod).controllers.push(controller);
|
|
250
354
|
}
|
|
251
355
|
if (/@Entity\b/.test(text)) {
|
|
252
|
-
const entity = extractEntity(text, file);
|
|
356
|
+
const entity = extractEntity(text, file, classIndex);
|
|
253
357
|
if (entity) moduleEntry(mod).entities.push(entity);
|
|
254
358
|
}
|
|
255
359
|
if (mod && file.includes(`${path.sep}domain${path.sep}`) && /public\s+enum\s+\w+/.test(text)) {
|
|
@@ -265,7 +369,7 @@ export function scanJavaSpring(repoRoot) {
|
|
|
265
369
|
// paths) -- this is what lib/gate-definitions.mjs's `scan` gate hashes to detect real content
|
|
266
370
|
// drift, and every other manifest-shaped gate input in this codebase (stack's `applied_file:`)
|
|
267
371
|
// is repo-relative too.
|
|
268
|
-
const filesRead =
|
|
372
|
+
const filesRead = files.map((f) => path.relative(repoRoot, f));
|
|
269
373
|
return { srcRoot, modules: [...modules.values()], pathPrefixSignals: detectGlobalPathPrefixSignals(repoRoot), filesRead };
|
|
270
374
|
}
|
|
271
375
|
|