unitbob 0.4.5 → 0.5.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/dist/files/guardrails.js +19 -9
- package/dist/files/suiteBuild.js +16 -1
- package/dist/files/suiteBuildUpload.js +71 -0
- package/dist/files/workerPlan.js +20 -3
- package/dist/runner/bootcheck.js +47 -9
- package/dist/runner/precheck.js +104 -36
- package/dist/runner/provision.js +326 -26
- package/dist/runner/pytest.js +25 -20
- package/dist/runner/rspec.js +19 -11
- package/dist/runner/toolchain.js +122 -0
- package/dist/runner/vitest.js +64 -30
- package/dist/surfaces/routeInventory.js +15 -3
- package/dist/verbs/codexInstall.js +1 -1
- package/dist/verbs/putSuiteBuild.js +32 -75
- package/dist/verbs/run.js +15 -6
- package/dist/verbs/runLocal.js +19 -7
- package/dist/verbs/suitePrepare.js +43 -7
- package/dist/verbs/validateBuild.js +140 -456
- package/dist/wire.js +21 -3
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
- package/plugin/codex/agents/suite-reviewer.toml +157 -0
- package/plugin/codex/agents/suite-worker.toml +9 -3
package/dist/files/guardrails.js
CHANGED
|
@@ -62,21 +62,31 @@ else
|
|
|
62
62
|
end
|
|
63
63
|
abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
|
|
64
64
|
`;
|
|
65
|
-
// Write the suite blob
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
65
|
+
// Write every file of the suite blob at its own (validated) relative path. The
|
|
66
|
+
// Ruby boot kit is materialized only for the rspec runner — Vitest and pytest
|
|
67
|
+
// runs need no connector-written support files here (the runtime pytest.ini
|
|
68
|
+
// lives outside this directory and is written by the pytest runner).
|
|
69
|
+
//
|
|
70
|
+
// Every file, not just the main one (spec 42, §6.4). The directory is wiped
|
|
71
|
+
// first and only the main file was written back, so a published suite of four
|
|
72
|
+
// files came back as one and the run that followed it silently protected a
|
|
73
|
+
// quarter of what the map claimed.
|
|
69
74
|
export function materializeGuardrails(projectRoot, suite) {
|
|
70
|
-
|
|
75
|
+
const files = [suite.suite_file, ...(suite.suite_file.support_files ?? [])];
|
|
76
|
+
for (const file of files)
|
|
77
|
+
assertGuardrailPath(file.path);
|
|
71
78
|
const dir = join(projectRoot, GUARDRAILS_DIR);
|
|
72
79
|
rmSync(dir, { recursive: true, force: true });
|
|
73
80
|
mkdirSync(dir, { recursive: true });
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
const written = files.map((file) => {
|
|
82
|
+
const path = join(projectRoot, file.path);
|
|
83
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
+
writeFileSync(path, file.content);
|
|
85
|
+
return path;
|
|
86
|
+
});
|
|
77
87
|
if (suite.runner_manifest.runner === 'rspec')
|
|
78
88
|
materializeHelper(projectRoot);
|
|
79
|
-
return { suitePath };
|
|
89
|
+
return { suitePath: written[0], supportPaths: written.slice(1) };
|
|
80
90
|
}
|
|
81
91
|
// Both Ruby flows boot the same way: the check flow writes the boot kit next to
|
|
82
92
|
// the suite here, the suite-build flow writes it right after the precheck.
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -82,12 +82,27 @@ export function branchRunner(output) {
|
|
|
82
82
|
}
|
|
83
83
|
return runner;
|
|
84
84
|
}
|
|
85
|
+
// What the reviewer actually read: the suite files, and the manifest that runs
|
|
86
|
+
// them. Nothing else (spec 42, §4).
|
|
87
|
+
//
|
|
88
|
+
// `test_metadata` used to be in here, and the server's copy of this formula
|
|
89
|
+
// stripped the review's own keys back out to match — two lists that had to stay
|
|
90
|
+
// identical for ever or every upload would break. The real cost was elsewhere,
|
|
91
|
+
// though: editing metadata declared the review stale. On noahsat-web,
|
|
92
|
+
// 2026-08-12 the reviewer was right that the steps drive only `PATCH`, the fix
|
|
93
|
+
// moved three `PUT` aliases into a deferred list, not one byte of the suite
|
|
94
|
+
// moved — and the run still paid for re-binding the candidate and a second
|
|
95
|
+
// reviewer pass, the most expensive step of the whole recipe, to satisfy the
|
|
96
|
+
// reviewer's own finding.
|
|
97
|
+
//
|
|
98
|
+
// `stableJson` sorts object keys and does nothing else. The server's
|
|
99
|
+
// `canonical_json` does the same, which is the only reason the two sides agree;
|
|
100
|
+
// a normalization added on one side alone would break every upload.
|
|
85
101
|
export function suiteCandidateDigest(output) {
|
|
86
102
|
return createHash('sha256')
|
|
87
103
|
.update(stableJson({
|
|
88
104
|
suite_file: output.suite_file,
|
|
89
105
|
runner_manifest: output.runner_manifest,
|
|
90
|
-
test_metadata: output.test_metadata,
|
|
91
106
|
}))
|
|
92
107
|
.digest('hex');
|
|
93
108
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readBehavioralReview } from "./suiteBuild.js";
|
|
2
|
+
// What travels to the server, and what "published" means when it answers. One
|
|
3
|
+
// module, because two commands ask those questions: `put-suite-build` sends the
|
|
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).
|
|
6
|
+
//
|
|
7
|
+
// A second assembly would be a second answer to "what are we uploading", and the
|
|
8
|
+
// dry run would then be checking something the publish does not send — which is
|
|
9
|
+
// worth less than not checking at all, because it reads as a verdict.
|
|
10
|
+
// The three outcomes that leave a branch published and current: a new version, an
|
|
11
|
+
// identical version already stored, or a reactivated one. Each returns the
|
|
12
|
+
// identity to run. Everything else — a rejected branch, a branch the host could
|
|
13
|
+
// not build, or a status this connector has never seen — fails closed and is
|
|
14
|
+
// never run, so a newer server can never trick an older connector into running
|
|
15
|
+
// something it does not understand.
|
|
16
|
+
export const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
|
|
17
|
+
// The answer a dry run gives to a branch it would accept. A server that does not
|
|
18
|
+
// know `dry_run` answers one of `PUBLISHED` instead — which means it published —
|
|
19
|
+
// and `validate-build` says so rather than reporting a check that passed.
|
|
20
|
+
export const WOULD_PUBLISH = 'would_publish';
|
|
21
|
+
// One branch, as the upload sends it. `source_digest` comes from the request,
|
|
22
|
+
// never from the host's answer, so the host cannot claim a different map than
|
|
23
|
+
// the branch was given.
|
|
24
|
+
export function uploadItem(request, output, testMetadata) {
|
|
25
|
+
const sourceDigest = request.branches.find((branch) => branch.suite_kind === output.suite_kind)?.source_digest ?? '';
|
|
26
|
+
if (output.build_error) {
|
|
27
|
+
return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
suite_kind: output.suite_kind,
|
|
31
|
+
source_digest: sourceDigest,
|
|
32
|
+
artifacts: {
|
|
33
|
+
suite_file: output.suite_file,
|
|
34
|
+
runner_manifest: output.runner_manifest,
|
|
35
|
+
test_metadata: testMetadata,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
// The behavioral branch's uploaded metadata, with the independent review and the
|
|
40
|
+
// connector's own run evidence folded in.
|
|
41
|
+
//
|
|
42
|
+
// Throws for anything that leaves this branch unpublishable — a missing review,
|
|
43
|
+
// one bound to a different candidate, a defect the review called not_supplied.
|
|
44
|
+
// The caller turns that into one unpublished branch rather than a failed
|
|
45
|
+
// command: a blocked review is a fact about the behavioral suite, and the
|
|
46
|
+
// structural peer next to it is finished and correct. Sinking the whole upload
|
|
47
|
+
// with it forced the one workaround this contract exists to prevent — hand-editing
|
|
48
|
+
// the answer down to a single branch, which loses the peer candidate for real.
|
|
49
|
+
export function withReview(config, request, output) {
|
|
50
|
+
const review = readBehavioralReview(config.projectRoot, output);
|
|
51
|
+
const probe = review.known_defect_probe;
|
|
52
|
+
const qualityReview = review.bdd_quality_review;
|
|
53
|
+
if (!qualityReview || typeof qualityReview !== 'object') {
|
|
54
|
+
throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
|
|
55
|
+
}
|
|
56
|
+
if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
|
|
57
|
+
throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
...output.test_metadata,
|
|
61
|
+
bdd_quality_review: {
|
|
62
|
+
...qualityReview,
|
|
63
|
+
candidate_digest: review.candidate_digest,
|
|
64
|
+
},
|
|
65
|
+
...(review.selection_review ? { selection_review: review.selection_review } : {}),
|
|
66
|
+
known_defect_probe: review.known_defect_probe,
|
|
67
|
+
known_defect_context: request.known_defect_context,
|
|
68
|
+
candidate_run: review.candidate_run,
|
|
69
|
+
...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/files/workerPlan.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
4
5
|
import { assertUnitbobPath } from "./artifactPath.js";
|
|
5
6
|
export function workerPlanPath(projectRoot) {
|
|
6
7
|
return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
|
|
@@ -33,8 +34,13 @@ export function readWorkerPlan(projectRoot) {
|
|
|
33
34
|
}
|
|
34
35
|
return parsed;
|
|
35
36
|
}
|
|
37
|
+
const RUBY_HARNESS = {
|
|
38
|
+
behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
|
|
39
|
+
structural: '.unitbob/structural/unitbob_helper.rb',
|
|
40
|
+
};
|
|
36
41
|
export function validateWorkerPlanFiles(projectRoot) {
|
|
37
42
|
const errors = [];
|
|
43
|
+
const rubyProject = detectStructuralRunner(projectRoot) === 'rspec';
|
|
38
44
|
const plan = readWorkerPlan(projectRoot);
|
|
39
45
|
let request;
|
|
40
46
|
try {
|
|
@@ -86,12 +92,23 @@ export function validateWorkerPlanFiles(projectRoot) {
|
|
|
86
92
|
errors.push(`${label}: done_when must be non-empty`);
|
|
87
93
|
if (!isNonEmptyString(item?.harness_path))
|
|
88
94
|
errors.push(`${label}: harness_path must be non-empty`);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
// Both connector-owned harness files are Ruby, and only a Ruby project has
|
|
96
|
+
// them: `unitbob_helper.rb` boots Rails for RSpec, and the behavioral World
|
|
97
|
+
// is materialized for cucumber alone. Demanding them everywhere refused
|
|
98
|
+
// every Python and JS plan over a file that does not exist and would mean
|
|
99
|
+
// nothing if it did — the same mistake as the stack gate that reported a
|
|
100
|
+
// Python project as no stack at all. Found 2026-08-12.
|
|
101
|
+
//
|
|
102
|
+
// What is required of the other stacks is what the rule was ever about: the
|
|
103
|
+
// harness is connector territory, under `.unitbob/`, not a file in the
|
|
104
|
+
// project.
|
|
105
|
+
const expectedHarness = rubyProject ? RUBY_HARNESS[item?.branch] ?? null : null;
|
|
92
106
|
if (expectedHarness && item.harness_path !== expectedHarness) {
|
|
93
107
|
errors.push(`${label}: harness_path must name the connector-owned ${expectedHarness}`);
|
|
94
108
|
}
|
|
109
|
+
if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
|
|
110
|
+
errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
|
|
111
|
+
}
|
|
95
112
|
if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
|
|
96
113
|
errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
|
|
97
114
|
}
|
package/dist/runner/bootcheck.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { executable, runProcess } from "../proc.js";
|
|
4
|
+
import { locateRunner } from "./toolchain.js";
|
|
4
5
|
import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
|
|
5
6
|
import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
|
|
6
7
|
import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
@@ -86,9 +87,14 @@ async function loadRubyHelper(projectRoot, helper, deps) {
|
|
|
86
87
|
// and the global `bundle` was standing right there the whole time.
|
|
87
88
|
const localBundle = join(projectRoot, 'bin', 'bundle');
|
|
88
89
|
const command = executable(localBundle) ? localBundle : 'bundle';
|
|
90
|
+
// When Unitbob installed rspec-rails for itself, the gems this helper needs
|
|
91
|
+
// are resolved by the sidecar Gemfile, not the project's. Asking bundler
|
|
92
|
+
// without that variable would load a different set of gems than the run does,
|
|
93
|
+
// which is exactly the way a check ends up predicting the wrong thing.
|
|
94
|
+
const located = locateRunner(projectRoot, 'rspec');
|
|
89
95
|
return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
|
|
90
96
|
cwd: projectRoot,
|
|
91
|
-
env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
97
|
+
env: { ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
92
98
|
}),
|
|
93
99
|
// A clean load says nothing on stdout and exits 0. Anything else is the
|
|
94
100
|
// suite failing to start.
|
|
@@ -110,10 +116,25 @@ async function pytestBootCheck(projectRoot, deps) {
|
|
|
110
116
|
// first: this check must not fail because a different step was skipped.
|
|
111
117
|
mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
|
|
112
118
|
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
// The same pytest the run will use, resolved once in `locateRunner` — the
|
|
120
|
+
// sidecar under `.unitbob/` when Unitbob installed one, else the machine's own
|
|
121
|
+
// interpreter. Asking a different interpreter than the run uses is how a check
|
|
122
|
+
// ends up answering about something nobody is going to execute.
|
|
123
|
+
//
|
|
124
|
+
// When it resolves nothing we still try the two interpreters by name rather
|
|
125
|
+
// than reporting `no_runner` from a lookup. The lookup is a prediction; the
|
|
126
|
+
// spawn is the fact, and a check that stops at its own prediction can be
|
|
127
|
+
// wrong in the one direction that costs the most — refusing a project that
|
|
128
|
+
// would have answered perfectly well.
|
|
129
|
+
const located = locateRunner(projectRoot, 'pytest');
|
|
130
|
+
const candidates = located
|
|
131
|
+
? [located]
|
|
132
|
+
: [
|
|
133
|
+
{ command: 'python3', args: ['-m', 'pytest'], env: undefined },
|
|
134
|
+
{ command: 'python', args: ['-m', 'pytest'], env: undefined },
|
|
135
|
+
];
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--collect-only', '-q'], { cwd: projectRoot, env: candidate.env });
|
|
117
138
|
if (result === null)
|
|
118
139
|
continue; // this interpreter is not on the machine
|
|
119
140
|
return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
|
|
@@ -148,7 +169,10 @@ function pytestVerdict(code) {
|
|
|
148
169
|
// turn away the majority. A file that is genuinely unparseable is caught here
|
|
149
170
|
// anyway, since `vitest list` has to parse it.
|
|
150
171
|
async function vitestBootCheck(projectRoot, deps) {
|
|
151
|
-
|
|
172
|
+
// A sidecar vitest counts as installed: it is ours, it is on disk, and it is
|
|
173
|
+
// the one the run will spawn. What stays out is `npx`, for the reason below.
|
|
174
|
+
const local = locateRunner(projectRoot, 'vitest')?.command
|
|
175
|
+
?? join(projectRoot, 'node_modules', '.bin', 'vitest');
|
|
152
176
|
// Only a vitest already installed in the project is used. Reaching for `npx`
|
|
153
177
|
// would install a package to answer a question, and installing into the
|
|
154
178
|
// user's project is not this check's business.
|
|
@@ -275,15 +299,17 @@ function hasProjectFrame(output, projectRoot, runner) {
|
|
|
275
299
|
// asked of that file. Judging the whole output at once let `.venv/lib/...`
|
|
276
300
|
// answer yes on the strength of its `lib/`, which is how a `TypeError` deep
|
|
277
301
|
// inside a dependency came back as a defect in the user's code.
|
|
278
|
-
const ownDirs = runner
|
|
279
|
-
const conventional =
|
|
302
|
+
const ownDirs = CONVENTIONAL_SOURCE_DIRS[runner] ?? [];
|
|
303
|
+
const conventional = ownDirs.length
|
|
304
|
+
? new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`)
|
|
305
|
+
: null;
|
|
280
306
|
for (const line of output.split('\n')) {
|
|
281
307
|
// Wherever a dependency is installed, it is not this project's code — and
|
|
282
308
|
// that has to be decided before anything below gets a chance to say yes.
|
|
283
309
|
if (INSTALLED_DEPENDENCY.test(line))
|
|
284
310
|
continue;
|
|
285
311
|
// The conventional homes of business code, relative or absolute.
|
|
286
|
-
if (conventional
|
|
312
|
+
if (conventional?.test(line))
|
|
287
313
|
return true;
|
|
288
314
|
if (line.includes(projectRoot))
|
|
289
315
|
return true;
|
|
@@ -303,6 +329,18 @@ function hasProjectFrame(output, projectRoot, runner) {
|
|
|
303
329
|
}
|
|
304
330
|
return false;
|
|
305
331
|
}
|
|
332
|
+
// Where each stack conventionally keeps its business code. Only a stack that
|
|
333
|
+
// really has such a convention gets an entry: `app/` and `lib/` are Rails, and
|
|
334
|
+
// they used to be the fallback for everything that was not vitest, which meant a
|
|
335
|
+
// Python project got Rails's layout applied to its stack traces. Python names no
|
|
336
|
+
// fixed layout at all, so it is deliberately absent — the repository-file test
|
|
337
|
+
// below is the answer there, and it is the more reliable one anyway.
|
|
338
|
+
const CONVENTIONAL_SOURCE_DIRS = {
|
|
339
|
+
rspec: ['app', 'lib'],
|
|
340
|
+
cucumber: ['app', 'lib'],
|
|
341
|
+
vitest: ['src'],
|
|
342
|
+
'cucumber-js': ['src'],
|
|
343
|
+
};
|
|
306
344
|
// Where a dependency lives once installed — never the project's own code, in
|
|
307
345
|
// any of the three languages. The last two are the languages' own installed
|
|
308
346
|
// libraries: `…/lib/ruby/3.3.0/psych.rb` is a frame the `lib/` rule below would
|
package/dist/runner/precheck.js
CHANGED
|
@@ -1,18 +1,36 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
};
|
|
3
|
+
import { defaultToolDeps, hasGemfileWith, locateRunner, projectProvidesRunner, runnerAvailable, SIDECAR_DIR, } from "./toolchain.js";
|
|
4
|
+
const defaultDeps = defaultToolDeps;
|
|
7
5
|
const STACKS = 'Ruby on Rails + RSpec, JavaScript/TypeScript + Vitest, or Python + pytest';
|
|
8
6
|
// Tried in this order, so a project carrying markers for more than one stack
|
|
9
7
|
// resolves to the same runner on every run.
|
|
10
8
|
const STRUCTURAL_RUNNERS = ['rspec', 'vitest', 'pytest'];
|
|
9
|
+
// The file that says "this project is written in this language". Nothing here
|
|
10
|
+
// asks whether the runner is installed — that is a separate question with a
|
|
11
|
+
// separate answer, and merging the two is what made this gate lie.
|
|
12
|
+
//
|
|
13
|
+
// A project whose language is obvious but whose runner is missing used to fail
|
|
14
|
+
// detection, and the caller then reported the only thing it had left: "this
|
|
15
|
+
// project matches none of those stacks". That sentence was false — the project
|
|
16
|
+
// was Python, it simply had no pytest — and it sent people to look for a problem
|
|
17
|
+
// with their project instead of at the one command that fixes it. The runner is
|
|
18
|
+
// now provisioned under `.unitbob/` (see `ensureStructuralRunner`), so the
|
|
19
|
+
// question this gate answers is the one it can answer honestly: which language.
|
|
20
|
+
const PYTHON_MARKERS = ['pyproject.toml', 'requirements.txt', 'Pipfile'];
|
|
21
|
+
function looksLikePython(projectRoot) {
|
|
22
|
+
return PYTHON_MARKERS.some((name) => existsSync(join(projectRoot, name)));
|
|
23
|
+
}
|
|
24
|
+
const STACK_MARKERS = {
|
|
25
|
+
rspec: (projectRoot) => hasGemfileWith(projectRoot, /\brails\b/),
|
|
26
|
+
vitest: (projectRoot) => existsSync(join(projectRoot, 'package.json')),
|
|
27
|
+
pytest: looksLikePython,
|
|
28
|
+
};
|
|
11
29
|
// Which structural runner this project's markers select, or null when none do.
|
|
12
30
|
// The gate below walks the same list: "is any stack present" and "which one is
|
|
13
31
|
// it" must never be able to disagree.
|
|
14
|
-
export function detectStructuralRunner(projectRoot,
|
|
15
|
-
return STRUCTURAL_RUNNERS.find((runner) =>
|
|
32
|
+
export function detectStructuralRunner(projectRoot, _deps = defaultDeps) {
|
|
33
|
+
return STRUCTURAL_RUNNERS.find((runner) => STACK_MARKERS[runner]?.(projectRoot)) ?? null;
|
|
16
34
|
}
|
|
17
35
|
// The BDD runner for a structural stack. One project, one language: the
|
|
18
36
|
// behavioral peer follows the stack already detected instead of probing the
|
|
@@ -33,7 +51,10 @@ export function detectBddRunner(projectRoot, deps = defaultDeps) {
|
|
|
33
51
|
const structural = detectStructuralRunner(projectRoot, deps);
|
|
34
52
|
return structural ? BDD_RUNNER_FOR_STACK[structural] ?? null : null;
|
|
35
53
|
}
|
|
36
|
-
// The generation-time gate: at least one supported stack must be present.
|
|
54
|
+
// The generation-time gate: at least one supported stack must be present. It
|
|
55
|
+
// says nothing about whether the runner is installed, because by the time that
|
|
56
|
+
// matters the runner has been provisioned; `runnerReadyPrecheck` is the check
|
|
57
|
+
// for that, and it runs straight after provisioning.
|
|
37
58
|
export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
|
|
38
59
|
const runner = detectStructuralRunner(projectRoot, deps);
|
|
39
60
|
if (runner !== null)
|
|
@@ -43,6 +64,54 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
|
|
|
43
64
|
message: `Unitbob guardrails support ${STACKS} only. This project matches none of those stacks.`,
|
|
44
65
|
};
|
|
45
66
|
}
|
|
67
|
+
// Is the runner startable now, after provisioning has had its turn?
|
|
68
|
+
//
|
|
69
|
+
// Not the same question as `validateStack`, and the difference is the sidecar.
|
|
70
|
+
// `validateStack` asks whether the *project* is set up for a stack — the right
|
|
71
|
+
// question when a host has chosen one and nothing has been installed yet. This
|
|
72
|
+
// asks whether anything on this machine can start the runner, which includes
|
|
73
|
+
// the environment Unitbob just built under `.unitbob/`.
|
|
74
|
+
//
|
|
75
|
+
// Asking the first question in the second's place refuses a project we have
|
|
76
|
+
// only just finished preparing: a JS project with no vitest of its own was
|
|
77
|
+
// told to `npm i -D vitest` seconds after a working vitest was installed for
|
|
78
|
+
// it. Found on the connector's own repository, 2026-08-12.
|
|
79
|
+
export function runnerReadyPrecheck(projectRoot, runner, deps = defaultDeps) {
|
|
80
|
+
// Ruby is the one stack whose lookup can never come back empty — `bundle exec
|
|
81
|
+
// rspec` is always a command one could type — so readiness is the gem being
|
|
82
|
+
// resolvable, from the sidecar Gemfile or from the project's own.
|
|
83
|
+
const ready = runner === 'rspec'
|
|
84
|
+
? locateRunner(projectRoot, 'rspec')?.source === 'sidecar' || projectProvidesRunner(projectRoot, 'rspec', deps)
|
|
85
|
+
: runnerAvailable(projectRoot, runner, deps);
|
|
86
|
+
if (ready)
|
|
87
|
+
return { ok: true };
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
message: `The ${runner} runner is not available: it is not installed in this project, and Unitbob could ` +
|
|
91
|
+
`not install one for itself under ${SIDECAR_DIR}/. Nothing was written and nothing was uploaded.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Has Unitbob built this runner for this project already? One question, asked
|
|
95
|
+
// the same way by every precheck that would otherwise advise the user to install
|
|
96
|
+
// something they now have.
|
|
97
|
+
//
|
|
98
|
+
// Ruby needs the second half. Its sidecar is a Gemfile, and `provisionRspec`
|
|
99
|
+
// writes that file *before* it runs bundler and leaves it in place when bundler
|
|
100
|
+
// fails — so its mere existence says "we tried", not "it is there", and taking
|
|
101
|
+
// it as proof would silence the rspec-rails advice exactly when the install
|
|
102
|
+
// failed and the advice is what the user needs. Bundler rewrites the sidecar
|
|
103
|
+
// lock on success, and the project's own lock cannot name a gem its Gemfile
|
|
104
|
+
// lacks, so the gem appearing there is the first artefact that means it
|
|
105
|
+
// resolves. The vitest side needs nothing extra: `locateVitest` tests the actual
|
|
106
|
+
// `.bin/vitest` executable.
|
|
107
|
+
function sidecarProvides(projectRoot, runner, deps = defaultDeps) {
|
|
108
|
+
if (locateRunner(projectRoot, runner, deps)?.source !== 'sidecar')
|
|
109
|
+
return false;
|
|
110
|
+
if (runner !== 'rspec')
|
|
111
|
+
return true;
|
|
112
|
+
const lock = join(projectRoot, SIDECAR_DIR, 'Gemfile.lock');
|
|
113
|
+
return existsSync(lock) && /\brspec-rails\s+\(/.test(readFileSync(lock, 'utf8'));
|
|
114
|
+
}
|
|
46
115
|
// Confirm the host-selected runner against local markers. A mismatch fails
|
|
47
116
|
// closed: the caller writes no files and uploads nothing.
|
|
48
117
|
//
|
|
@@ -56,9 +125,9 @@ export function anyStackPrecheck(projectRoot, deps = defaultDeps) {
|
|
|
56
125
|
export function validateStack(projectRoot, runner, deps = defaultDeps) {
|
|
57
126
|
switch (runner) {
|
|
58
127
|
case 'rspec':
|
|
59
|
-
return rubyPrecheck(projectRoot);
|
|
128
|
+
return rubyPrecheck(projectRoot, deps);
|
|
60
129
|
case 'vitest':
|
|
61
|
-
return vitestPrecheck(projectRoot);
|
|
130
|
+
return vitestPrecheck(projectRoot, deps);
|
|
62
131
|
case 'pytest':
|
|
63
132
|
return pytestPrecheck(projectRoot, deps);
|
|
64
133
|
case 'cucumber':
|
|
@@ -100,16 +169,14 @@ function jsBehavioralPrecheck(projectRoot) {
|
|
|
100
169
|
// and message shape as the structural pytest precheck; pytest-bdd itself, if
|
|
101
170
|
// missing, surfaces as a suite error from the run.
|
|
102
171
|
function pythonBehavioralPrecheck(projectRoot, deps) {
|
|
103
|
-
|
|
104
|
-
if (!markers.some((name) => existsSync(join(projectRoot, name)))) {
|
|
172
|
+
if (!looksLikePython(projectRoot)) {
|
|
105
173
|
return {
|
|
106
174
|
ok: false,
|
|
107
175
|
message: 'The behavioral (Gherkin) suite selected the Python stack, but this project has none of ' +
|
|
108
|
-
`${
|
|
176
|
+
`${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
|
|
109
177
|
};
|
|
110
178
|
}
|
|
111
|
-
|
|
112
|
-
if (!available) {
|
|
179
|
+
if (!runnerAvailable(projectRoot, 'pytest', deps)) {
|
|
113
180
|
return {
|
|
114
181
|
ok: false,
|
|
115
182
|
message: 'The behavioral (Gherkin) suite selected the Python stack, but pytest is not importable in ' +
|
|
@@ -119,7 +186,7 @@ function pythonBehavioralPrecheck(projectRoot, deps) {
|
|
|
119
186
|
}
|
|
120
187
|
return { ok: true };
|
|
121
188
|
}
|
|
122
|
-
function rubyPrecheck(projectRoot) {
|
|
189
|
+
function rubyPrecheck(projectRoot, deps) {
|
|
123
190
|
if (!hasGemfileWith(projectRoot, /\brails\b/)) {
|
|
124
191
|
return {
|
|
125
192
|
ok: false,
|
|
@@ -129,7 +196,13 @@ function rubyPrecheck(projectRoot) {
|
|
|
129
196
|
}
|
|
130
197
|
// Specifically rspec-rails: the boot helper requires `rspec/rails`, so a
|
|
131
198
|
// bare `rspec` gem passes nothing downstream — stop with the honest offer.
|
|
132
|
-
|
|
199
|
+
//
|
|
200
|
+
// Unless Unitbob already installed one for itself. The advice below asks the
|
|
201
|
+
// user to change their Gemfile, and that is the wrong sentence seconds after a
|
|
202
|
+
// working runner was provisioned under `.unitbob/`. `runnerReadyPrecheck`
|
|
203
|
+
// already knows this; this function used to send the reader back to the old
|
|
204
|
+
// answer regardless.
|
|
205
|
+
if (!sidecarProvides(projectRoot, 'rspec', deps) && !hasGemfileWith(projectRoot, /\brspec-rails\b/)) {
|
|
133
206
|
return {
|
|
134
207
|
ok: false,
|
|
135
208
|
message: "Unitbob guardrails need the rspec-rails gem, which is not in this project's " +
|
|
@@ -139,7 +212,7 @@ function rubyPrecheck(projectRoot) {
|
|
|
139
212
|
}
|
|
140
213
|
return { ok: true };
|
|
141
214
|
}
|
|
142
|
-
function vitestPrecheck(projectRoot) {
|
|
215
|
+
function vitestPrecheck(projectRoot, deps) {
|
|
143
216
|
const packageJson = join(projectRoot, 'package.json');
|
|
144
217
|
if (!existsSync(packageJson)) {
|
|
145
218
|
return {
|
|
@@ -147,7 +220,12 @@ function vitestPrecheck(projectRoot) {
|
|
|
147
220
|
message: 'The JavaScript/TypeScript stack was selected, but this project has no package.json.',
|
|
148
221
|
};
|
|
149
222
|
}
|
|
150
|
-
|
|
223
|
+
// Same rule as the Ruby precheck above: a runner Unitbob installed for this
|
|
224
|
+
// project is a runner this project has. Without this the connector's own
|
|
225
|
+
// repository was told to `npm i -D vitest` seconds after a working vitest had
|
|
226
|
+
// been put under `.unitbob/` for it.
|
|
227
|
+
const hasVitest = sidecarProvides(projectRoot, 'vitest', deps) ||
|
|
228
|
+
/"vitest"/.test(readFileSync(packageJson, 'utf8')) ||
|
|
151
229
|
existsSync(join(projectRoot, 'node_modules', '.bin', 'vitest'));
|
|
152
230
|
if (!hasVitest) {
|
|
153
231
|
return {
|
|
@@ -160,22 +238,20 @@ function vitestPrecheck(projectRoot) {
|
|
|
160
238
|
return { ok: true };
|
|
161
239
|
}
|
|
162
240
|
function pytestPrecheck(projectRoot, deps) {
|
|
163
|
-
|
|
164
|
-
const found = markers.some((name) => existsSync(join(projectRoot, name)));
|
|
165
|
-
if (!found) {
|
|
241
|
+
if (!looksLikePython(projectRoot)) {
|
|
166
242
|
return {
|
|
167
243
|
ok: false,
|
|
168
244
|
message: 'The Python stack was selected, but this project has none of ' +
|
|
169
|
-
`${
|
|
245
|
+
`${PYTHON_MARKERS.join(', ')} — it does not look like a Python project.`,
|
|
170
246
|
};
|
|
171
247
|
}
|
|
172
248
|
// Spec 30 fails closed on runner availability: unlike marker files, pytest
|
|
173
|
-
// must actually be importable
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
// the
|
|
177
|
-
|
|
178
|
-
if (!
|
|
249
|
+
// must actually be importable, or every run would end as a "No module named
|
|
250
|
+
// pytest" suite error after files were written. `runnerAvailable` asks the
|
|
251
|
+
// same question the run asks, of the same environments in the same order —
|
|
252
|
+
// the sidecar under `.unitbob/` first, then the machine's own interpreters —
|
|
253
|
+
// so the check and the run can never disagree about what is runnable.
|
|
254
|
+
if (!runnerAvailable(projectRoot, 'pytest', deps)) {
|
|
179
255
|
return {
|
|
180
256
|
ok: false,
|
|
181
257
|
message: 'The Python stack was selected, but pytest is not importable in the current Python ' +
|
|
@@ -186,11 +262,3 @@ function pytestPrecheck(projectRoot, deps) {
|
|
|
186
262
|
}
|
|
187
263
|
return { ok: true };
|
|
188
264
|
}
|
|
189
|
-
function hasGemfileWith(projectRoot, pattern) {
|
|
190
|
-
for (const name of ['Gemfile', 'gems.rb']) {
|
|
191
|
-
const path = join(projectRoot, name);
|
|
192
|
-
if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
|
|
193
|
-
return true;
|
|
194
|
-
}
|
|
195
|
-
return false;
|
|
196
|
-
}
|