unitbob 0.5.1 → 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 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
- process.stderr.write(`${err.message}\n`);
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
- writeFileSync(join(cwd, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
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
  }
@@ -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
- export function materializeBehavioralWorld(projectRoot) {
88
- const worldPath = join(projectRoot, BEHAVIORAL_WORLD_PATH);
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, BEHAVIORAL_WORLD);
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
- if (files.some((file) => file.path === BEHAVIORAL_WORLD_PATH)) {
102
- throw new Error(`${BEHAVIORAL_WORLD_PATH} is the connector-owned World and cannot be supplied by the host artifact.`);
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
- if (runner === 'cucumber') {
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
- ...(runner === 'cucumber' ? [BEHAVIORAL_WORLD_PATH] : []),
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
- const RUNNER_ENVIRONMENT_ENTRIES = {
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']),
@@ -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 42, §6.4). The directory is wiped
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.
@@ -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 42, §4).
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 42, §3).
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
- 'vendor/',
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