unitbob 0.7.13 → 0.7.15

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 CHANGED
@@ -35,7 +35,7 @@ Codex:
35
35
  ```
36
36
  codex plugin marketplace add sergeygershun/unitbob-connector
37
37
  codex plugin add unitbob@unitbob
38
- npx -y unitbob@0.7.13 codex-install
38
+ npx -y unitbob@0.7.15 codex-install
39
39
  ```
40
40
 
41
41
  Then start a new chat. A chat opened before the install does not know about
@@ -79,8 +79,8 @@ Do steps 1 and 2 above first. Then, in the chat, in this order:
79
79
  | 1. Name it | `I want to add <feature>` | Unitbob writes down what you want and which parts of the map it may touch. You get a link to the feature's page. |
80
80
  | 2. Talk it through | `Let's talk through <feature>` | The assistant asks a few numbered questions about how the feature should behave — in plain words, with options and a recommendation. Answer with a letter, a word, or `do as you recommend`. Then it shows you the list of things that will be true when the feature is done and asks: is this it? Say `yes`. |
81
81
  | 3. Write the checks | `Write the checks for <feature>` | Each item from that list becomes a check. They are all red now — on purpose, the feature is not built yet. |
82
- | 4. Build it | `Build <feature>` | Ordinary work in the same chat. Say `Run the checks` at any time to see how many of the feature's checks already pass, and whether anything else broke. |
83
- | 5. Wrap up | `I'm done with <feature>` | The checks run once more, a second assistant reads them over, and you get the link to the feature's page. |
82
+ | 4. Build it | `Build <feature>` | Ordinary work in the same chat. Say `Run the checks` at any time to see how many of the feature's checks already pass, and whether anything else broke. The moment they all pass, a second assistant reads them over on its own and you get the link to the feature's page. |
83
+ | 5. Wrap up | `I'm done with <feature>` | Only if step 4 did not end on that link: the checks run once more, the second assistant reads them over, and you get the link. |
84
84
  | 6. Finish | Press **Finish this feature** on that page | Only you can press it, and only when everything is green. The feature gets its own lamp on the map, already green. |
85
85
 
86
86
  Good to know:
package/dist/cli.js CHANGED
@@ -153,9 +153,13 @@ export async function main(argv, deps = { ensureLinked }) {
153
153
  case 'accept-worker-plan':
154
154
  await acceptWorkerPlan(await linked(), args);
155
155
  return 0;
156
- case 'validate-worker-checkpoints':
157
- await validateWorkerCheckpoints(await linked(), args);
158
- return 0;
156
+ case 'validate-worker-checkpoints': {
157
+ // Spec 55-1, §3. Non-zero for an invalid slice, so a workflow that
158
+ // only reads the exit code still stops — but the verdict is per slice
159
+ // and printed, never thrown.
160
+ const verdict = await validateWorkerCheckpoints(await linked(), args);
161
+ return verdict.invalid_workers.length > 0 ? 1 : 0;
162
+ }
159
163
  case 'put-suite-build':
160
164
  return await publishAndRun(await linked(), args);
161
165
  case 'fix-prepare':
@@ -2,6 +2,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rm
2
2
  import { dirname, join } from 'node:path';
3
3
  import { assertUnitbobPath } from "./artifactPath.js";
4
4
  import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
5
+ import { looksLikeNext } from "../surfaces/nextRoutes.js";
5
6
  // The behavioral suite lives under its own root: the main `.feature` plus its
6
7
  // step definitions and any helper files, all under `.unitbob/behavioral/`
7
8
  // (spec 32). Nothing here is ever written into the project's own `spec/`,
@@ -10,7 +11,7 @@ import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
10
11
  // blob only so `check` can execute it locally.
11
12
  export const BEHAVIORAL_DIR = '.unitbob/behavioral';
12
13
  export const BEHAVIORAL_WORLD_PATH = `${BEHAVIORAL_DIR}/step_definitions/00_unitbob_world.rb`;
13
- const BEHAVIORAL_WORLD_JS_PATH = `${BEHAVIORAL_DIR}/step_definitions/00_unitbob_world.js`;
14
+ export const BEHAVIORAL_WORLD_JS_PATH = `${BEHAVIORAL_DIR}/step_definitions/00_unitbob_world.js`;
14
15
  // A level above "step_definitions/", and deliberately. pytest loads every
15
16
  // conftest.py from the rootdir down to the directory it collects, so this one is
16
17
  // loaded — and the host's own "step_definitions/conftest.py", which the step
@@ -197,6 +198,180 @@ net.Socket.prototype.connect = function unitbobGuardedConnect(...args) {
197
198
  return unitbobConnect.apply(this, args);
198
199
  };
199
200
  `;
201
+ // Spec 56-1, §2. On Next.js the JavaScript harness is the network guard above
202
+ // *and* a door into the application. Rails gets its door from the connector
203
+ // too — `ActionDispatch::Integration` in the Ruby World; Nest brings its own
204
+ // (`supertest`); Next has no framework test client, and without this every
205
+ // coordinator wrote the same two hundred lines of shims and, on the appartment
206
+ // run, called the route handler directly — which walks past `proxy.ts` and
207
+ // turned a 307 on production into a green "this address is public".
208
+ //
209
+ // The door is the one the spike proved (requirements, "Спайк"): Next's own
210
+ // programmatic API in this process, in dev mode, so the handler, the page and
211
+ // `proxy.ts` run here, where a step can replace `globalThis.fetch` and see
212
+ // what the application sent out. Nothing of the project's is changed: it
213
+ // compiles into `.next/` as `next dev` would (and, on Next 16, takes the same
214
+ // `.next/dev/lock`), and touches no source, config or env file.
215
+ //
216
+ // What is deliberately not here: signing in, fixtures, seeding, the test
217
+ // database. Those stay in host-owned shared steps, as on every other stack.
218
+ // The one thing this file says about the database is "no": a `DATABASE_URL`
219
+ // whose database is not named like a test one does not get a door.
220
+ const NEXT_DOOR_JS = `
221
+ // ---------------------------------------------------------------------------
222
+ // The door into this Next.js application (spec 56-1).
223
+ //
224
+ // Before the first scenario the application is started here, in this process,
225
+ // through Next's own API in dev mode (it compiles into \`.next/\` on demand, as
226
+ // \`next dev\` would). Every request then takes the same road as under
227
+ // \`next start\`: \`proxy.ts\` / \`middleware.ts\`, the router, the handler.
228
+ // Every scenario gets
229
+ // \`this.unitbobFetch(path, init)\`: a fetch against that server with a cookie
230
+ // jar of its own (a Set-Cookie from one response goes out with the next
231
+ // request of the same scenario, and is forgotten at the scenario boundary) and
232
+ // \`redirect: 'manual'\`, so a 307 from the gate is seen as a 307.
233
+ //
234
+ // Do not import a route handler and call it. That walks past the gate, and a
235
+ // green "this address is public" from such a scenario is a lie.
236
+ //
237
+ // Because the application runs in this process, the network guard above holds
238
+ // for its outgoing calls too, and a step may replace \`globalThis.fetch\` in a
239
+ // \`Before\` hook of the project's own steps to read what the application sent
240
+ // out — that hook runs after the warm-up request below, which is what keeps
241
+ // the replacement alive through the first compilation on Next 15.
242
+ //
243
+ // Signing in, fixtures, seeding and the test database are not here; they are
244
+ // host-owned shared steps. This file says exactly one thing about the
245
+ // database: it refuses to open the door on one that is not named like a test
246
+ // database.
247
+ const http = require('node:http');
248
+ const path = require('node:path');
249
+ const { AfterAll, Before, BeforeAll } = require('@cucumber/cucumber');
250
+
251
+ // This file lives in .unitbob/behavioral/step_definitions/; the project root
252
+ // is three levels up. \`next\` is the project's own — resolved from there, past
253
+ // the sidecar's node_modules, which holds only cucumber.
254
+ const unitbobRoot = path.resolve(__dirname, '..', '..', '..');
255
+
256
+ // The fetch this process started with, taken before Next wraps the global one
257
+ // and before any step replaces it: the door must keep working while a
258
+ // scenario has \`globalThis.fetch\` stubbed to watch the application's calls.
259
+ const unitbobRealFetch = globalThis.fetch;
260
+
261
+ let unitbobApp = null;
262
+ let unitbobServer = null;
263
+ let unitbobOrigin = null;
264
+
265
+ function unitbobProjectRequire(name, from) {
266
+ return require(require.resolve(name, { paths: [from] }));
267
+ }
268
+
269
+ // The database a URL names: the path's last segment, before any query string.
270
+ // Percent-decoded when the URL parses; taken as text when it does not.
271
+ function unitbobDatabaseName(url) {
272
+ try {
273
+ return decodeURIComponent(new URL(url).pathname.split('/').pop() || '');
274
+ } catch {
275
+ return String(url).split('?')[0].split('/').pop() || '';
276
+ }
277
+ }
278
+
279
+ BeforeAll({ timeout: 180000 }, async function () {
280
+ // The runner already sets NODE_ENV=test; kept here so Next reads .env.test
281
+ // even when this file is loaded some other way.
282
+ process.env.NODE_ENV = process.env.NODE_ENV || 'test';
283
+
284
+ // .env* through the loader Next itself uses (\`@next/env\`, found beside the
285
+ // project's \`next\`), so the guard below sees what the application will
286
+ // see: process.env, then .env.test.local, .env.test, .env — and a variable
287
+ // already in the environment wins over every file.
288
+ try {
289
+ const nextPackage = path.dirname(require.resolve('next/package.json', { paths: [unitbobRoot] }));
290
+ unitbobProjectRequire('@next/env', nextPackage).loadEnvConfig(unitbobRoot, true);
291
+ } catch {
292
+ // Next loads the files itself at prepare(); only the guard below is left
293
+ // reading the shell's environment alone.
294
+ }
295
+
296
+ if (process.env.DATABASE_URL) {
297
+ const name = unitbobDatabaseName(process.env.DATABASE_URL);
298
+ if (!/test/i.test(name)) {
299
+ const refusal =
300
+ '[unitbob] Unitbob will not run scenarios against "' + name + '": it does not look like a test database. ' +
301
+ 'Put a test DATABASE_URL in .env.test';
302
+ // Printed as a line of its own before it is thrown: cucumber-js reports a
303
+ // BeforeAll failure as "a BeforeAll hook errored" with the cause folded
304
+ // under it, and the one line that matters is this one.
305
+ console.error(refusal);
306
+ throw new Error(refusal);
307
+ }
308
+ }
309
+
310
+ const next = unitbobProjectRequire('next', unitbobRoot);
311
+ unitbobApp = next({ dev: true, dir: unitbobRoot });
312
+ await unitbobApp.prepare();
313
+ const handle = unitbobApp.getRequestHandler();
314
+ unitbobServer = http.createServer((req, res) => handle(req, res));
315
+ await new Promise((resolve, reject) => {
316
+ unitbobServer.once('error', reject);
317
+ unitbobServer.listen(0, '127.0.0.1', resolve);
318
+ });
319
+ unitbobOrigin = 'http://127.0.0.1:' + unitbobServer.address().port;
320
+
321
+ // One warm-up request: the first compilation happens now, not inside the
322
+ // first scenario's timeout, and a \`fetch\` replaced by a step afterwards
323
+ // survives it.
324
+ try {
325
+ await (await unitbobRealFetch(unitbobOrigin + '/', { redirect: 'manual' })).arrayBuffer();
326
+ } catch {
327
+ // A first page that fails is the scenarios' business, not the door's.
328
+ }
329
+ });
330
+
331
+ Before(function () {
332
+ const jar = new Map();
333
+ this.unitbobFetch = async function unitbobFetch(target, init) {
334
+ if (!unitbobOrigin) throw new Error('[unitbob] The application is not running: BeforeAll did not open the door.');
335
+ const options = Object.assign({}, init);
336
+ const headers = new Headers(options.headers || {});
337
+ if (jar.size > 0 && !headers.has('cookie')) {
338
+ headers.set('cookie', Array.from(jar, ([name, value]) => name + '=' + value).join('; '));
339
+ }
340
+ options.headers = headers;
341
+ options.redirect = options.redirect || 'manual';
342
+
343
+ const response = await unitbobRealFetch(new URL(target, unitbobOrigin), options);
344
+ // A test jar, not a browser's: Path, Domain and Secure are not honoured —
345
+ // every cookie goes back to the one origin the door serves. An empty value,
346
+ // Max-Age=0 or a past Expires forgets the cookie.
347
+ for (const cookie of response.headers.getSetCookie()) {
348
+ const [pair, ...attributes] = cookie.split(';');
349
+ const eq = pair.indexOf('=');
350
+ if (eq === -1) continue; // not a name=value pair; nothing to remember
351
+ const name = pair.slice(0, eq).trim();
352
+ const value = pair.slice(eq + 1).trim();
353
+ const expires = attributes.map((a) => /^\\s*expires=(.+)$/i.exec(a)).find(Boolean);
354
+ const maxAge = attributes.map((a) => /^\\s*max-age=(-?\\d+)$/i.exec(a)).find(Boolean);
355
+ const gone = value === '' || (maxAge && Number(maxAge[1]) <= 0) || (expires && new Date(expires[1]).getTime() <= Date.now());
356
+ if (gone) jar.delete(name);
357
+ else jar.set(name, value);
358
+ }
359
+ return response;
360
+ };
361
+ });
362
+
363
+ AfterAll({ timeout: 60000 }, async function () {
364
+ if (unitbobServer) {
365
+ // Keep-alive sockets from the scenarios' fetches would keep close() waiting.
366
+ if (typeof unitbobServer.closeAllConnections === 'function') unitbobServer.closeAllConnections();
367
+ await new Promise((resolve) => unitbobServer.close(() => resolve()));
368
+ }
369
+ if (unitbobApp) await unitbobApp.close().catch(() => {});
370
+ // A dev-mode Next keeps watchers open, so the process does not end on its own
371
+ // — the connector runs cucumber-js with \`--exit\` for that.
372
+ });
373
+ `;
374
+ const BEHAVIORAL_WORLD_NEXT_JS = BEHAVIORAL_WORLD_JS + NEXT_DOOR_JS;
200
375
  // The Python peer. pytest reads the connector's own ini through \`-c\`, so the
201
376
  // project's pytest settings do not apply, and only the conftest.py files on the
202
377
  // path from the repository root down to the collected directory are loaded —
@@ -274,11 +449,20 @@ const BEHAVIORAL_WORLDS = {
274
449
  'cucumber-js': { path: BEHAVIORAL_WORLD_JS_PATH, content: BEHAVIORAL_WORLD_JS },
275
450
  'pytest-bdd': { path: BEHAVIORAL_WORLD_PY_PATH, content: BEHAVIORAL_WORLD_PY },
276
451
  };
277
- export function behavioralWorldFor(runner) {
278
- return BEHAVIORAL_WORLDS[runner];
452
+ // The table is by runner; the project is the one exception, and it is named
453
+ // here rather than hidden in the table: on Next.js (spec 56-1) the cucumber-js
454
+ // harness carries the door into the application as well as the network guard.
455
+ // Same path — everything that reasons about *where* the harness is stays a
456
+ // function of the runner alone.
457
+ export function behavioralWorldFor(runner, projectRoot) {
458
+ const world = BEHAVIORAL_WORLDS[runner];
459
+ if (world && runner === 'cucumber-js' && projectRoot !== undefined && looksLikeNext(projectRoot)) {
460
+ return { path: world.path, content: BEHAVIORAL_WORLD_NEXT_JS };
461
+ }
462
+ return world;
279
463
  }
280
464
  export function materializeBehavioralWorld(projectRoot, runner = 'cucumber') {
281
- const world = behavioralWorldFor(runner);
465
+ const world = behavioralWorldFor(runner, projectRoot);
282
466
  if (!world)
283
467
  return null;
284
468
  const worldPath = join(projectRoot, world.path);
@@ -51,7 +51,13 @@ export function readPacketIndex(projectRoot) {
51
51
  // written, and before any plan exists: the entrypoints are known from the
52
52
  // request, the workers are not, so a packet belongs to an entrypoint and two
53
53
  // entrypoints in one file share one packet.
54
- export function writeSuitePackets(projectRoot, request) {
54
+ //
55
+ // Spec 55-1, §1: the packets agree with the probe. A structural file the
56
+ // runner cannot load gets no packet and a note, for the same reason the probe
57
+ // leaves it out — a worker handed the file would write an import of it and
58
+ // meet the same failure one step later, at `run-local`. `runner` is null when
59
+ // no structural runner was found, and then nothing is filtered.
60
+ export function writeSuitePackets(projectRoot, request, runner = null) {
55
61
  const targets = resolveTargets(projectRoot, request);
56
62
  const notes = new Map();
57
63
  // Regenerated whole, every run. A packet left over from a previous assignment
@@ -67,6 +73,11 @@ export function writeSuitePackets(projectRoot, request) {
67
73
  count(notes, 'did not resolve to one file');
68
74
  continue;
69
75
  }
76
+ if (target.branch === 'structural' && !isRunnerFile(runner, sourceFile)) {
77
+ target.note = `not a ${runner} file — left out of the code-structure suite, as the boot check leaves it out`;
78
+ count(notes, 'not a file this runner can load');
79
+ continue;
80
+ }
70
81
  // Two entrypoints in one file get one packet, written once, referenced twice.
71
82
  if (!written.has(sourceFile) && !refused.has(sourceFile)) {
72
83
  const copied = copyPacket(projectRoot, sourceFile);
@@ -122,6 +133,21 @@ function resolveTargets(projectRoot, request) {
122
133
  }
123
134
  return targets;
124
135
  }
136
+ // Spec 55-1, §1. What each structural runner can load: a file with any other
137
+ // extension is not this stack's, whatever the map says about it. Vite fails at
138
+ // import-analysis on the first `.py`, before any setup file runs; pytest and
139
+ // rspec have no reading of a `.ts` at all. The extension lists are the
140
+ // runners' own, not a table of languages: nothing here says what a project is
141
+ // written in, only what one probe can `import`.
142
+ export const RUNNER_EXTENSIONS = {
143
+ vitest: ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'],
144
+ pytest: ['.py'],
145
+ rspec: ['.rb'],
146
+ };
147
+ export function isRunnerFile(runner, file) {
148
+ const extensions = runner ? RUNNER_EXTENSIONS[runner] : undefined;
149
+ return !extensions || extensions.some((extension) => file.endsWith(extension));
150
+ }
125
151
  // Spec 38, criterion 2. The source files the structural guardrails of this
126
152
  // request will import — the list the boot check loads instead of the project's
127
153
  // own tests. Computed from two artifacts already on this machine, so it costs no
@@ -130,8 +156,15 @@ function resolveTargets(projectRoot, request) {
130
156
  // A name nothing answered to is simply absent. That gap is already a visible
131
157
  // number — "the source behind N of M entrypoints" — and turning it into a
132
158
  // refusal would stop a run over a hole in the graph (see В3 of the requirements).
133
- export function structuralSourceFiles(projectRoot, request) {
159
+ //
160
+ // Spec 55-1, §1: a file the runner cannot load is not absent but *left out*,
161
+ // and returned as such. On repo 139 the map named Python tools beside a Next.js
162
+ // app, the vitest probe imported them, vite fell over at import-analysis and
163
+ // the vibecoder was sent to repair their code and rebuild the map. One list for
164
+ // the probe and the packets, filtered once, here.
165
+ export function structuralSourceFiles(projectRoot, request, runner) {
134
166
  const files = [];
167
+ const leftOut = [];
135
168
  for (const target of resolveTargets(projectRoot, request)) {
136
169
  if (target.branch !== 'structural' || !target.source_file)
137
170
  continue;
@@ -152,10 +185,11 @@ export function structuralSourceFiles(projectRoot, request) {
152
185
  continue;
153
186
  if (!existsSync(join(projectRoot, target.source_file)))
154
187
  continue;
155
- if (!files.includes(target.source_file))
156
- files.push(target.source_file);
188
+ const list = isRunnerFile(runner, target.source_file) ? files : leftOut;
189
+ if (!list.includes(target.source_file))
190
+ list.push(target.source_file);
157
191
  }
158
- return files;
192
+ return { files, leftOut };
159
193
  }
160
194
  // The two names a request carries. Structural interfaces name methods
161
195
  // (`User#get_token`); behavioral capabilities name addresses (`POST /api/tokens`).
@@ -21,6 +21,29 @@ export function requestDigest(projectRoot) {
21
21
  export function workerPlanDigest(projectRoot) {
22
22
  return exactFileDigest(workerPlanPath(projectRoot));
23
23
  }
24
+ // Spec 55-1, §2. The fingerprint of one slice: its plan item as canonical JSON
25
+ // (keys sorted at every depth, arrays in order), hashed. `plan_digest` covers
26
+ // the whole file and answers "is this the plan on disk"; this one answers "is
27
+ // this still the same job", which is what a finished checkpoint is bound to.
28
+ //
29
+ // Every field counts, `worker_id` and `owned_paths` included: a checkpoint
30
+ // says which files it wrote and which worker wrote them, so a slice that moved
31
+ // its paths or took a new id is a different slice even when its promises are
32
+ // word for word the same. Reordering the workers in the file, or editing a
33
+ // neighbour, changes nothing here.
34
+ export function sliceDigest(item) {
35
+ return createHash('sha256').update(canonicalJson(item)).digest('hex');
36
+ }
37
+ function canonicalJson(value) {
38
+ if (Array.isArray(value))
39
+ return `[${value.map(canonicalJson).join(',')}]`;
40
+ if (value && typeof value === 'object') {
41
+ const record = value;
42
+ const keys = Object.keys(record).filter((key) => record[key] !== undefined).sort();
43
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
44
+ }
45
+ return JSON.stringify(value) ?? 'null';
46
+ }
24
47
  // The addresses the request handed to each capability, indexed by id. The
25
48
  // checkpoint gate needs them for one question only: was this address given to
26
49
  // this slice at all (spec 41, criterion 1). What a slice *left* is not worked out
@@ -48,6 +71,34 @@ export function assignedSurfaces(projectRoot) {
48
71
  }
49
72
  return byId;
50
73
  }
74
+ // Spec 55-1, §4. The marker the request minted for each capability and each
75
+ // structural interface, by id — the `@ubc_…` tag a Scenario must carry and the
76
+ // `[ubc_…]` a structural test names. This is the one reader of markers in
77
+ // `src/`, and it answers one question: which markers were this slice's. What a
78
+ // marker *means* on the map is the server's, and nothing here goes near it.
79
+ export function assignedMarkers(projectRoot) {
80
+ const request = readRequest(projectRoot);
81
+ const byId = new Map();
82
+ for (const branch of Array.isArray(request.branches) ? request.branches : []) {
83
+ const assignment = branch.assignment;
84
+ for (const entry of Array.isArray(assignment?.capabilities) ? assignment.capabilities : []) {
85
+ markerOf(entry, 'capability_id', byId);
86
+ }
87
+ for (const block of Array.isArray(assignment?.blocks) ? assignment.blocks : []) {
88
+ const interfaces = block?.interfaces;
89
+ for (const entry of Array.isArray(interfaces) ? interfaces : [])
90
+ markerOf(entry, 'interface_id', byId);
91
+ }
92
+ }
93
+ return byId;
94
+ }
95
+ function markerOf(entry, idField, into) {
96
+ const record = entry;
97
+ const id = record?.[idField];
98
+ const marker = record?.case_marker;
99
+ if (isNonEmptyString(id) && isNonEmptyString(marker))
100
+ into.set(id, marker);
101
+ }
51
102
  // The task, read as loosely typed JSON.
52
103
  //
53
104
  // `readSuiteBuildRequest` in `suiteBuild.ts` returns the same file typed, and is
@@ -83,25 +134,38 @@ export function seedWorkerCheckpoints(projectRoot) {
83
134
  const plan_digest = workerPlanDigest(projectRoot);
84
135
  const written = [];
85
136
  const kept = [];
137
+ const carried = [];
86
138
  const superseded = [];
87
139
  for (const item of plan.workers) {
88
140
  const path = checkpointPath(projectRoot, item);
89
141
  const label = `${item.branch}:${item.worker_id}`;
90
142
  // Left alone when it already belongs to this plan: the coordinator's facts
91
143
  // and a worker's finished slice both live in this file, and a run costs
92
- // hours.
93
- if (belongsToPlan(path, plan_digest)) {
144
+ // hours. One field may be added: a checkpoint from before slice digests
145
+ // existed lacks its own, and the plan it belongs to is its slice.
146
+ const existing = readCheckpoint(path);
147
+ if (existing?.plan_digest === plan_digest) {
148
+ if (existing.slice_digest === undefined) {
149
+ writeFileSync(path, `${JSON.stringify({ ...existing, slice_digest: sliceDigest(item) }, null, 2)}\n`);
150
+ }
94
151
  kept.push(label);
95
152
  continue;
96
153
  }
154
+ // Spec 55-1, §2. A checkpoint from an earlier plan whose own slice did not
155
+ // change is the same job, finished or not, and it stays. Only the file
156
+ // digest it is bound to is rewritten; everything a worker or the coordinator
157
+ // put in it is left as it is. On repo 139 a replan sent 43 of 43 checkpoints
158
+ // aside for one edited promise, and the copying back fell to a human.
159
+ if (existing && existing.request_digest === request_digest && existing.slice_digest === sliceDigest(item)) {
160
+ writeFileSync(path, `${JSON.stringify({ ...existing, plan_digest }, null, 2)}\n`);
161
+ carried.push(label);
162
+ continue;
163
+ }
97
164
  // Everything else needs a fresh seed — but the file being replaced is not
98
- // necessarily worthless. `plan_digest` is a digest of the whole
99
- // `worker-plan.json`, so editing one slice invalidates every checkpoint at
100
- // once, including finished ones; and replanning after the first slice comes
101
- // back is a documented, ordinary move. Overwriting in place would have made
102
- // this verb the one thing in the build that destroys hours of work, on the
103
- // most ordinary path there is. Moved for the same reason `suite-prepare`
104
- // moves the previous run rather than deleting it.
165
+ // necessarily worthless: the slice changed, and what was done for the old
166
+ // one may still be worth reading. Overwriting in place would have made this
167
+ // verb the one thing in the build that destroys hours of work. Moved for the
168
+ // same reason `suite-prepare` moves the previous run rather than deleting it.
105
169
  if (existsSync(path)) {
106
170
  const aside = join(dirname(path), SUPERSEDED_DIR, `${item.branch}-${item.worker_id}.json`);
107
171
  mkdirSync(dirname(aside), { recursive: true });
@@ -113,23 +177,26 @@ export function seedWorkerCheckpoints(projectRoot) {
113
177
  writeFileSync(path, `${JSON.stringify(seedFor(item, request_digest, plan_digest), null, 2)}\n`);
114
178
  written.push(label);
115
179
  }
116
- return { written, kept, superseded };
180
+ return { written, kept, carried, superseded };
117
181
  }
118
- function belongsToPlan(path, planDigest) {
182
+ // A checkpoint on disk, or nothing: a missing file and a file that is not a JSON
183
+ // object are the same answer to every question asked here.
184
+ function readCheckpoint(path) {
119
185
  if (!existsSync(path))
120
- return false;
186
+ return null;
121
187
  try {
122
188
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
123
- return parsed?.plan_digest === planDigest;
189
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
124
190
  }
125
191
  catch {
126
- return false;
192
+ return null;
127
193
  }
128
194
  }
129
195
  function seedFor(item, request_digest, plan_digest) {
130
196
  return {
131
197
  request_digest,
132
198
  plan_digest,
199
+ slice_digest: sliceDigest(item),
133
200
  branch: item.branch,
134
201
  worker_id: item.worker_id,
135
202
  // Every promise starts unresolved: the slice has not been worked yet, and
@@ -5,6 +5,7 @@ import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
5
  import { commandFileOnHost } from "./toolchain.js";
6
6
  import { clearReport, readFreshReport } from "./types.js";
7
7
  import { PYTEST_BDD_PLUGIN } from "./pytestBddPlugin.js";
8
+ import { looksLikeNext } from "../surfaces/nextRoutes.js";
8
9
  export const BDD_TIMEOUT_MS = 10 * 60 * 1000;
9
10
  // The behavioral suite lives under one root; the report is written inside it so
10
11
  // the app under test cannot pollute it and it travels with the suite.
@@ -44,6 +45,12 @@ const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
44
45
  const PYTEST_INI = '[pytest]\naddopts =\n';
45
46
  // Load order is a fact about both Cucumbers and about neither pytest — pytest
46
47
  // picks `conftest.py` up itself, so there is no trap to work around there.
48
+ // Spec 55-1, §7. A step text with a path in it — `I open /start` — is read by
49
+ // both cucumbers as a Cucumber Expression, where `/` is alternation; the
50
+ // expression fails to compile and the runner dies before the first scenario.
51
+ // One line where the worker reads what the runner requires.
52
+ const CUCUMBER_SLASH = '`/` inside a Cucumber Expression means "or"; escape it as `\\/` or use a regular expression for a ' +
53
+ 'step whose text carries a path.';
47
54
  const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file is not special to the runner — `account_access` ' +
48
55
  'loads before `shared`. Open each capability file with an explicit require of the shared one rather ' +
49
56
  'than trusting the alphabet.';
@@ -63,6 +70,7 @@ const BDD_STRATEGIES = {
63
70
  'The connector points `--require` at `step_definitions/`, so every `.rb` file there is loaded. ' +
64
71
  'That explicit `--require` also switches off Cucumber\'s automatic loading of `features/support/`: ' +
65
72
  'a World or helper parked there is never evaluated, and every step then fails on a bare object.',
73
+ CUCUMBER_SLASH,
66
74
  CUCUMBER_LOAD_ORDER,
67
75
  ],
68
76
  },
@@ -79,6 +87,7 @@ const BDD_STRATEGIES = {
79
87
  'The connector registers no TypeScript loader, so a `.ts` file cannot compile itself. If you want ' +
80
88
  'one, register the compiler from the file that sorts first — and remember the file registering it ' +
81
89
  'is itself loaded as plain JavaScript.',
90
+ CUCUMBER_SLASH,
82
91
  CUCUMBER_LOAD_ORDER,
83
92
  ],
84
93
  },
@@ -177,6 +186,7 @@ async function runCucumberJs(projectRoot, filter) {
177
186
  steps,
178
187
  '--format',
179
188
  `message:${CUCUMBER_REPORT}`,
189
+ ...cucumberJsExitArgs(projectRoot),
180
190
  ...tagArgs(filter, '--tags', '@'),
181
191
  ];
182
192
  const survivor = clearReport(join(projectRoot, CUCUMBER_REPORT));
@@ -186,6 +196,15 @@ async function runCucumberJs(projectRoot, filter) {
186
196
  });
187
197
  return finalize(run, projectRoot, CUCUMBER_REPORT, survivor);
188
198
  }
199
+ // Spec 56-1. On Next.js the World starts the application in dev mode inside
200
+ // the cucumber-js process, and a dev Next keeps its watchers open after
201
+ // `close()` — the process would sit there after the last scenario, until the
202
+ // timeout. `--exit` has cucumber-js end it once the report is written. Only on
203
+ // Next: anywhere else nothing is left running, and the flag would only hide a
204
+ // step that leaked a handle. The world probe passes the same flag.
205
+ export function cucumberJsExitArgs(projectRoot) {
206
+ return looksLikeNext(projectRoot) ? ['--exit'] : [];
207
+ }
189
208
  // Python: pytest driving pytest-bdd, with the connector's reporter plugin. The
190
209
  // plugin writes the JSON report; `-c` isolates the run from the project's own
191
210
  // addopts. The runner command is connector-owned.
@@ -4,6 +4,7 @@ import { executable } from "../proc.js";
4
4
  import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
5
5
  import { commandFileOnHost, locateRunner } from "./toolchain.js";
6
6
  import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
7
+ import { isRunnerFile } from "../files/packets.js";
7
8
  import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
8
9
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
9
10
  import { VITEST_BOOT_CONFIG_FILE, vitestBootConfigSource } from "./vitest.js";
@@ -279,7 +280,13 @@ async function withProbe(projectRoot, files, ask) {
279
280
  // `globals: true` in the config we write is what lets it be named without an
280
281
  // import (see `writeVitestBootConfig`).
281
282
  function vitestProbeSource(sourceFiles) {
282
- const imports = sourceFiles.map((file) => `import ${JSON.stringify(`../../${file}`)};`).join('\n');
283
+ // Belt and braces with `structuralSourceFiles`, which already left these
284
+ // out: a file vite cannot parse is our resolution falling short, never this
285
+ // project's code (spec 55-1, §1), and the pytest probe below has kept the
286
+ // same silence since spec 38.
287
+ const imports = sourceFiles
288
+ .filter((file) => isRunnerFile('vitest', file))
289
+ .map((file) => `import ${JSON.stringify(`../../${file}`)};`).join('\n');
283
290
  return `// Written by the unitbob connector before the boot check — do not edit.
284
291
  ${imports}
285
292
 
@@ -365,12 +372,27 @@ function classify(projectRoot, runner, result, verdict) {
365
372
  if (outcome === 'runner_could_not_answer')
366
373
  return { status: 'not_checked', reason: 'runner_could_not_answer' };
367
374
  const output = `${result.stdout}\n${result.stderr}`.trim();
368
- return {
369
- status: 'broken',
370
- cause: causeOf(output, projectRoot, runner),
371
- message: firstErrorLine(output),
372
- detail: output.length > DETAIL_LIMIT ? `${output.slice(0, DETAIL_LIMIT)}\n…` : output,
373
- };
375
+ const message = firstErrorLine(output);
376
+ const detail = output.length > DETAIL_LIMIT ? `${output.slice(0, DETAIL_LIMIT)}\n…` : output;
377
+ const file = foreignFileParsed(output, projectRoot, runner);
378
+ if (file)
379
+ return { status: 'broken', cause: 'harness', file, message, detail };
380
+ return { status: 'broken', cause: causeOf(output, projectRoot, runner), message, detail };
381
+ }
382
+ // Spec 55-1, §1. Vite's import-analysis stopped on a file this runner has no
383
+ // reading of — a `.py` the map named, say. That file got to vite through our
384
+ // list and nowhere else, so the answer is about the list. Only vite says this;
385
+ // the other runners never see such a file (their probes skip it) and their
386
+ // parse errors mean what they say.
387
+ const FOREIGN_PARSE = /vite:import-analysis|Failed to parse source for import analysis/;
388
+ const REPORTED_FILE = /^\s*File:\s*(\S+?)(?::\d+)*\s*$/m;
389
+ function foreignFileParsed(output, projectRoot, runner) {
390
+ if (runner !== 'vitest' || !FOREIGN_PARSE.test(output))
391
+ return null;
392
+ const file = REPORTED_FILE.exec(output)?.[1];
393
+ if (!file || isRunnerFile(runner, file))
394
+ return null;
395
+ return file.startsWith(`${projectRoot}/`) ? file.slice(projectRoot.length + 1) : file;
374
396
  }
375
397
  // One concept, not a catalogue of known errors — the lesson spec 32-2 drew from
376
398
  // the over-fitted regex scanner of 32-3. "A name that does not resolve to a file
@@ -468,7 +490,11 @@ const SOURCE_FRAME = /([\w.\-/\\]+\.(?:py|rb|ts|tsx|js|jsx|mjs|cjs)):\d+/g;
468
490
  // router), and one rule for quoting a failed load is better than two.
469
491
  export function firstErrorLine(output) {
470
492
  const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
471
- const looksLikeError = /(^|[^A-Za-z])(error|exception|traceback)|:\d+:in |^E\s|\(.*Error\)/i;
493
+ // `ReferenceError:`, `TypeError:` — a JavaScript error class is one word,
494
+ // so the letter before `Error` is not a boundary there. `⨯` is the glyph
495
+ // Next.js opens its own error lines with ("⨯ Another next dev server is
496
+ // already running").
497
+ const looksLikeError = /(^|[^A-Za-z])(error|exception|traceback)|:\d+:in |^E\s|\(.*Error\)|^\w*Error:|^\s*⨯ /i;
472
498
  return lines.find((line) => looksLikeError.test(line)) ?? lines[0] ?? 'the runner exited without output';
473
499
  }
474
500
  // The one repair this check performs. A test database is not a repository file