unitbob 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/cli.js +9 -1
- package/dist/config.js +37 -1
- package/dist/files/behavioral.js +210 -10
- package/dist/files/guardrails.js +1 -1
- package/dist/files/suiteBuild.js +1 -1
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/proc.js +122 -2
- package/dist/runner/bdd.js +126 -48
- package/dist/runner/bootcheck.js +32 -20
- package/dist/runner/docker.js +139 -0
- package/dist/runner/place.js +181 -0
- package/dist/runner/placeAdvice.js +52 -0
- package/dist/runner/placeEnvironment.js +111 -0
- package/dist/runner/precheck.js +35 -0
- package/dist/runner/provision.js +63 -29
- package/dist/runner/pytest.js +9 -10
- package/dist/runner/pytestBddPlugin.js +11 -3
- package/dist/runner/rspec.js +14 -12
- package/dist/runner/toolchain.js +49 -11
- package/dist/runner/types.js +45 -1
- package/dist/runner/vitest.js +9 -10
- package/dist/runner/worldProbe.js +26 -14
- package/dist/surfaces/routeInventory.js +14 -12
- package/dist/verbs/codexInstall.js +51 -16
- package/dist/verbs/mapPrepare.js +16 -5
- package/dist/verbs/putSuiteBuild.js +9 -2
- package/dist/verbs/run.js +10 -2
- package/dist/verbs/runLocal.js +17 -2
- package/dist/verbs/suitePrepare.js +101 -9
- package/dist/verbs/suiteReviewPrepare.js +14 -0
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
package/README.md
CHANGED
|
@@ -95,3 +95,40 @@ self-contained — that is what they are designed for.
|
|
|
95
95
|
- **Red lamp** — something the structure relied on broke. Copy its `id` and run
|
|
96
96
|
step 3.
|
|
97
97
|
- The project links itself by folder name — nothing to set up by hand.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## If your tests only run inside Docker
|
|
102
|
+
|
|
103
|
+
Some projects keep the code on this machine and everything that runs it — the
|
|
104
|
+
interpreter, the packages, the database — inside a container. Name that
|
|
105
|
+
container in `.unitbob.json` and Unitbob starts the project's own commands in
|
|
106
|
+
there:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "server": "…", "repo_id": 3, "token": "…",
|
|
110
|
+
"exec": { "docker": { "container": "myapp-web-1" } } }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Nothing else changes. Files are still read and written here, and the path inside
|
|
114
|
+
the container is worked out from the container's own mounts, so there is nothing
|
|
115
|
+
else to configure. Leave the field out and everything runs on this machine,
|
|
116
|
+
exactly as before.
|
|
117
|
+
|
|
118
|
+
The project folder has to be **mounted** into the container rather than copied
|
|
119
|
+
into the image — which it already is in any setup where you can edit a file and
|
|
120
|
+
see the change. If it is not, Unitbob says so and stops before writing anything.
|
|
121
|
+
|
|
122
|
+
Known limits of running in a container, all of them deliberate for now:
|
|
123
|
+
|
|
124
|
+
- **A run that times out can leave a process alive inside the container.** The
|
|
125
|
+
timeout stops the `docker exec`, not necessarily what it started. A report
|
|
126
|
+
such a process writes afterwards is never counted as a later run's result.
|
|
127
|
+
- **On a Linux host, files the container writes belong to `root`.** Unitbob does
|
|
128
|
+
not map users: guessing there breaks images that installed their packages as a
|
|
129
|
+
user of their own.
|
|
130
|
+
- **Only a container that is already running.** A project whose tests go through
|
|
131
|
+
`docker compose run --rm` is not supported yet.
|
|
132
|
+
- **Review at a fixed revision is not supported with a container.** It works in
|
|
133
|
+
a git worktree under the system's temporary directory, which is outside the
|
|
134
|
+
mount. Ordinary review works in the project itself and is unaffected.
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { existsSync, statSync } from 'node:fs';
|
|
12
12
|
import { resolve } from 'node:path';
|
|
13
13
|
import { ensureLinked } from "./link.js";
|
|
14
|
+
import { placeAdvice } from "./runner/placeAdvice.js";
|
|
15
|
+
import { ToolchainUnavailableError } from "./runner/toolchain.js";
|
|
14
16
|
import { recipe } from "./verbs/recipe.js";
|
|
15
17
|
import { show } from "./verbs/show.js";
|
|
16
18
|
import { run, runOnly } from "./verbs/run.js";
|
|
@@ -147,7 +149,13 @@ export async function main(argv, deps = { ensureLinked }) {
|
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
151
|
catch (err) {
|
|
150
|
-
|
|
152
|
+
// Spec 36, §7.1. One `catch` already stands over every verb, and every hard
|
|
153
|
+
// stop reaches it — but it also catches "the server did not answer" and
|
|
154
|
+
// "your token was refused", and advising somebody with a dead Heroku to
|
|
155
|
+
// configure a container is a new kind of useless message. So the advice is
|
|
156
|
+
// attached to one named stop and to nothing else.
|
|
157
|
+
const advice = err instanceof ToolchainUnavailableError ? placeAdvice(err.projectRoot) : null;
|
|
158
|
+
process.stderr.write(`${err.message}\n${advice ? `\n${advice}\n` : ''}`);
|
|
151
159
|
return 1;
|
|
152
160
|
}
|
|
153
161
|
}
|
package/dist/config.js
CHANGED
|
@@ -37,6 +37,23 @@ export function readLocalToken(cwd) {
|
|
|
37
37
|
const token = readConfigField(cwd, 'token');
|
|
38
38
|
return typeof token === 'string' && token.length > 0 ? token : null;
|
|
39
39
|
}
|
|
40
|
+
// The container this project's own processes run in, or null when they run on
|
|
41
|
+
// this machine (spec 36). One field, one word: the path inside the container is
|
|
42
|
+
// never asked for — it is read off the container's own mounts.
|
|
43
|
+
//
|
|
44
|
+
// "exec": { "docker": { "container": "source_code-web-1" } }
|
|
45
|
+
//
|
|
46
|
+
// Absent means local, and local means byte-for-byte the behaviour this connector
|
|
47
|
+
// has always had: no docker call, no new question, no new line of output.
|
|
48
|
+
export function readLocalExecContainer(cwd) {
|
|
49
|
+
const exec = readConfigField(cwd, 'exec');
|
|
50
|
+
const docker = record(exec)?.['docker'];
|
|
51
|
+
const container = record(docker)?.['container'];
|
|
52
|
+
return typeof container === 'string' && container.trim().length > 0 ? container.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
function record(value) {
|
|
55
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
56
|
+
}
|
|
40
57
|
function readConfigField(cwd, field) {
|
|
41
58
|
const path = join(cwd, CONFIG_FILE);
|
|
42
59
|
if (!existsSync(path))
|
|
@@ -73,6 +90,25 @@ export function locateLinkedRoot(cwd) {
|
|
|
73
90
|
return null;
|
|
74
91
|
}
|
|
75
92
|
}
|
|
93
|
+
// Write the link, and leave everything else in the file alone.
|
|
94
|
+
//
|
|
95
|
+
// It used to write exactly these three keys and nothing else, which quietly made
|
|
96
|
+
// every other key disposable — and this function runs on ordinary events, a
|
|
97
|
+
// re-link among them. Somebody who had written `exec` by hand would lose it
|
|
98
|
+
// while fixing something unrelated, and the loss says nothing about itself: the
|
|
99
|
+
// next run simply goes back to running on this machine.
|
|
76
100
|
export function writeConfigFile(cwd, config) {
|
|
77
|
-
|
|
101
|
+
const path = join(cwd, CONFIG_FILE);
|
|
102
|
+
let existing = {};
|
|
103
|
+
try {
|
|
104
|
+
const parsed = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
105
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
106
|
+
existing = parsed;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Unparseable, so there is nothing to keep. Writing the link is still the
|
|
110
|
+
// right thing to do — it is what the caller came here for, and the file was
|
|
111
|
+
// no use to anybody in the state it was in.
|
|
112
|
+
}
|
|
113
|
+
writeFileSync(path, `${JSON.stringify({ ...existing, ...config }, null, 2)}\n`);
|
|
78
114
|
}
|
package/dist/files/behavioral.js
CHANGED
|
@@ -10,6 +10,13 @@ import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
|
|
|
10
10
|
// blob only so `check` can execute it locally.
|
|
11
11
|
export const BEHAVIORAL_DIR = '.unitbob/behavioral';
|
|
12
12
|
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
|
+
// A level above "step_definitions/", and deliberately. pytest loads every
|
|
15
|
+
// conftest.py from the rootdir down to the directory it collects, so this one is
|
|
16
|
+
// loaded — and the host's own "step_definitions/conftest.py", which the step
|
|
17
|
+
// loading rules already promise them as the home for shared fixtures, stays
|
|
18
|
+
// theirs. Claiming that filename here would take it away.
|
|
19
|
+
const BEHAVIORAL_WORLD_PY_PATH = `${BEHAVIORAL_DIR}/conftest.py`;
|
|
13
20
|
// The Ruby/Cucumber harness is connector-owned. Host agents own business steps;
|
|
14
21
|
// this file owns only the stable Rails integration seam they build on.
|
|
15
22
|
export const BEHAVIORAL_WORLD = `# Generated by Unitbob. DO NOT EDIT: suite materialization restores this file.
|
|
@@ -57,6 +64,46 @@ end
|
|
|
57
64
|
|
|
58
65
|
World(UnitbobWorld)
|
|
59
66
|
|
|
67
|
+
# Cucumber loads neither spec/rails_helper.rb nor spec/support/**, so every
|
|
68
|
+
# switch this project's RSpec setup throws is still off here. Nothing said so:
|
|
69
|
+
# the behavioral branch just behaved differently from the structural one, and
|
|
70
|
+
# outgoing HTTP went to the real network while the structural branch had it
|
|
71
|
+
# blocked (spec 35-1).
|
|
72
|
+
#
|
|
73
|
+
# Only what is the same in every Rails application belongs below. Anything that
|
|
74
|
+
# depends on *this* application — signing in, factories, reading props, stubbing
|
|
75
|
+
# a payment provider — stays in host-owned shared steps, where it always was.
|
|
76
|
+
#
|
|
77
|
+
# And not one step definition, here or anywhere in this file. That is a rule with
|
|
78
|
+
# a mechanical reason: Cucumber loads every step file in the bundle into a single
|
|
79
|
+
# flat namespace, so a step defined here would not merely risk colliding with a
|
|
80
|
+
# worker's step — it would collide, and the run would stop on an ambiguous match
|
|
81
|
+
# rather than fail with something a reader can act on.
|
|
82
|
+
begin
|
|
83
|
+
require 'webmock/cucumber'
|
|
84
|
+
# Localhost stays reachable on purpose: a suite that talks to a local search or
|
|
85
|
+
# storage service is not the leak this closes, and blocking it would break runs
|
|
86
|
+
# that were never going out of the machine.
|
|
87
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
|
88
|
+
rescue LoadError
|
|
89
|
+
warn '[unitbob] webmock could not be loaded: outgoing HTTP from this suite reaches the real network.'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if defined?(Sidekiq)
|
|
93
|
+
begin
|
|
94
|
+
require 'sidekiq/testing'
|
|
95
|
+
Sidekiq::Testing.fake!
|
|
96
|
+
rescue LoadError
|
|
97
|
+
warn '[unitbob] sidekiq/testing could not be loaded: Sidekiq jobs in this suite are not in fake mode.'
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
ActiveJob::Base.queue_adapter = :test if defined?(ActiveJob::Base)
|
|
102
|
+
|
|
103
|
+
# The host the integration session already uses, so URL helpers agree with the
|
|
104
|
+
# requests the steps make instead of raising for a missing default.
|
|
105
|
+
Rails.application.routes.default_url_options[:host] ||= 'www.example.com'
|
|
106
|
+
|
|
60
107
|
Before do
|
|
61
108
|
@unitbob_time_zone = Time.zone
|
|
62
109
|
@unitbob_locale = I18n.locale
|
|
@@ -84,10 +131,159 @@ After do
|
|
|
84
131
|
raise mock_error if mock_error
|
|
85
132
|
end
|
|
86
133
|
`;
|
|
87
|
-
|
|
88
|
-
|
|
134
|
+
// The JS/TS peer of the file above. cucumber-js reads no test bootstrap of its
|
|
135
|
+
// own either — and the connector's explicit `--require` switches off even its
|
|
136
|
+
// own `features/support/` loading — so the branch had the same silence Ruby did.
|
|
137
|
+
//
|
|
138
|
+
// There is no cross-project equivalent of "Sidekiq in fake mode": a JavaScript
|
|
139
|
+
// project has no one job runner. What is the same everywhere is the network, and
|
|
140
|
+
// that is what this file settles.
|
|
141
|
+
const BEHAVIORAL_WORLD_JS = `// Generated by Unitbob. DO NOT EDIT: suite materialization restores this file.
|
|
142
|
+
'use strict';
|
|
143
|
+
|
|
144
|
+
// cucumber-js loads none of this project's own test bootstrap — not its Vitest
|
|
145
|
+
// setup files, and not \`features/support/\`, which the connector's explicit
|
|
146
|
+
// \`--require\` switches off. Whatever your test setup switches on is still off
|
|
147
|
+
// here, and on one Rails run the same silence sent live HTTP to the real network
|
|
148
|
+
// while the structural peer had it blocked (spec 35-1).
|
|
149
|
+
//
|
|
150
|
+
// Only what is the same in every JavaScript project belongs below. Anything that
|
|
151
|
+
// depends on *this* application — signing in, fixtures, seeding — stays in
|
|
152
|
+
// host-owned shared steps, where it always was.
|
|
153
|
+
//
|
|
154
|
+
// And not one step definition, here or anywhere in this file. That is a rule with
|
|
155
|
+
// a mechanical reason: cucumber-js registers every step in the bundle into a
|
|
156
|
+
// single flat namespace, so a step defined here would not merely risk colliding
|
|
157
|
+
// with a worker's step — it would collide, and the run would stop on an ambiguous
|
|
158
|
+
// match rather than fail with something a reader can act on.
|
|
159
|
+
const net = require('node:net');
|
|
160
|
+
|
|
161
|
+
// Localhost stays reachable on purpose: a suite that talks to a local database,
|
|
162
|
+
// search or storage service is not the leak this closes, and blocking it would
|
|
163
|
+
// break runs that were never leaving the machine.
|
|
164
|
+
function unitbobLeavesThisMachine(host) {
|
|
165
|
+
const name = String(host).replace(/^\\[|\\]$/g, '');
|
|
166
|
+
return !(name === 'localhost' || name === '::1' || name === '0.0.0.0' || name.startsWith('127.'));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Guarded at the socket, not at \`http.request\`: Node's own \`fetch\` opens its
|
|
170
|
+
// connections below that layer, so a guard one level up would have let the most
|
|
171
|
+
// modern way to call out be the one way that still worked. Mocking libraries
|
|
172
|
+
// (nock, msw) intercept above this and are unaffected.
|
|
173
|
+
// \`Socket.prototype.connect\` is called in more shapes than its documentation
|
|
174
|
+
// shows: \`(options)\`, \`(path)\`, \`(port, host)\`, and — from \`net.connect()\` and
|
|
175
|
+
// therefore from Node's own \`fetch\` — an already normalized \`[options, callback]\`
|
|
176
|
+
// array. Missing that last shape is not a narrower guard, it is no guard at all:
|
|
177
|
+
// the options land where a callback was expected, the host reads as undefined,
|
|
178
|
+
// and every outbound call is waved through.
|
|
179
|
+
function unitbobTarget(args) {
|
|
180
|
+
const first = Array.isArray(args[0]) ? args[0][0] : args[0];
|
|
181
|
+
// A unix socket path is not a network address at all.
|
|
182
|
+
if (typeof first === 'string') return { path: first };
|
|
183
|
+
if (first !== null && typeof first === 'object') return { path: first.path, host: first.host };
|
|
184
|
+
return { host: typeof args[1] === 'string' ? args[1] : undefined };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const unitbobConnect = net.Socket.prototype.connect;
|
|
188
|
+
net.Socket.prototype.connect = function unitbobGuardedConnect(...args) {
|
|
189
|
+
const { path, host } = unitbobTarget(args);
|
|
190
|
+
|
|
191
|
+
if (!path && unitbobLeavesThisMachine(host === undefined ? 'localhost' : host)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
'[unitbob] Refused a connection to ' + host + ': this suite must not reach the network. ' +
|
|
194
|
+
'Stub the boundary in your step definitions instead.',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return unitbobConnect.apply(this, args);
|
|
198
|
+
};
|
|
199
|
+
`;
|
|
200
|
+
// The Python peer. pytest reads the connector's own ini through \`-c\`, so the
|
|
201
|
+
// project's pytest settings do not apply, and only the conftest.py files on the
|
|
202
|
+
// path from the repository root down to the collected directory are loaded —
|
|
203
|
+
// which is to say a project's \`tests/conftest.py\` fixtures are not here either.
|
|
204
|
+
const BEHAVIORAL_WORLD_PY = `# Generated by Unitbob. DO NOT EDIT: suite materialization restores this file.
|
|
205
|
+
"""Connector-owned harness for the Unitbob behavioral suite.
|
|
206
|
+
|
|
207
|
+
pytest runs here against the connector's own config ("-c"), so this project's
|
|
208
|
+
pytest settings do not apply, and the only conftest.py files loaded are those on
|
|
209
|
+
the path from the repository root down to "step_definitions/" — a
|
|
210
|
+
"tests/conftest.py" is not one of them. Whatever your own test setup switches
|
|
211
|
+
on is therefore still off, and on one Rails run that same silence sent live HTTP
|
|
212
|
+
to the real network while the structural peer had it blocked (spec 35-1).
|
|
213
|
+
|
|
214
|
+
Only what is the same in every Python project belongs here. Anything that depends
|
|
215
|
+
on *this* application — signing in, factories, seeding — stays in host-owned
|
|
216
|
+
shared steps and in your own "step_definitions/conftest.py", which this file
|
|
217
|
+
deliberately does not claim.
|
|
218
|
+
|
|
219
|
+
No step definitions live here, on purpose: step definitions belong to the workers
|
|
220
|
+
that own their capability, and a shared one would collide with theirs.
|
|
221
|
+
"""
|
|
222
|
+
import socket
|
|
223
|
+
|
|
224
|
+
_UNITBOB_LOCAL = ("localhost", "::1", "0.0.0.0", "")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _unitbob_leaves_this_machine(address):
|
|
228
|
+
# A unix socket path is a string, not an address tuple, and is not a network
|
|
229
|
+
# address at all.
|
|
230
|
+
if not isinstance(address, tuple) or not address:
|
|
231
|
+
return False
|
|
232
|
+
host = str(address[0]).strip("[]")
|
|
233
|
+
# Localhost stays reachable on purpose: a suite that talks to a local
|
|
234
|
+
# database, search or storage service is not the leak this closes, and
|
|
235
|
+
# blocking it would break runs that were never leaving the machine.
|
|
236
|
+
return not (host in _UNITBOB_LOCAL or host.startswith("127."))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _unitbob_refuse(address):
|
|
240
|
+
return OSError(
|
|
241
|
+
"[unitbob] Refused a connection to %s: this suite must not reach the "
|
|
242
|
+
"network. Stub the boundary in your step definitions instead." % (address,)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Guarded at the socket, so it holds for requests, urllib3, httpx, aiohttp and
|
|
247
|
+
# anything else, rather than for the one HTTP client this file happened to know
|
|
248
|
+
# about. Mocking libraries (responses, respx) intercept above this and are
|
|
249
|
+
# unaffected.
|
|
250
|
+
_unitbob_connect = socket.socket.connect
|
|
251
|
+
_unitbob_connect_ex = socket.socket.connect_ex
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _unitbob_guarded_connect(self, address):
|
|
255
|
+
if _unitbob_leaves_this_machine(address):
|
|
256
|
+
raise _unitbob_refuse(address)
|
|
257
|
+
return _unitbob_connect(self, address)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _unitbob_guarded_connect_ex(self, address):
|
|
261
|
+
if _unitbob_leaves_this_machine(address):
|
|
262
|
+
raise _unitbob_refuse(address)
|
|
263
|
+
return _unitbob_connect_ex(self, address)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
socket.socket.connect = _unitbob_guarded_connect
|
|
267
|
+
socket.socket.connect_ex = _unitbob_guarded_connect_ex
|
|
268
|
+
`;
|
|
269
|
+
// One connector-owned harness per BDD runner, or nothing for a runner that has
|
|
270
|
+
// none. Kept as one table so a fourth runner cannot be added with a step-loading
|
|
271
|
+
// rule and no harness — the two halves of "what this branch runs inside".
|
|
272
|
+
const BEHAVIORAL_WORLDS = {
|
|
273
|
+
cucumber: { path: BEHAVIORAL_WORLD_PATH, content: BEHAVIORAL_WORLD },
|
|
274
|
+
'cucumber-js': { path: BEHAVIORAL_WORLD_JS_PATH, content: BEHAVIORAL_WORLD_JS },
|
|
275
|
+
'pytest-bdd': { path: BEHAVIORAL_WORLD_PY_PATH, content: BEHAVIORAL_WORLD_PY },
|
|
276
|
+
};
|
|
277
|
+
export function behavioralWorldFor(runner) {
|
|
278
|
+
return BEHAVIORAL_WORLDS[runner];
|
|
279
|
+
}
|
|
280
|
+
export function materializeBehavioralWorld(projectRoot, runner = 'cucumber') {
|
|
281
|
+
const world = behavioralWorldFor(runner);
|
|
282
|
+
if (!world)
|
|
283
|
+
return null;
|
|
284
|
+
const worldPath = join(projectRoot, world.path);
|
|
89
285
|
mkdirSync(dirname(worldPath), { recursive: true });
|
|
90
|
-
writeFileSync(worldPath,
|
|
286
|
+
writeFileSync(worldPath, world.content);
|
|
91
287
|
return worldPath;
|
|
92
288
|
}
|
|
93
289
|
// Write a behavioral artifact envelope (main file + support files) under the
|
|
@@ -98,8 +294,9 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
|
|
|
98
294
|
const files = [artifact, ...(artifact.support_files ?? [])];
|
|
99
295
|
for (const file of files)
|
|
100
296
|
assertUnitbobPath(file.path, BEHAVIORAL_DIR);
|
|
101
|
-
|
|
102
|
-
|
|
297
|
+
const world = behavioralWorldFor(runner);
|
|
298
|
+
if (world && files.some((file) => file.path === world.path)) {
|
|
299
|
+
throw new Error(`${world.path} is the connector-owned World and cannot be supplied by the host artifact.`);
|
|
103
300
|
}
|
|
104
301
|
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
105
302
|
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
@@ -117,9 +314,7 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
|
|
|
117
314
|
if (file === artifact)
|
|
118
315
|
mainPath = dest;
|
|
119
316
|
}
|
|
120
|
-
|
|
121
|
-
materializeBehavioralWorld(projectRoot);
|
|
122
|
-
}
|
|
317
|
+
materializeBehavioralWorld(projectRoot, runner);
|
|
123
318
|
return { mainPath };
|
|
124
319
|
}
|
|
125
320
|
// Everything under the behavioral root that the next materialization will
|
|
@@ -136,10 +331,11 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
|
|
|
136
331
|
const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
|
|
137
332
|
if (!existsSync(behavioralRoot))
|
|
138
333
|
return [];
|
|
334
|
+
const connectorWorld = behavioralWorldFor(runner);
|
|
139
335
|
const listed = new Set([
|
|
140
336
|
artifact.path,
|
|
141
337
|
...(artifact.support_files ?? []).map((file) => file.path),
|
|
142
|
-
...(
|
|
338
|
+
...(connectorWorld ? [connectorWorld.path] : []),
|
|
143
339
|
]);
|
|
144
340
|
const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
|
|
145
341
|
return readdirSync(behavioralRoot)
|
|
@@ -178,7 +374,11 @@ export function copyBehavioralRunnerEnvironment(sourceRoot, targetRoot, runner)
|
|
|
178
374
|
// look like noise. Taken from the runner that writes them, never re-typed here.
|
|
179
375
|
const CONNECTOR_RUN_ARTIFACTS = new Set(BDD_RUN_ARTIFACTS);
|
|
180
376
|
const EMPTY_ENTRIES = new Set();
|
|
181
|
-
|
|
377
|
+
// What under the behavioral root is an *installed environment* rather than
|
|
378
|
+
// generated text. Exported since spec 36: when the place a run happens in
|
|
379
|
+
// changes, exactly these entries are thrown away so they can be installed again,
|
|
380
|
+
// and the generated suite sitting beside them is left untouched.
|
|
381
|
+
export const RUNNER_ENVIRONMENT_ENTRIES = {
|
|
182
382
|
cucumber: new Set(['.bundle', 'Gemfile', 'Gemfile.lock']),
|
|
183
383
|
'cucumber-js': new Set(['node_modules', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']),
|
|
184
384
|
'pytest-bdd': new Set(['.venv']),
|
package/dist/files/guardrails.js
CHANGED
|
@@ -67,7 +67,7 @@ abort 'unitbob_helper: refusing to run against a non-test environment' unless Ra
|
|
|
67
67
|
// runs need no connector-written support files here (the runtime pytest.ini
|
|
68
68
|
// lives outside this directory and is written by the pytest runner).
|
|
69
69
|
//
|
|
70
|
-
// Every file, not just the main one (spec
|
|
70
|
+
// Every file, not just the main one (spec 43, §6.4). The directory is wiped
|
|
71
71
|
// first and only the main file was written back, so a published suite of four
|
|
72
72
|
// files came back as one and the run that followed it silently protected a
|
|
73
73
|
// quarter of what the map claimed.
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -83,7 +83,7 @@ export function branchRunner(output) {
|
|
|
83
83
|
return runner;
|
|
84
84
|
}
|
|
85
85
|
// What the reviewer actually read: the suite files, and the manifest that runs
|
|
86
|
-
// them. Nothing else (spec
|
|
86
|
+
// them. Nothing else (spec 43, §4).
|
|
87
87
|
//
|
|
88
88
|
// `test_metadata` used to be in here, and the server's copy of this formula
|
|
89
89
|
// stripped the review's own keys back out to match — two lists that had to stay
|
|
@@ -2,7 +2,7 @@ import { readBehavioralReview } from "./suiteBuild.js";
|
|
|
2
2
|
// What travels to the server, and what "published" means when it answers. One
|
|
3
3
|
// module, because two commands ask those questions: `put-suite-build` sends the
|
|
4
4
|
// batch, and `validate-build` sends the same batch as a dry run so the server's
|
|
5
|
-
// verdict is about the exact bytes the publish will carry (spec
|
|
5
|
+
// verdict is about the exact bytes the publish will carry (spec 43, §3).
|
|
6
6
|
//
|
|
7
7
|
// A second assembly would be a second answer to "what are we uploading", and the
|
|
8
8
|
// dry run would then be checking something the publish does not send — which is
|
package/dist/proc.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// captures stdout/stderr/exit code and hands them back untouched — shaping or
|
|
3
3
|
// interpreting that output is the caller's (and ultimately Rails') job.
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
// Can this path actually be spawned? A binstub that exists but has lost its
|
|
8
8
|
// executable bit is a real state — checkouts over a filesystem with no
|
|
@@ -19,6 +19,15 @@ export function executable(path) {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
export const GRAPHIFY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
22
|
+
// The raw spawn, and it stays raw: this is how the connector starts the tools it
|
|
23
|
+
// brought with it. `graphify` is installed on the vibecoder's own machine and
|
|
24
|
+
// only ever reads files, so it has no business travelling anywhere.
|
|
25
|
+
//
|
|
26
|
+
// A command that needs the *project's* dependencies goes through
|
|
27
|
+
// `runInProject` (`runner/place.ts`) instead — those may have to start where the
|
|
28
|
+
// project's toolchain lives, which is not always this machine (spec 36). The
|
|
29
|
+
// boundary is deliberately visible at each call site rather than hidden in a
|
|
30
|
+
// mode: which function is called is the whole rule.
|
|
22
31
|
export function runProcess(command, args = [], options = {}) {
|
|
23
32
|
return new Promise((resolve, reject) => {
|
|
24
33
|
const child = spawn(command, args, { cwd: options.cwd, env: options.env });
|
|
@@ -93,7 +102,14 @@ export const GRAPH_NOISE_PATTERNS = [
|
|
|
93
102
|
// apps dumped libraries — on the measured app that folder was moment.js,
|
|
94
103
|
// datatables.js and jquery.inputmask, against a single node of own code. A
|
|
95
104
|
// modern Rails app keeps its own JS in `app/javascript/`, which stays.
|
|
96
|
-
|
|
105
|
+
//
|
|
106
|
+
// Anchored to the repository root, and this is the whole difference between a
|
|
107
|
+
// pattern and a blind spot: a gitignore pattern whose only separator is the
|
|
108
|
+
// trailing one is *not* relative to the root, so a bare `vendor/` matched a
|
|
109
|
+
// `vendor` directory at any depth. On one real Rails app (2026-08-16) that
|
|
110
|
+
// took `app/controllers/vendor/` with it — the project's own contractor
|
|
111
|
+
// console, gone from the map for a whole run, with nothing said about it.
|
|
112
|
+
'/vendor/',
|
|
97
113
|
'app/assets/javascripts/',
|
|
98
114
|
'app/assets/builds/',
|
|
99
115
|
'app/assets/config/',
|
|
@@ -135,8 +151,25 @@ export function ensureUnitbobIgnored(projectRoot) {
|
|
|
135
151
|
// `.graphifyignore` is unitbob's own bookkeeping, like the other two entries —
|
|
136
152
|
// the user never edits it, so it stays out of their commits.
|
|
137
153
|
ensureLines(join(projectRoot, '.gitignore'), ['.unitbob/', 'graphify-out/', '.graphifyignore']);
|
|
154
|
+
// Before `ensureLines`, never after. `ensureLines` appends whatever the
|
|
155
|
+
// template is missing, so on an already-installed project the anchored form
|
|
156
|
+
// would land in the file *next to* the old unanchored one, and the old one
|
|
157
|
+
// would go on eating `app/**/vendor/` exactly as before.
|
|
158
|
+
replaceLine(join(projectRoot, '.graphifyignore'), 'vendor/', '/vendor/');
|
|
138
159
|
ensureLines(join(projectRoot, '.graphifyignore'), GRAPH_NOISE_PATTERNS);
|
|
139
160
|
}
|
|
161
|
+
// Rewrite one line this connector wrote in an earlier release, and nothing else.
|
|
162
|
+
// The comparison is exact on the trimmed line, so a line the user wrote —
|
|
163
|
+
// `vendor/bundle/`, `# vendor`, anything else — is left byte for byte as they
|
|
164
|
+
// wrote it. Idempotent: a second run finds nothing to replace.
|
|
165
|
+
function replaceLine(path, from, to) {
|
|
166
|
+
if (!existsSync(path))
|
|
167
|
+
return;
|
|
168
|
+
const lines = readFileSync(path, 'utf8').split('\n');
|
|
169
|
+
if (!lines.some((line) => line.trim() === from))
|
|
170
|
+
return;
|
|
171
|
+
writeFileSync(path, lines.map((line) => (line.trim() === from ? to : line)).join('\n'));
|
|
172
|
+
}
|
|
140
173
|
// Appends whichever lines are missing, in one write, leaving the user's own
|
|
141
174
|
// entries (and their order) untouched. Idempotent: a second run adds nothing.
|
|
142
175
|
function ensureLines(path, lines) {
|
|
@@ -150,6 +183,93 @@ function ensureLines(path, lines) {
|
|
|
150
183
|
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
151
184
|
writeFileSync(path, `${current}${prefix}${missing.join('\n')}\n`);
|
|
152
185
|
}
|
|
186
|
+
// What the ignore file actually costs, counted in files, pattern by pattern.
|
|
187
|
+
//
|
|
188
|
+
// This is the general cure and the reason it is worth more than the particular
|
|
189
|
+
// one above: an ignore pattern is a silent instrument. Everything it matches
|
|
190
|
+
// simply never reaches the graph, and the subsystem it swallowed leaves no trace
|
|
191
|
+
// of having existed — which is how one over-broad line hid a whole console and
|
|
192
|
+
// the run looked complete. Anchoring `/vendor/` fixes the blind spot we found;
|
|
193
|
+
// this makes the next one visible, whichever pattern causes it.
|
|
194
|
+
//
|
|
195
|
+
// The whole file is read, not just this connector's own template: a line the
|
|
196
|
+
// user wrote can hide a subsystem exactly as well as a line we wrote.
|
|
197
|
+
export function ignoreExclusions(projectRoot) {
|
|
198
|
+
const path = join(projectRoot, '.graphifyignore');
|
|
199
|
+
if (!existsSync(path))
|
|
200
|
+
return [];
|
|
201
|
+
const counted = readFileSync(path, 'utf8')
|
|
202
|
+
.split('\n')
|
|
203
|
+
.flatMap((line) => {
|
|
204
|
+
const matcher = compileIgnorePattern(line);
|
|
205
|
+
return matcher ? [{ pattern: line.trim(), matcher, files: 0 }] : [];
|
|
206
|
+
});
|
|
207
|
+
if (counted.length === 0)
|
|
208
|
+
return [];
|
|
209
|
+
for (const file of filesUnder(projectRoot)) {
|
|
210
|
+
// Every pattern that matches is credited, not just the first: two patterns
|
|
211
|
+
// covering the same directory are each costing you those files, and picking
|
|
212
|
+
// a winner would report one of them as harmless.
|
|
213
|
+
for (const entry of counted) {
|
|
214
|
+
if (matchesIgnorePattern(file, entry.matcher))
|
|
215
|
+
entry.files += 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return counted.filter((entry) => entry.files > 0).map(({ pattern, files }) => ({ pattern, files }));
|
|
219
|
+
}
|
|
220
|
+
// The working subset of gitignore syntax — the part the patterns in this file
|
|
221
|
+
// actually use. The rule that matters is the anchoring one: a pattern with a
|
|
222
|
+
// separator anywhere but the end is relative to the repository root, and one
|
|
223
|
+
// without is not. That single rule is the entire distance between `/vendor/`
|
|
224
|
+
// and `vendor/`, so it is spelled out here rather than assumed.
|
|
225
|
+
//
|
|
226
|
+
// Negations (`!`) are not supported and are skipped rather than half-honoured:
|
|
227
|
+
// counting a re-include as an exclusion would report a loss that never happened.
|
|
228
|
+
function compileIgnorePattern(line) {
|
|
229
|
+
const trimmed = line.trim();
|
|
230
|
+
if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith('!'))
|
|
231
|
+
return null;
|
|
232
|
+
const dirOnly = trimmed.endsWith('/');
|
|
233
|
+
const path = dirOnly ? trimmed.slice(0, -1) : trimmed;
|
|
234
|
+
const anchored = path.includes('/');
|
|
235
|
+
const source = path.replace(/^\//, '').split('/').map(globSegment).join('/');
|
|
236
|
+
return { dirOnly, regex: new RegExp(anchored ? `^${source}$` : `^(?:.*/)?${source}$`) };
|
|
237
|
+
}
|
|
238
|
+
function globSegment(segment) {
|
|
239
|
+
return segment
|
|
240
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
241
|
+
.replace(/\*/g, '[^/]*')
|
|
242
|
+
.replace(/\?/g, '[^/]');
|
|
243
|
+
}
|
|
244
|
+
function matchesIgnorePattern(relativePath, matcher) {
|
|
245
|
+
const parts = relativePath.split('/');
|
|
246
|
+
// A pattern ending in `/` matches directories only, so for a file it is the
|
|
247
|
+
// ancestors that have to match and never the file itself. Excluding a
|
|
248
|
+
// directory excludes everything under it, which is why every prefix is tried.
|
|
249
|
+
const deepest = matcher.dirOnly ? parts.length - 1 : parts.length;
|
|
250
|
+
for (let depth = 1; depth <= deepest; depth += 1) {
|
|
251
|
+
if (matcher.regex.test(parts.slice(0, depth).join('/')))
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
// Directories graphify drops on its own (see the note above
|
|
257
|
+
// `GRAPH_NOISE_PATTERNS`), plus `.git`. Counting inside them would charge our
|
|
258
|
+
// patterns for files that were never going to reach the graph anyway, and the
|
|
259
|
+
// number the reader is weighing is "what did this line cost me".
|
|
260
|
+
const ALREADY_OFF_THE_GRAPH = new Set([
|
|
261
|
+
'.git', 'node_modules', 'venv', '.venv', 'dist', 'build', 'target', 'out', '__pycache__',
|
|
262
|
+
]);
|
|
263
|
+
// Symlinks are neither followed nor counted: a link is not a file the graph
|
|
264
|
+
// would have gained, and following one can walk forever.
|
|
265
|
+
function filesUnder(root, relative = '') {
|
|
266
|
+
return readdirSync(join(root, relative), { withFileTypes: true }).flatMap((entry) => {
|
|
267
|
+
const path = relative ? `${relative}/${entry.name}` : entry.name;
|
|
268
|
+
if (entry.isDirectory())
|
|
269
|
+
return ALREADY_OFF_THE_GRAPH.has(entry.name) ? [] : filesUnder(root, path);
|
|
270
|
+
return entry.isFile() ? [path] : [];
|
|
271
|
+
});
|
|
272
|
+
}
|
|
153
273
|
export async function runGraphifyExtractKeyless(projectRoot) {
|
|
154
274
|
// Deterministic AST-only graph; no LLM, no API key. `update --force` re-extracts
|
|
155
275
|
// the code and refreshes <root>/graphify-out/graph.json in place, replacing
|