unitbob 0.4.5 → 0.5.1
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/bdd.js +75 -12
- 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 +51 -16
- 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 +84 -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/runner/pytest.js
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { runProcess } from "../proc.js";
|
|
4
4
|
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
5
|
+
import { locateRunner } from "./toolchain.js";
|
|
5
6
|
import { readReport } from "./types.js";
|
|
6
7
|
export const PYTEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
8
|
export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
|
|
@@ -11,19 +12,34 @@ export const PYTEST_RESULT_FILE = join(GUARDRAILS_DIR, 'pytest_result.xml');
|
|
|
11
12
|
// never part of the suite digest.
|
|
12
13
|
export const PYTEST_INI_FILE = join('.unitbob', 'pytest.ini');
|
|
13
14
|
export const PYTEST_INI = '[pytest]\naddopts =\n';
|
|
14
|
-
// Run the materialised Unitbob guardrail suite with pytest
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
15
|
+
// Run the materialised Unitbob guardrail suite with pytest (spec 30) — no
|
|
16
|
+
// guessing at Poetry/uv/virtualenv wrappers. Only the guardrail files run; the
|
|
17
|
+
// JUnit XML report goes to --junit-xml, not stdout. The command is
|
|
18
|
+
// connector-owned: the suite artifact never carries a command string.
|
|
19
|
+
//
|
|
20
|
+
// Every file of the branch is named positionally (spec 42, §6.5) — a branch is
|
|
21
|
+
// one file per assignment now, and pytest takes as many paths as it is given.
|
|
22
|
+
//
|
|
23
|
+
// Which pytest is a single question answered in one place (`locateRunner`), so
|
|
24
|
+
// the precheck, the boot check and this run can never end up talking about
|
|
25
|
+
// different interpreters. `python` is the last resort when nothing was found:
|
|
26
|
+
// spawning it produces the honest "No module named pytest" rather than a silent
|
|
27
|
+
// no-op, and the checks upstream have already had their chance to say so first.
|
|
28
|
+
export async function runPytestSuite(projectRoot, suitePaths) {
|
|
20
29
|
writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
|
|
21
|
-
const
|
|
22
|
-
const
|
|
30
|
+
const located = locateRunner(projectRoot, 'pytest');
|
|
31
|
+
const command = located?.command ?? 'python';
|
|
32
|
+
const args = [
|
|
33
|
+
...(located?.args ?? ['-m', 'pytest']),
|
|
34
|
+
'-c',
|
|
35
|
+
PYTEST_INI_FILE,
|
|
36
|
+
...suitePaths,
|
|
37
|
+
`--junit-xml=${PYTEST_RESULT_FILE}`,
|
|
38
|
+
];
|
|
23
39
|
const result = await runProcess(command, args, {
|
|
24
40
|
cwd: projectRoot,
|
|
25
41
|
timeoutMs: PYTEST_TIMEOUT_MS,
|
|
26
|
-
env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
|
|
42
|
+
env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
|
|
27
43
|
});
|
|
28
44
|
return {
|
|
29
45
|
...result,
|
|
@@ -33,14 +49,3 @@ export async function runPytestSuite(projectRoot, suitePath) {
|
|
|
33
49
|
report: readReport(join(projectRoot, PYTEST_RESULT_FILE)),
|
|
34
50
|
};
|
|
35
51
|
}
|
|
36
|
-
// The interpreter that can actually run pytest: `python3` when pytest imports
|
|
37
|
-
// there (macOS/Linux ship no bare `python`), else `python`. Probing `-m pytest`
|
|
38
|
-
// rather than just `--version` keeps this in step with pytestPrecheck, so the
|
|
39
|
-
// run uses the same interpreter the precheck confirmed.
|
|
40
|
-
async function pickPython(projectRoot) {
|
|
41
|
-
const probe = await runProcess('python3', ['-m', 'pytest', '--version'], {
|
|
42
|
-
cwd: projectRoot,
|
|
43
|
-
timeoutMs: 10_000,
|
|
44
|
-
}).catch(() => ({ stdout: '', stderr: '', code: 1 }));
|
|
45
|
-
return probe.code === 0 ? 'python3' : 'python';
|
|
46
|
-
}
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { runProcess } from "../proc.js";
|
|
3
3
|
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
4
|
+
import { locateRunner } from "./toolchain.js";
|
|
4
5
|
import { readReport } from "./types.js";
|
|
5
6
|
export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
6
7
|
// Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
|
|
@@ -8,17 +9,23 @@ export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
8
9
|
// the project's random ordering.
|
|
9
10
|
export const RSPEC_SEED = '1';
|
|
10
11
|
export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
|
|
11
|
-
// Run the materialised Unitbob guardrail suite (spec 26). Only
|
|
12
|
+
// Run the materialised Unitbob guardrail suite (spec 26). Only these files run —
|
|
12
13
|
// never the project's full suite — under RAILS_ENV=test with a fixed order/seed.
|
|
13
14
|
// --options points at the materialized empty file so the project's own .rspec
|
|
14
15
|
// (a --require of a helper we replaced, an extra stdout formatter) can neither
|
|
15
16
|
// break the boot nor corrupt the JSON output. The JSON report goes to `--out`
|
|
16
17
|
// (a file), not stdout, so the app's own stdout writes during the run can never
|
|
17
|
-
// corrupt it.
|
|
18
|
-
|
|
18
|
+
// corrupt it.
|
|
19
|
+
//
|
|
20
|
+
// `suitePaths` is every file of the branch in the suite blob's own
|
|
21
|
+
// project-relative form (spec 42, §6.5). Named one by one rather than as a
|
|
22
|
+
// directory: the artifact already says exactly which files it is, while a
|
|
23
|
+
// directory would also collect whatever else happens to be sitting under the
|
|
24
|
+
// root.
|
|
25
|
+
export async function runRspecSuite(projectRoot, suitePaths) {
|
|
19
26
|
const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
|
|
20
27
|
const { result, command, args } = await invokeRspec(projectRoot, [
|
|
21
|
-
|
|
28
|
+
...suitePaths,
|
|
22
29
|
'--options',
|
|
23
30
|
optionsPath,
|
|
24
31
|
'--order',
|
|
@@ -38,18 +45,19 @@ export async function runRspecSuite(projectRoot, suitePath) {
|
|
|
38
45
|
report: readReport(join(projectRoot, RSPEC_RESULT_FILE)),
|
|
39
46
|
};
|
|
40
47
|
}
|
|
41
|
-
//
|
|
48
|
+
// Which rspec — the sidecar Unitbob installed, the project's own `bin/rspec`
|
|
49
|
+
// binstub, or `bundle exec rspec` — is decided once, in `locateRunner`, so this
|
|
50
|
+
// run and the checks that predicted it always mean the same installation. Every
|
|
42
51
|
// run sets RAILS_ENV=test so guardrails execute against the Rails test
|
|
43
52
|
// environment the project's `rails_helper` configures.
|
|
44
53
|
async function invokeRspec(projectRoot, rspecArgs) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const args = hasLocalRspec ? rspecArgs : ['exec', 'rspec', ...rspecArgs];
|
|
54
|
+
const located = locateRunner(projectRoot, 'rspec');
|
|
55
|
+
const command = located?.command ?? 'bundle';
|
|
56
|
+
const args = [...(located?.args ?? ['exec', 'rspec']), ...rspecArgs];
|
|
49
57
|
const result = await runProcess(command, args, {
|
|
50
58
|
cwd: projectRoot,
|
|
51
59
|
timeoutMs: RSPEC_TIMEOUT_MS,
|
|
52
|
-
env: { ...process.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
60
|
+
env: { ...process.env, ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
|
|
53
61
|
});
|
|
54
62
|
return { result, command, args };
|
|
55
63
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { executable } from "../proc.js";
|
|
5
|
+
// Where Unitbob keeps a test runner it had to install for itself, together with
|
|
6
|
+
// whatever that runner needs to load the project.
|
|
7
|
+
//
|
|
8
|
+
// Deliberately not `.unitbob/structural/`: `materializeGuardrails` deletes that
|
|
9
|
+
// directory before every suite write, so an environment installed there would be
|
|
10
|
+
// rebuilt on every single run. Deliberately not the project's own dependency
|
|
11
|
+
// files either — the whole point is that a vibecoder's repository looks exactly
|
|
12
|
+
// the same after Unitbob has run as it did before. `.unitbob/` is already in
|
|
13
|
+
// their .gitignore (see `ensureUnitbobIgnored`).
|
|
14
|
+
export const SIDECAR_DIR = '.unitbob/runners';
|
|
15
|
+
export function sidecarPath(projectRoot, ...segments) {
|
|
16
|
+
return join(projectRoot, SIDECAR_DIR, ...segments);
|
|
17
|
+
}
|
|
18
|
+
export const defaultToolDeps = {
|
|
19
|
+
commandSucceeds: (command, args, cwd) => spawnSync(command, args, { cwd, timeout: 10_000 }).status === 0,
|
|
20
|
+
};
|
|
21
|
+
// How to invoke `runner` in this project, or null when nothing here can.
|
|
22
|
+
//
|
|
23
|
+
// The sidecar wins when it exists, and that order is deliberate. A sidecar is
|
|
24
|
+
// only ever built because the project could not supply the runner itself, so
|
|
25
|
+
// preferring it keeps every later run on the same environment the build was
|
|
26
|
+
// prepared against. The alternative — asking the project first, every time —
|
|
27
|
+
// lets a stray `pip install pytest` between two runs move the suite to a
|
|
28
|
+
// different interpreter without anybody choosing that, and a guardrail suite
|
|
29
|
+
// whose meaning is "green means fine" cannot afford a silent environment flip.
|
|
30
|
+
export function locateRunner(projectRoot, runner, deps = defaultToolDeps) {
|
|
31
|
+
switch (runner) {
|
|
32
|
+
case 'pytest':
|
|
33
|
+
return locatePytest(projectRoot, deps);
|
|
34
|
+
case 'vitest':
|
|
35
|
+
return locateVitest(projectRoot);
|
|
36
|
+
case 'rspec':
|
|
37
|
+
return locateRspec(projectRoot);
|
|
38
|
+
default:
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Is a runner available at all — from the project or from a sidecar?
|
|
43
|
+
export function runnerAvailable(projectRoot, runner, deps = defaultToolDeps) {
|
|
44
|
+
return locateRunner(projectRoot, runner, deps) !== null;
|
|
45
|
+
}
|
|
46
|
+
// Does the project supply this runner on its own, with no help from us? This is
|
|
47
|
+
// the question provisioning asks before it builds anything: a project that is
|
|
48
|
+
// already set up is left completely alone.
|
|
49
|
+
//
|
|
50
|
+
// Ruby answers from the Gemfile rather than from `locateRspec`, which always has
|
|
51
|
+
// a `bundle exec rspec` to offer whether or not the gem behind it exists.
|
|
52
|
+
export function projectProvidesRunner(projectRoot, runner, deps = defaultToolDeps) {
|
|
53
|
+
if (runner === 'rspec') {
|
|
54
|
+
return hasGemfileWith(projectRoot, /\brails\b/) && hasGemfileWith(projectRoot, /\brspec-rails\b/);
|
|
55
|
+
}
|
|
56
|
+
return locateRunner(projectRoot, runner, deps)?.source === 'project';
|
|
57
|
+
}
|
|
58
|
+
// Does one of the project's Gemfiles mention this? Shared by the Ruby precheck
|
|
59
|
+
// and by the question above, so "is this a Rails project" is answered the same
|
|
60
|
+
// way wherever it is asked.
|
|
61
|
+
export function hasGemfileWith(projectRoot, pattern) {
|
|
62
|
+
for (const name of ['Gemfile', 'gems.rb']) {
|
|
63
|
+
const path = join(projectRoot, name);
|
|
64
|
+
if (existsSync(path) && pattern.test(readFileSync(path, 'utf8')))
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// Python: an interpreter that can actually import pytest. The sidecar venv is
|
|
70
|
+
// checked by file, the project's interpreters by asking them — `python3 -m
|
|
71
|
+
// pytest --version` is the same question the run itself asks, so the two can
|
|
72
|
+
// never disagree about which interpreter is usable.
|
|
73
|
+
function locatePytest(projectRoot, deps) {
|
|
74
|
+
// The sidecar interpreter is asked the same question as any other, rather
|
|
75
|
+
// than trusted because the file is there. A virtualenv can exist and still
|
|
76
|
+
// have no pytest in it — `uv venv` creates one with no pip at all, so an
|
|
77
|
+
// install into it can fail while leaving a perfectly good `bin/python`
|
|
78
|
+
// behind. Trusting the file made provisioning report success and the boot
|
|
79
|
+
// check then say "No module named pytest" about an environment we had just
|
|
80
|
+
// built. Found on a Flask project, 2026-08-12.
|
|
81
|
+
const venvPython = sidecarPath(projectRoot, '.venv', 'bin', 'python');
|
|
82
|
+
if (executable(venvPython) && deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
|
|
83
|
+
return { command: venvPython, args: ['-m', 'pytest'], source: 'sidecar' };
|
|
84
|
+
}
|
|
85
|
+
for (const python of ['python3', 'python']) {
|
|
86
|
+
if (deps.commandSucceeds(python, ['-m', 'pytest', '--version'], projectRoot)) {
|
|
87
|
+
return { command: python, args: ['-m', 'pytest'], source: 'project' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// JS/TS: a vitest binary we can spawn. `npx` is not offered here — it would
|
|
93
|
+
// install a package to answer a question, and this function's callers include
|
|
94
|
+
// checks that must not install anything. The vitest runner keeps `npx` as its
|
|
95
|
+
// own last resort, which is the behaviour it has always had.
|
|
96
|
+
function locateVitest(projectRoot) {
|
|
97
|
+
const sidecar = sidecarPath(projectRoot, 'node_modules', '.bin', 'vitest');
|
|
98
|
+
if (executable(sidecar))
|
|
99
|
+
return { command: sidecar, args: [], source: 'sidecar' };
|
|
100
|
+
const project = join(projectRoot, 'node_modules', '.bin', 'vitest');
|
|
101
|
+
if (executable(project))
|
|
102
|
+
return { command: project, args: [], source: 'project' };
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
// Ruby: bundler decides what "rspec" means, so the sidecar is selected by
|
|
106
|
+
// pointing BUNDLE_GEMFILE at the sidecar Gemfile rather than by a different
|
|
107
|
+
// binary. The project's own `bin/rspec` binstub is preferred over `bundle exec`
|
|
108
|
+
// when it is there, exactly as the rspec runner has always preferred it.
|
|
109
|
+
function locateRspec(projectRoot) {
|
|
110
|
+
if (existsSync(sidecarPath(projectRoot, 'Gemfile'))) {
|
|
111
|
+
return {
|
|
112
|
+
command: 'bundle',
|
|
113
|
+
args: ['exec', 'rspec'],
|
|
114
|
+
env: { BUNDLE_GEMFILE: `${SIDECAR_DIR}/Gemfile` },
|
|
115
|
+
source: 'sidecar',
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const binstub = join(projectRoot, 'bin', 'rspec');
|
|
119
|
+
if (executable(binstub))
|
|
120
|
+
return { command: binstub, args: [], source: 'project' };
|
|
121
|
+
return { command: 'bundle', args: ['exec', 'rspec'], source: 'project' };
|
|
122
|
+
}
|
package/dist/runner/vitest.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import { runProcess } from "../proc.js";
|
|
4
4
|
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
5
|
+
import { locateRunner } from "./toolchain.js";
|
|
5
6
|
import { readReport } from "./types.js";
|
|
6
7
|
export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
8
|
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
|
|
@@ -28,27 +29,44 @@ const PROJECT_CONFIGS = [
|
|
|
28
29
|
'vite.config.cjs',
|
|
29
30
|
];
|
|
30
31
|
// Run the materialised Unitbob guardrail suite with the project's own Vitest
|
|
31
|
-
// (spec 30). Only the guardrail
|
|
32
|
+
// (spec 30). Only the guardrail files run — the path arguments filter the run.
|
|
32
33
|
//
|
|
33
|
-
// A bare `vitest run <file>` treats
|
|
34
|
+
// A bare `vitest run <file>` treats each path as a filter that is intersected
|
|
34
35
|
// with the project's `test.include`, so a project whose include does not cover
|
|
35
|
-
// `.unitbob/` would collect no tests.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
36
|
+
// `.unitbob/` would collect no tests. So the config Unitbob writes names every
|
|
37
|
+
// file of the branch in `include`, and the positional filters keep the run to
|
|
38
|
+
// exactly those files.
|
|
39
|
+
//
|
|
40
|
+
// Named files rather than a directory glob, since spec 42, §6.5 made a branch
|
|
41
|
+
// several files: the artifact already says which files it is, and a glob would
|
|
42
|
+
// have to guess a naming convention nothing enforces. `include` is written even
|
|
43
|
+
// when the project has no config of its own — Vitest's default include only
|
|
44
|
+
// covers `.unitbob/` for a file that happens to be named `*.test.ts`, which is a
|
|
45
|
+
// trap set for whoever names a slice after its capability.
|
|
40
46
|
//
|
|
41
47
|
// The JSON report goes to --outputFile, not stdout, so app logging can never
|
|
42
48
|
// corrupt it. The command is connector-owned: the suite artifact never carries
|
|
43
49
|
// a command string.
|
|
44
|
-
export async function runVitestSuite(projectRoot,
|
|
45
|
-
const configArgs = writeMergedConfig(projectRoot,
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
export async function runVitestSuite(projectRoot, suitePaths) {
|
|
51
|
+
const configArgs = writeMergedConfig(projectRoot, suitePaths);
|
|
52
|
+
// An installed vitest — the sidecar's, else the project's — is spawned by
|
|
53
|
+
// path. `npx` stays as the last resort it has always been: it is the only
|
|
54
|
+
// option that can conjure a runner out of nothing, which is right here at the
|
|
55
|
+
// end and wrong everywhere else (see `locateRunner`, which does not offer it).
|
|
56
|
+
const located = locateRunner(projectRoot, 'vitest');
|
|
57
|
+
const command = located?.command ?? 'npx';
|
|
58
|
+
const args = [
|
|
59
|
+
...(located ? located.args : ['vitest']),
|
|
60
|
+
'run',
|
|
61
|
+
...suitePaths,
|
|
62
|
+
...configArgs,
|
|
63
|
+
'--reporter=json',
|
|
64
|
+
`--outputFile=${VITEST_RESULT_FILE}`,
|
|
65
|
+
];
|
|
48
66
|
const result = await runProcess(command, args, {
|
|
49
67
|
cwd: projectRoot,
|
|
50
68
|
timeoutMs: VITEST_TIMEOUT_MS,
|
|
51
|
-
env: { ...process.env, UNITBOB_REPO_ROOT: projectRoot },
|
|
69
|
+
env: { ...process.env, ...located?.env, UNITBOB_REPO_ROOT: projectRoot },
|
|
52
70
|
});
|
|
53
71
|
return {
|
|
54
72
|
...result,
|
|
@@ -58,31 +76,47 @@ export async function runVitestSuite(projectRoot, suitePath) {
|
|
|
58
76
|
report: readReport(join(projectRoot, VITEST_RESULT_FILE)),
|
|
59
77
|
};
|
|
60
78
|
}
|
|
61
|
-
// Returns the `--config` args to add, writing the
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
function writeMergedConfig(projectRoot,
|
|
79
|
+
// Returns the `--config` args to add, writing the config first. Always written:
|
|
80
|
+
// the branch's files have to be in `include` or nothing is collected, and the
|
|
81
|
+
// names a worker gives its slice are not something to bet a whole run on.
|
|
82
|
+
function writeMergedConfig(projectRoot, suitePaths) {
|
|
65
83
|
const projectConfig = PROJECT_CONFIGS.find((name) => existsSync(join(projectRoot, name)));
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
writeFileSync(
|
|
84
|
+
const path = join(projectRoot, VITEST_CONFIG_FILE);
|
|
85
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
86
|
+
writeFileSync(path, configSource(projectConfig, suitePaths));
|
|
69
87
|
return ['--config', VITEST_CONFIG_FILE];
|
|
70
88
|
}
|
|
71
89
|
// The .unitbob/ config sits one level below the project root, so the project
|
|
72
|
-
// config is a `../` import.
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
90
|
+
// config is a `../` import. A function-form config is resolved first, and
|
|
91
|
+
// everything the project set — plugins, aliases, setup files, environment — is
|
|
92
|
+
// carried through; only `test.include` is replaced, with exactly this branch's
|
|
93
|
+
// files. Replacing rather than concatenating is the point: the run must be these
|
|
94
|
+
// files and no others, and the positional filters then say the same thing twice.
|
|
95
|
+
//
|
|
96
|
+
// Nothing is imported from `vitest` itself, and that is deliberate. Vite
|
|
97
|
+
// re-imports this generated file from a temporary module beside it, so every
|
|
98
|
+
// bare `import` here resolves by walking up from `.unitbob/` — while a project
|
|
99
|
+
// whose only vitest is the one Unitbob installed keeps it at
|
|
100
|
+
// `.unitbob/runners/node_modules`, which is not on that path. An
|
|
101
|
+
// `import { mergeConfig } from 'vitest/config'` there dies with
|
|
102
|
+
// ERR_MODULE_NOT_FOUND before a single test is collected, on exactly the
|
|
103
|
+
// projects the sidecar exists for. A spread does the same job with no import,
|
|
104
|
+
// and `defineConfig` is a typing helper that buys a generated file nothing.
|
|
105
|
+
function configSource(projectConfig, suitePaths) {
|
|
106
|
+
const include = `include: ${JSON.stringify(suitePaths)}`;
|
|
107
|
+
const header = '// Written by the unitbob connector before each vitest run — do not edit.';
|
|
108
|
+
if (!projectConfig) {
|
|
109
|
+
return `${header}
|
|
110
|
+
export default { test: { ${include} } };
|
|
111
|
+
`;
|
|
112
|
+
}
|
|
113
|
+
return `${header}
|
|
78
114
|
import projectConfig from ${JSON.stringify(`../${projectConfig}`)};
|
|
79
115
|
|
|
80
116
|
const base = typeof projectConfig === 'function'
|
|
81
117
|
? await projectConfig({ command: 'serve', mode: 'test' })
|
|
82
118
|
: projectConfig;
|
|
83
119
|
|
|
84
|
-
export default
|
|
85
|
-
test: { include: [${JSON.stringify(suitePath)}] },
|
|
86
|
-
});
|
|
120
|
+
export default { ...base, test: { ...(base.test ?? {}), ${include} } };
|
|
87
121
|
`;
|
|
88
122
|
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { runProcess } from "../proc.js";
|
|
4
4
|
import { firstErrorLine } from "../runner/bootcheck.js";
|
|
5
|
+
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
5
6
|
import { graphPath } from "../files/mapBuild.js";
|
|
6
7
|
// Reading a router means booting the application, which on a large Rails app is
|
|
7
8
|
// tens of seconds. The same budget the other boot-shaped step uses.
|
|
@@ -20,7 +21,7 @@ export function routeInventoryPath(projectRoot) {
|
|
|
20
21
|
// records why Django, FastAPI and Flask come next and why Express cannot follow
|
|
21
22
|
// at all (its addresses are registered by arbitrary code at run time).
|
|
22
23
|
export async function extractRouteInventory(projectRoot, deps = defaultDeps) {
|
|
23
|
-
if (!
|
|
24
|
+
if (!canAskTheRouter(projectRoot))
|
|
24
25
|
return silent(projectRoot, 'unsupported_stack');
|
|
25
26
|
const asked = await askTheRouter(projectRoot, deps);
|
|
26
27
|
if ('reason' in asked)
|
|
@@ -150,8 +151,19 @@ function becauseOf(result) {
|
|
|
150
151
|
function plural(count, one, many) {
|
|
151
152
|
return count === 1 ? one : many;
|
|
152
153
|
}
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
// Which stack this is, is not this module's question — one answer to it already
|
|
155
|
+
// exists and this file now asks it. `config/routes.rb` stays as a second-level
|
|
156
|
+
// condition inside the Ruby branch, because a Rails application without a route
|
|
157
|
+
// file has no router to ask.
|
|
158
|
+
//
|
|
159
|
+
// It used to be the whole test, which made it a fourth independent way of
|
|
160
|
+
// deciding "is this Rails?" — and one that was wrong at both edges: a Rails app
|
|
161
|
+
// that keeps its routes elsewhere was refused, and any project that happens to
|
|
162
|
+
// carry a `config/routes.rb` was asked to boot Rails. There are still several
|
|
163
|
+
// stack detectors in this package; this removes the one that had a single caller
|
|
164
|
+
// and no excuse.
|
|
165
|
+
function canAskTheRouter(projectRoot) {
|
|
166
|
+
return (detectStructuralRunner(projectRoot) === 'rspec' && existsSync(join(projectRoot, 'config', 'routes.rb')));
|
|
155
167
|
}
|
|
156
168
|
// The question, asked of the router object rather than of the `rails routes`
|
|
157
169
|
// command line. `--expanded` was the earlier reading, and it cost this project
|
|
@@ -2,27 +2,62 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
|
|
5
|
+
// The four named roles, in one place. Everything that counts them counts this
|
|
6
|
+
// list — see the message at the bottom, which used to carry the number as a
|
|
7
|
+
// literal and spent a release saying "Installed 3" beside four names.
|
|
8
|
+
export const AGENT_NAMES = ['suite-worker', 'suite-repair-worker', 'fact-finder', 'suite-reviewer'];
|
|
6
9
|
const bundledAgentsDir = fileURLToPath(new URL('../../plugin/codex/agents/', import.meta.url));
|
|
10
|
+
const LABEL = {
|
|
11
|
+
created: 'created: ',
|
|
12
|
+
updated: 'updated: ',
|
|
13
|
+
current: 'already current:',
|
|
14
|
+
};
|
|
15
|
+
// Install (or refresh) the bounded Codex role definitions in the user's agent
|
|
16
|
+
// directory.
|
|
17
|
+
//
|
|
18
|
+
// This used to refuse when an installed file differed from the bundled one, to
|
|
19
|
+
// protect a definition the user had edited by hand. The case it actually met was
|
|
20
|
+
// the ordinary one: an upgrade from an older release, where every file differs
|
|
21
|
+
// and every install therefore failed. Spec 43, §1.6.
|
|
22
|
+
//
|
|
23
|
+
// The refusal became actively harmful once the workflows started asking a role
|
|
24
|
+
// whether this session can see it. A stale role answers that question exactly
|
|
25
|
+
// like a current one, so an update nobody could apply reads as "fully equipped"
|
|
26
|
+
// — the check would pass and the run would proceed on last release's
|
|
27
|
+
// instructions. So the file is overwritten, and what changed is said out loud
|
|
28
|
+
// rather than left for the user to discover.
|
|
7
29
|
export function installCodexAgents(args, deps = { home: homedir(), stdout: process.stdout }) {
|
|
8
30
|
if (args.length > 0)
|
|
9
31
|
throw new Error('codex-install accepts no arguments.');
|
|
10
32
|
const targetDir = join(deps.home, '.codex', 'agents');
|
|
11
|
-
const files = AGENT_NAMES.map((name) => ({
|
|
12
|
-
source: join(bundledAgentsDir, `${name}.toml`),
|
|
13
|
-
target: join(targetDir, `${name}.toml`),
|
|
14
|
-
}));
|
|
15
|
-
for (const file of files) {
|
|
16
|
-
if (!existsSync(file.target))
|
|
17
|
-
continue;
|
|
18
|
-
if (readFileSync(file.target, 'utf8') === readFileSync(file.source, 'utf8'))
|
|
19
|
-
continue;
|
|
20
|
-
throw new Error(`Refusing to overwrite existing Codex agent definition: ${file.target}`);
|
|
21
|
-
}
|
|
22
33
|
mkdirSync(targetDir, { recursive: true });
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
const byOutcome = { created: [], updated: [], current: [] };
|
|
35
|
+
for (const name of AGENT_NAMES) {
|
|
36
|
+
const source = join(bundledAgentsDir, `${name}.toml`);
|
|
37
|
+
const target = join(targetDir, `${name}.toml`);
|
|
38
|
+
const outcome = outcomeFor(source, target);
|
|
39
|
+
if (outcome !== 'current')
|
|
40
|
+
copyFileSync(source, target);
|
|
41
|
+
byOutcome[outcome].push(`${name}.toml`);
|
|
26
42
|
}
|
|
27
|
-
|
|
43
|
+
const changes = ['created', 'updated', 'current']
|
|
44
|
+
.filter((outcome) => byOutcome[outcome].length > 0)
|
|
45
|
+
.map((outcome) => ` ${LABEL[outcome]} ${byOutcome[outcome].join(', ')}`);
|
|
46
|
+
// The reason is attached only when something actually changed, and it is
|
|
47
|
+
// worded to be true of both ways it can change. "A thread open before these
|
|
48
|
+
// files existed" is true of a first install and false of an upgrade, where the
|
|
49
|
+
// files did exist — and the upgrade is the case that matters most, because a
|
|
50
|
+
// thread holding last release's definition answers a readiness check exactly
|
|
51
|
+
// like a current one.
|
|
52
|
+
const changed = byOutcome.created.length + byOutcome.updated.length > 0;
|
|
53
|
+
const why = changed
|
|
54
|
+
? ' — a thread already open is running the definitions it read when it started, not these'
|
|
55
|
+
: '';
|
|
56
|
+
deps.stdout.write(`${AGENT_NAMES.length} Unitbob Codex agent definitions in ${targetDir}:\n${changes.join('\n')}\n` +
|
|
57
|
+
`Start a new Codex thread before running Unitbob${why}.\n`);
|
|
58
|
+
}
|
|
59
|
+
function outcomeFor(source, target) {
|
|
60
|
+
if (!existsSync(target))
|
|
61
|
+
return 'created';
|
|
62
|
+
return readFileSync(target, 'utf8') === readFileSync(source, 'utf8') ? 'current' : 'updated';
|
|
28
63
|
}
|