unitbob 0.3.5 → 0.3.6

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.
@@ -199,16 +199,25 @@ function checkSurfaceCoverage(row, id, expected, suiteText, add) {
199
199
  // here in the server's own shape; refusing it locally would refuse an answer
200
200
  // the server takes, which is the one direction of drift that costs a branch.
201
201
  const declaredUnreachable = collectUnreachable(row, id, reached, add);
202
- const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface));
202
+ const deferred = collectDeferred(row, id, reached, declaredUnreachable, add);
203
+ // Spec 34-3, criterion 6. Cheap here and expensive later: over the ceiling is
204
+ // one of the answers the server rejects, and finding it after the suite has
205
+ // been written, run and reviewed costs the whole cycle.
206
+ const budget = expected.surfaceBudget;
207
+ if (budget !== undefined && reached.size > budget) {
208
+ add(`${id} guards ${reached.size} surfaces, over the surface_budget of ${budget}` +
209
+ ' — guard the most important ones up to that number and list the rest in deferred_surfaces.');
210
+ }
211
+ const missed = expected.surfaces.filter((surface) => !reached.has(surface) && !declaredUnreachable.has(surface) && !deferred.has(surface));
203
212
  if (missed.length > 0) {
204
213
  add(`${id} surface_coverage accounts for no scenario at ${missed.join(', ')}` +
205
- ' — drive it, or declare it unreachable with a business reason.');
214
+ ' — drive it, declare it unreachable with a business reason, or defer it under the surface budget.');
206
215
  }
207
216
  // No check here for "declares everything unreachable and drives nothing": the
208
217
  // caller already returned when the capability's marker appears in no suite
209
218
  // file, so a capability with no Scenario never reaches this function at all.
210
219
  // The server refuses that state for the same reason, one rule earlier.
211
- const foreign = [...reached, ...declaredUnreachable].filter((surface) => !expected.surfaces.includes(surface));
220
+ const foreign = [...reached, ...declaredUnreachable, ...deferred].filter((surface) => !expected.surfaces.includes(surface));
212
221
  if (foreign.length > 0) {
213
222
  add(`${id} surface_coverage names ${foreign.join(', ')}, which this branch's assignment does not carry.`);
214
223
  }
@@ -262,6 +271,49 @@ function collectUnreachable(row, id, reached, add) {
262
271
  }
263
272
  return surfaces;
264
273
  }
274
+ // Spec 34-3, criterion 6. The addresses this run did not take, because the
275
+ // capability carried more than `surface_budget` of them. Mirrored here for the
276
+ // same reason the unreachable list is, and more urgently: refusing this answer
277
+ // locally does not merely disagree with the server, it hands the host an error
278
+ // message pointing at `unreachable_surfaces` — the one place these must never
279
+ // go, because "nothing can cause this request" and "there were better ones" are
280
+ // different sentences and only one of them is true.
281
+ //
282
+ // Plain surface ids, with no reason each. That asymmetry with the unreachable
283
+ // list is deliberate: there the sentence is the guard, because an address you
284
+ // cannot write a sentence about is not really unreachable. Here the reason is
285
+ // the same for every entry and already known — the ceiling.
286
+ function collectDeferred(row, id, reached, unreachable, add) {
287
+ const declared = row.deferred_surfaces;
288
+ if (declared === undefined)
289
+ return new Set();
290
+ if (!Array.isArray(declared) || declared.length === 0) {
291
+ add(`${id} deferred_surfaces must be a non-empty array of surface ids when it is present.`);
292
+ return new Set();
293
+ }
294
+ const surfaces = new Set();
295
+ for (const [index, item] of declared.entries()) {
296
+ const surface = typeof item === 'string' ? item.trim() : '';
297
+ if (!surface) {
298
+ add(`${id} deferred_surfaces[${index}] names no surface.`);
299
+ continue;
300
+ }
301
+ if (reached.has(surface)) {
302
+ add(`${id} both drives ${surface} in a scenario and defers it — it is one or the other.`);
303
+ continue;
304
+ }
305
+ if (unreachable.has(surface)) {
306
+ add(`${id} declares ${surface} both unreachable and deferred — cannot be reached and was not taken this time are different answers.`);
307
+ continue;
308
+ }
309
+ if (surfaces.has(surface)) {
310
+ add(`${id} defers ${surface} more than once.`);
311
+ continue;
312
+ }
313
+ surfaces.add(surface);
314
+ }
315
+ return surfaces;
316
+ }
265
317
  // Scenario names carrying one marker, by shape rather than by grammar. See the
266
318
  // caller for why this stays deliberately timid.
267
319
  function scenarioNamesTagged(suiteText, marker) {
@@ -333,6 +385,7 @@ function hasContent(assignment) {
333
385
  // case, wherever the shape happens to nest it.
334
386
  function assignedCases(assignment) {
335
387
  const found = [];
388
+ let surfaceBudget;
336
389
  const walk = (value) => {
337
390
  if (Array.isArray(value)) {
338
391
  value.forEach(walk);
@@ -341,6 +394,11 @@ function assignedCases(assignment) {
341
394
  if (!value || typeof value !== 'object')
342
395
  return;
343
396
  const row = value;
397
+ // Found by the same walk rather than by knowing where the server put it, for
398
+ // the same reason the cases are: the assignment body is opaque here.
399
+ if (typeof row.surface_budget === 'number' && Number.isFinite(row.surface_budget)) {
400
+ surfaceBudget = row.surface_budget;
401
+ }
344
402
  const key = row.contract_key;
345
403
  const marker = row.case_marker;
346
404
  if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
@@ -356,7 +414,9 @@ function assignedCases(assignment) {
356
414
  Object.values(row).forEach(walk);
357
415
  };
358
416
  walk(assignment);
359
- return found;
417
+ // Stamped after the walk, never during it: nothing promises the ceiling is
418
+ // visited before the cases that answer to it.
419
+ return found.map((entry) => ({ ...entry, surfaceBudget }));
360
420
  }
361
421
  // Every byte of the branch's suite, main file and support files together, for
362
422
  // the "is the marker actually in there" check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {