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.
Files changed (77) hide show
  1. package/README.md +185 -13
  2. package/bin/bskel.mjs +689 -33
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +65 -7
  5. package/contracts/openapi.mjs +321 -30
  6. package/contracts/validate.mjs +23 -4
  7. package/handles/_engine.mjs +123 -30
  8. package/handles/capability-codec.mjs +94 -0
  9. package/handles/codec.mjs +13 -3
  10. package/handles/providers/java-spring/emit.mjs +78 -33
  11. package/handles/providers/java-spring/observe.mjs +4 -3
  12. package/handles/providers/java-spring/plan.mjs +73 -16
  13. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +19 -1
  14. package/handles/providers/java-spring/templates/HandleController.java.tmpl +19 -9
  15. package/handles/providers/java-spring/templates/HandleService.java.tmpl +21 -2
  16. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +1 -1
  17. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +36 -9
  18. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +8 -2
  19. package/handles/providers/java-spring.mjs +8 -0
  20. package/handles/providers/python-fastapi/emit.mjs +21 -26
  21. package/handles/providers/python-fastapi/observe.mjs +6 -5
  22. package/handles/providers/python-fastapi/templates/codec.py.tmpl +18 -3
  23. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +129 -39
  24. package/handles/providers/python-fastapi.mjs +3 -3
  25. package/handles/providers/typescript-express/emit.mjs +144 -55
  26. package/handles/providers/typescript-express/observe.mjs +102 -0
  27. package/handles/providers/typescript-express/templates/codec.ts.tmpl +13 -3
  28. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  29. package/handles/providers/typescript-express/templates/handleEntities.ts.tmpl +89 -0
  30. package/handles/providers/typescript-express/templates/handleService.ts.tmpl +81 -0
  31. package/handles/providers/typescript-express/templates/migration.sql.tmpl +36 -0
  32. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  33. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  34. package/handles/providers/typescript-express/templates/recordSnapshotWrapper.ts.tmpl +123 -0
  35. package/handles/providers/typescript-express/templates/registry.ts.tmpl +19 -10
  36. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +13 -0
  37. package/handles/providers/typescript-express/templates/resolverPolicy.ts.tmpl +20 -0
  38. package/handles/providers/typescript-express/templates/router.ts.tmpl +113 -2
  39. package/handles/providers/typescript-express.mjs +7 -4
  40. package/lib/attest.mjs +40 -0
  41. package/lib/cli.mjs +136 -5
  42. package/lib/cross-feature-collisions.mjs +286 -0
  43. package/lib/diff.mjs +35 -0
  44. package/lib/exit-codes.mjs +21 -0
  45. package/lib/fsutil.mjs +7 -2
  46. package/lib/gate-definitions.mjs +85 -1
  47. package/lib/gates.mjs +5 -1
  48. package/lib/http-server.mjs +192 -6
  49. package/lib/lock.mjs +68 -15
  50. package/lib/patch-kinds.mjs +52 -0
  51. package/lib/patch-transactions.mjs +206 -0
  52. package/lib/serve-ui.html +211 -0
  53. package/lib/verify.mjs +23 -6
  54. package/lib/workflow.mjs +31 -3
  55. package/package.json +8 -2
  56. package/scanners/adapters/_java-spring-analyzer.mjs +9 -1
  57. package/scanners/adapters/java-spring.mjs +114 -10
  58. package/scanners/adapters/javascript-express.mjs +46 -13
  59. package/scanners/adapters/python-fastapi.mjs +9 -1
  60. package/scanners/adapters/typescript-express.mjs +19 -2
  61. package/scanners/db/ddl-apply.mjs +253 -0
  62. package/scanners/db/introspect.mjs +61 -32
  63. package/scanners/db/migrations.mjs +73 -18
  64. package/schemas/cross-feature-report.schema.json +66 -0
  65. package/schemas/cross-feature-resolution.schema.json +28 -0
  66. package/schemas/feature-contract.schema.json +3 -3
  67. package/schemas/gate-attestation.schema.json +22 -0
  68. package/schemas/gate-export.schema.json +58 -0
  69. package/schemas/handles-plan.schema.json +2 -0
  70. package/schemas/oracle-manifest.schema.json +58 -0
  71. package/schemas/patch-transaction.schema.json +182 -0
  72. package/schemas/scan-report.schema.json +6 -4
  73. package/schemas/stack-choice.schema.json +12 -1
  74. package/schemas/stack-record.schema.json +6 -1
  75. package/stack/apply.mjs +51 -7
  76. package/stack/catalog/ngrok.yml +8 -2
  77. 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 an empty outputs.spec -- java-spring and
114
- // python-fastapi both emit migration.sql, G4's D-handles-providers follow-up -- but the code
115
- // path stays general for whatever provider comes next).
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: path.relative(root, outputPath), exists: outputExists });
136
+ checks.push({ artifact: `handles ${label}`, path: outputRelPath, exists: outputExists });
128
137
  }
129
138
  }
130
- checks.push(...handlesManifestChecks(root, featureId, handlesRan));
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
- artifact: e.kind === 'infra' ? 'handles infra' : 'handles resolver',
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
- const MUTATING_PREFIXES = ['bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive', 'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit', 'bskel handles plan', 'bskel stack apply'];
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: MUTATING_PREFIXES.some((p) => command.startsWith(p)) };
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.0.0-beta.9",
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
- const CLASS_OR_RECORD_RE = /(?:public\s+)?(class|record)\s+(\w+)/;
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
- function moduleOf(filePath, srcRoot) {
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
- return domainIdx >= 0 && parts[domainIdx + 1] ? parts[domainIdx + 1] : null;
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
- function extractEntity(text, filePath) {
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
- idField: idFieldMatch ? idFieldMatch[1] : null,
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 listJavaFiles(srcRoot)) {
244
- const text = fs.readFileSync(file, 'utf8');
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 = listJavaFiles(srcRoot).map((f) => path.relative(repoRoot, f));
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