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 +3 -3
- package/dist/cli.js +7 -3
- package/dist/files/behavioral.js +188 -4
- package/dist/files/packets.js +39 -5
- package/dist/files/workerPlan.js +81 -14
- package/dist/runner/bdd.js +19 -0
- package/dist/runner/bootcheck.js +34 -8
- package/dist/runner/worldProbe.js +100 -3
- package/dist/surfaces/nextRoutes.js +133 -0
- package/dist/surfaces/routeInventory.js +76 -21
- package/dist/verbs/acceptWorkerPlan.js +0 -0
- package/dist/verbs/putMapBuild.js +1 -1
- package/dist/verbs/suitePrepare.js +41 -25
- package/dist/verbs/validateWorkerCheckpoints.js +228 -72
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +5 -2
- package/plugin/codex/agents/suite-reviewer.toml +4 -0
- package/plugin/codex/agents/suite-worker.toml +21 -10
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.
|
|
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>` |
|
|
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
|
-
|
|
158
|
-
|
|
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':
|
package/dist/files/behavioral.js
CHANGED
|
@@ -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
|
-
|
|
278
|
-
|
|
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);
|
package/dist/files/packets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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`).
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
-
|
|
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
|
|
186
|
+
return null;
|
|
121
187
|
try {
|
|
122
188
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
123
|
-
return parsed
|
|
189
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
124
190
|
}
|
|
125
191
|
catch {
|
|
126
|
-
return
|
|
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
|
package/dist/runner/bdd.js
CHANGED
|
@@ -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.
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|