unitbob 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/cli.js +9 -1
- package/dist/config.js +37 -1
- package/dist/files/behavioral.js +210 -10
- package/dist/files/guardrails.js +1 -1
- package/dist/files/suiteBuild.js +1 -1
- package/dist/files/suiteBuildUpload.js +1 -1
- package/dist/proc.js +122 -2
- package/dist/runner/bdd.js +126 -48
- package/dist/runner/bootcheck.js +32 -20
- package/dist/runner/docker.js +139 -0
- package/dist/runner/place.js +181 -0
- package/dist/runner/placeAdvice.js +52 -0
- package/dist/runner/placeEnvironment.js +111 -0
- package/dist/runner/precheck.js +35 -0
- package/dist/runner/provision.js +63 -29
- package/dist/runner/pytest.js +9 -10
- package/dist/runner/pytestBddPlugin.js +11 -3
- package/dist/runner/rspec.js +14 -12
- package/dist/runner/toolchain.js +49 -11
- package/dist/runner/types.js +45 -1
- package/dist/runner/vitest.js +9 -10
- package/dist/runner/worldProbe.js +26 -14
- package/dist/surfaces/routeInventory.js +14 -12
- package/dist/verbs/codexInstall.js +51 -16
- package/dist/verbs/mapPrepare.js +16 -5
- package/dist/verbs/putSuiteBuild.js +9 -2
- package/dist/verbs/run.js +10 -2
- package/dist/verbs/runLocal.js +17 -2
- package/dist/verbs/suitePrepare.js +101 -9
- package/dist/verbs/suiteReviewPrepare.js +14 -0
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
|
@@ -14,6 +14,15 @@ import os
|
|
|
14
14
|
_UNITBOB_REPORT = {"version": 1, "scenarios": []}
|
|
15
15
|
_UNITBOB_CURRENT = {}
|
|
16
16
|
|
|
17
|
+
# Resolved once, at import, and never again. The connector passes this path
|
|
18
|
+
# relative to the project root — a relative path means the same thing whether the
|
|
19
|
+
# run happens on this machine or inside a container, and an absolute host path
|
|
20
|
+
# would name a directory the run cannot see. The working directory is the project
|
|
21
|
+
# root when the plugin loads, so a fixture that changes directory later cannot
|
|
22
|
+
# move the report out from under us.
|
|
23
|
+
_UNITBOB_OUT = os.environ.get("UNITBOB_PYTEST_BDD_REPORT")
|
|
24
|
+
_UNITBOB_OUT = os.path.abspath(_UNITBOB_OUT) if _UNITBOB_OUT else None
|
|
25
|
+
|
|
17
26
|
|
|
18
27
|
def pytest_bdd_before_scenario(request, feature, scenario):
|
|
19
28
|
_UNITBOB_CURRENT[id(scenario)] = {
|
|
@@ -55,8 +64,7 @@ def pytest_bdd_after_scenario(request, feature, scenario):
|
|
|
55
64
|
|
|
56
65
|
|
|
57
66
|
def pytest_sessionfinish(session, exitstatus):
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
with open(out, "w") as handle:
|
|
67
|
+
if _UNITBOB_OUT:
|
|
68
|
+
with open(_UNITBOB_OUT, "w") as handle:
|
|
61
69
|
json.dump(_UNITBOB_REPORT, handle)
|
|
62
70
|
`;
|
package/dist/runner/rspec.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import { runProcess } from "../proc.js";
|
|
3
2
|
import { GUARDRAILS_DIR, OPTIONS_FILE } from "../files/guardrails.js";
|
|
3
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
4
4
|
import { locateRunner } from "./toolchain.js";
|
|
5
|
-
import {
|
|
5
|
+
import { clearReport, readFreshReport } from "./types.js";
|
|
6
6
|
export const RSPEC_TIMEOUT_MS = 10 * 60 * 1000;
|
|
7
7
|
// Spec 26: the Unitbob file runs in a defined order with a fixed seed so a
|
|
8
8
|
// green→red flip can never come from run-order nondeterminism. It does not inherit
|
|
@@ -18,13 +18,15 @@ export const RSPEC_RESULT_FILE = join(GUARDRAILS_DIR, 'rspec_result.json');
|
|
|
18
18
|
// corrupt it.
|
|
19
19
|
//
|
|
20
20
|
// `suitePaths` is every file of the branch in the suite blob's own
|
|
21
|
-
// project-relative form (spec
|
|
21
|
+
// project-relative form (spec 43, §6.5). Named one by one rather than as a
|
|
22
22
|
// directory: the artifact already says exactly which files it is, while a
|
|
23
23
|
// directory would also collect whatever else happens to be sitting under the
|
|
24
24
|
// root.
|
|
25
25
|
export async function runRspecSuite(projectRoot, suitePaths) {
|
|
26
26
|
const optionsPath = join(GUARDRAILS_DIR, OPTIONS_FILE);
|
|
27
|
-
const
|
|
27
|
+
const reportPath = join(projectRoot, RSPEC_RESULT_FILE);
|
|
28
|
+
const survivor = clearReport(reportPath);
|
|
29
|
+
const run = await invokeRspec(projectRoot, [
|
|
28
30
|
...suitePaths,
|
|
29
31
|
'--options',
|
|
30
32
|
optionsPath,
|
|
@@ -38,11 +40,9 @@ export async function runRspecSuite(projectRoot, suitePaths) {
|
|
|
38
40
|
RSPEC_RESULT_FILE,
|
|
39
41
|
]);
|
|
40
42
|
return {
|
|
41
|
-
...
|
|
42
|
-
command,
|
|
43
|
-
args,
|
|
43
|
+
...run,
|
|
44
44
|
resultPath: RSPEC_RESULT_FILE,
|
|
45
|
-
report:
|
|
45
|
+
report: readFreshReport(reportPath, survivor),
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
// Which rspec — the sidecar Unitbob installed, the project's own `bin/rspec`
|
|
@@ -54,10 +54,12 @@ async function invokeRspec(projectRoot, rspecArgs) {
|
|
|
54
54
|
const located = locateRunner(projectRoot, 'rspec');
|
|
55
55
|
const command = located?.command ?? 'bundle';
|
|
56
56
|
const args = [...(located?.args ?? ['exec', 'rspec']), ...rspecArgs];
|
|
57
|
-
|
|
58
|
-
cwd: projectRoot,
|
|
57
|
+
return runInProject(projectRoot, command, args, {
|
|
59
58
|
timeoutMs: RSPEC_TIMEOUT_MS,
|
|
60
|
-
env: {
|
|
59
|
+
env: {
|
|
60
|
+
...located?.env,
|
|
61
|
+
RAILS_ENV: 'test',
|
|
62
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
63
|
+
},
|
|
61
64
|
});
|
|
62
|
-
return { result, command, args };
|
|
63
65
|
}
|
package/dist/runner/toolchain.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
3
|
import { executable } from "../proc.js";
|
|
4
|
+
import { commandSucceedsInProject } from "./place.js";
|
|
5
5
|
// Where Unitbob keeps a test runner it had to install for itself, together with
|
|
6
6
|
// whatever that runner needs to load the project.
|
|
7
7
|
//
|
|
@@ -15,8 +15,42 @@ export const SIDECAR_DIR = '.unitbob/runners';
|
|
|
15
15
|
export function sidecarPath(projectRoot, ...segments) {
|
|
16
16
|
return join(projectRoot, SIDECAR_DIR, ...segments);
|
|
17
17
|
}
|
|
18
|
+
// The stop that means "nothing here can start this project's test runner"
|
|
19
|
+
// (spec 36, §7.1).
|
|
20
|
+
//
|
|
21
|
+
// It carries no new wording — the four places that throw it say exactly what
|
|
22
|
+
// they said before. All it adds is a name, so that one place at the top can tell
|
|
23
|
+
// this stop apart from "the server did not answer" and "your token was refused",
|
|
24
|
+
// and offer the one piece of advice that only fits this one. Hanging that advice
|
|
25
|
+
// on the individual failure sites instead would have given it to Ruby alone: a
|
|
26
|
+
// pytest project in a container stops somewhere else, with different words, and
|
|
27
|
+
// a vitest one somewhere else again.
|
|
28
|
+
export class ToolchainUnavailableError extends Error {
|
|
29
|
+
projectRoot;
|
|
30
|
+
constructor(message, projectRoot) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'ToolchainUnavailableError';
|
|
33
|
+
this.projectRoot = projectRoot;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// The file a command names, on the host's own filesystem.
|
|
37
|
+
//
|
|
38
|
+
// A command that names a file we own is written relative to the project root, so
|
|
39
|
+
// that it means the same thing wherever it is started (spec 36, §4.2). Asking
|
|
40
|
+
// whether that file exists is a different question and is always the host's:
|
|
41
|
+
// under the invariant the connector's files are on the host, and the place sees
|
|
42
|
+
// the very same ones. A command with no path in it — `bundle`, `python3` — is
|
|
43
|
+
// resolved by the place through its own PATH and is returned unchanged.
|
|
44
|
+
export function commandFileOnHost(projectRoot, command) {
|
|
45
|
+
if (isAbsolute(command) || !command.includes('/'))
|
|
46
|
+
return command;
|
|
47
|
+
return join(projectRoot, command);
|
|
48
|
+
}
|
|
49
|
+
// Asked of the place the run will happen in, never of this machine by default
|
|
50
|
+
// (spec 36, §3). `cwd` is the project root at every call site, which is what
|
|
51
|
+
// says which place that is.
|
|
18
52
|
export const defaultToolDeps = {
|
|
19
|
-
commandSucceeds: (command, args, cwd) =>
|
|
53
|
+
commandSucceeds: (command, args, cwd) => commandSucceedsInProject(cwd, command, args),
|
|
20
54
|
};
|
|
21
55
|
// How to invoke `runner` in this project, or null when nothing here can.
|
|
22
56
|
//
|
|
@@ -78,8 +112,9 @@ function locatePytest(projectRoot, deps) {
|
|
|
78
112
|
// behind. Trusting the file made provisioning report success and the boot
|
|
79
113
|
// check then say "No module named pytest" about an environment we had just
|
|
80
114
|
// built. Found on a Flask project, 2026-08-12.
|
|
81
|
-
const venvPython =
|
|
82
|
-
if (executable(
|
|
115
|
+
const venvPython = `${SIDECAR_DIR}/.venv/bin/python`;
|
|
116
|
+
if (executable(commandFileOnHost(projectRoot, venvPython)) &&
|
|
117
|
+
deps.commandSucceeds(venvPython, ['-m', 'pytest', '--version'], projectRoot)) {
|
|
83
118
|
return { command: venvPython, args: ['-m', 'pytest'], source: 'sidecar' };
|
|
84
119
|
}
|
|
85
120
|
for (const python of ['python3', 'python']) {
|
|
@@ -94,11 +129,11 @@ function locatePytest(projectRoot, deps) {
|
|
|
94
129
|
// checks that must not install anything. The vitest runner keeps `npx` as its
|
|
95
130
|
// own last resort, which is the behaviour it has always had.
|
|
96
131
|
function locateVitest(projectRoot) {
|
|
97
|
-
const sidecar =
|
|
98
|
-
if (executable(sidecar))
|
|
132
|
+
const sidecar = `${SIDECAR_DIR}/node_modules/.bin/vitest`;
|
|
133
|
+
if (executable(commandFileOnHost(projectRoot, sidecar)))
|
|
99
134
|
return { command: sidecar, args: [], source: 'sidecar' };
|
|
100
|
-
const project =
|
|
101
|
-
if (executable(project))
|
|
135
|
+
const project = 'node_modules/.bin/vitest';
|
|
136
|
+
if (executable(commandFileOnHost(projectRoot, project)))
|
|
102
137
|
return { command: project, args: [], source: 'project' };
|
|
103
138
|
return null;
|
|
104
139
|
}
|
|
@@ -115,8 +150,11 @@ function locateRspec(projectRoot) {
|
|
|
115
150
|
source: 'sidecar',
|
|
116
151
|
};
|
|
117
152
|
}
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
// Relative, and the slash is not decoration: `spawn` resolves a command
|
|
154
|
+
// against the working directory only when it has one, and a bare `rspec` would
|
|
155
|
+
// go looking on PATH — a different command altogether.
|
|
156
|
+
const binstub = 'bin/rspec';
|
|
157
|
+
if (executable(commandFileOnHost(projectRoot, binstub)))
|
|
120
158
|
return { command: binstub, args: [], source: 'project' };
|
|
121
159
|
return { command: 'bundle', args: ['exec', 'rspec'], source: 'project' };
|
|
122
160
|
}
|
package/dist/runner/types.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
2
2
|
// Read a report file back verbatim. A missing or unreadable file is a clean
|
|
3
3
|
// empty string, not a throw: the caller reports a structured suite error.
|
|
4
4
|
export function readReport(path) {
|
|
@@ -9,3 +9,47 @@ export function readReport(path) {
|
|
|
9
9
|
return '';
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
// Clear the report before a run, and hand back whatever survived the attempt
|
|
13
|
+
// (spec 36, criterion 9).
|
|
14
|
+
//
|
|
15
|
+
// The report is written to a fixed path with no run marker on it, and — once the
|
|
16
|
+
// run happens in a container — into a folder both sides share. A run we gave up
|
|
17
|
+
// on can leave a process alive inside that container, and that process finishes
|
|
18
|
+
// and writes the report after we stopped waiting. The next run would then read a
|
|
19
|
+
// file belonging to a run nobody watched, and report a green result nobody
|
|
20
|
+
// earned. Having no result at all is the better of the two.
|
|
21
|
+
//
|
|
22
|
+
// What comes back is the modification time of a file that would not delete —
|
|
23
|
+
// a read-only mount, a permission we do not have — so the read below can tell
|
|
24
|
+
// "the same file, still there" from "a new one the run just wrote". Nothing is
|
|
25
|
+
// compared against this machine's clock: both stamps come from whoever wrote the
|
|
26
|
+
// file, so a container whose clock differs from the host's cannot make a good
|
|
27
|
+
// report look stale.
|
|
28
|
+
export function clearReport(path) {
|
|
29
|
+
try {
|
|
30
|
+
rmSync(path, { force: true });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Could not remove it. That is exactly the case the stamp below covers.
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return statSync(path).mtimeMs;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// The report of *this* run, or nothing. `survivor` is what `clearReport`
|
|
43
|
+
// returned before the run started.
|
|
44
|
+
export function readFreshReport(path, survivor) {
|
|
45
|
+
if (survivor !== null) {
|
|
46
|
+
try {
|
|
47
|
+
if (statSync(path).mtimeMs === survivor)
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return readReport(path);
|
|
55
|
+
}
|
package/dist/runner/vitest.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { runProcess } from "../proc.js";
|
|
4
3
|
import { GUARDRAILS_DIR } from "../files/guardrails.js";
|
|
4
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
5
|
import { locateRunner } from "./toolchain.js";
|
|
6
|
-
import {
|
|
6
|
+
import { clearReport, readFreshReport } from "./types.js";
|
|
7
7
|
export const VITEST_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
8
|
export const VITEST_RESULT_FILE = join(GUARDRAILS_DIR, 'vitest_result.json');
|
|
9
9
|
// A connector-owned Vitest config, written next to .unitbob/ before a run when
|
|
@@ -37,7 +37,7 @@ const PROJECT_CONFIGS = [
|
|
|
37
37
|
// file of the branch in `include`, and the positional filters keep the run to
|
|
38
38
|
// exactly those files.
|
|
39
39
|
//
|
|
40
|
-
// Named files rather than a directory glob, since spec
|
|
40
|
+
// Named files rather than a directory glob, since spec 43, §6.5 made a branch
|
|
41
41
|
// several files: the artifact already says which files it is, and a glob would
|
|
42
42
|
// have to guess a naming convention nothing enforces. `include` is written even
|
|
43
43
|
// when the project has no config of its own — Vitest's default include only
|
|
@@ -63,17 +63,16 @@ export async function runVitestSuite(projectRoot, suitePaths) {
|
|
|
63
63
|
'--reporter=json',
|
|
64
64
|
`--outputFile=${VITEST_RESULT_FILE}`,
|
|
65
65
|
];
|
|
66
|
-
const
|
|
67
|
-
|
|
66
|
+
const reportPath = join(projectRoot, VITEST_RESULT_FILE);
|
|
67
|
+
const survivor = clearReport(reportPath);
|
|
68
|
+
const run = await runInProject(projectRoot, command, args, {
|
|
68
69
|
timeoutMs: VITEST_TIMEOUT_MS,
|
|
69
|
-
env: { ...
|
|
70
|
+
env: { ...located?.env, UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot) },
|
|
70
71
|
});
|
|
71
72
|
return {
|
|
72
|
-
...
|
|
73
|
-
command,
|
|
74
|
-
args,
|
|
73
|
+
...run,
|
|
75
74
|
resultPath: VITEST_RESULT_FILE,
|
|
76
|
-
report:
|
|
75
|
+
report: readFreshReport(reportPath, survivor),
|
|
77
76
|
};
|
|
78
77
|
}
|
|
79
78
|
// Returns the `--config` args to add, writing the config first. Always written:
|
|
@@ -1,36 +1,35 @@
|
|
|
1
1
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { runProcess } from "../proc.js";
|
|
4
3
|
import { BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
|
|
4
|
+
import { BEHAVIORAL_GEMFILE } from "./bdd.js";
|
|
5
|
+
import { projectRootAsSeenByThePlace, runInProject } from "./place.js";
|
|
5
6
|
import { PROVISION_TIMEOUT_MS } from "./provision.js";
|
|
6
7
|
const PROBE_ROOT = '.unitbob/suite-build/world-probe';
|
|
7
8
|
export async function probeBehavioralWorld(projectRoot, deps = {
|
|
8
|
-
runCmd: (command, args, options) =>
|
|
9
|
-
cwd: options.cwd,
|
|
10
|
-
env: options.env,
|
|
11
|
-
timeoutMs: PROVISION_TIMEOUT_MS,
|
|
12
|
-
}),
|
|
9
|
+
runCmd: (command, args, options) => runInProject(options.cwd, command, args, { env: options.env, timeoutMs: PROVISION_TIMEOUT_MS }),
|
|
13
10
|
}) {
|
|
11
|
+
// Written on the host, named to the run relative to the project root — the
|
|
12
|
+
// same shape `bdd.ts` has always had, and the reason nothing here needs a path
|
|
13
|
+
// rewritten when the run happens somewhere else (spec 36, §4.2).
|
|
14
|
+
const feature = `${PROBE_ROOT}/world.feature`;
|
|
15
|
+
const steps = `${PROBE_ROOT}/world_steps.rb`;
|
|
14
16
|
const probeRoot = join(projectRoot, PROBE_ROOT);
|
|
15
|
-
const feature = join(probeRoot, 'world.feature');
|
|
16
|
-
const steps = join(probeRoot, 'world_steps.rb');
|
|
17
17
|
mkdirSync(probeRoot, { recursive: true });
|
|
18
|
-
writeFileSync(feature, PROBE_FEATURE);
|
|
19
|
-
writeFileSync(steps, PROBE_STEPS);
|
|
18
|
+
writeFileSync(join(projectRoot, feature), PROBE_FEATURE);
|
|
19
|
+
writeFileSync(join(projectRoot, steps), PROBE_STEPS);
|
|
20
20
|
try {
|
|
21
21
|
const result = await deps.runCmd('bundle', [
|
|
22
22
|
'exec', 'cucumber', feature,
|
|
23
|
-
'--require',
|
|
23
|
+
'--require', BEHAVIORAL_WORLD_PATH,
|
|
24
24
|
'--require', steps,
|
|
25
25
|
'--format', 'progress',
|
|
26
26
|
], {
|
|
27
27
|
cwd: projectRoot,
|
|
28
28
|
env: {
|
|
29
|
-
...process.env,
|
|
30
29
|
RAILS_ENV: 'test',
|
|
31
30
|
CUCUMBER_PUBLISH_QUIET: 'true',
|
|
32
|
-
UNITBOB_REPO_ROOT: projectRoot,
|
|
33
|
-
BUNDLE_GEMFILE:
|
|
31
|
+
UNITBOB_REPO_ROOT: projectRootAsSeenByThePlace(projectRoot),
|
|
32
|
+
BUNDLE_GEMFILE: BEHAVIORAL_GEMFILE,
|
|
34
33
|
},
|
|
35
34
|
});
|
|
36
35
|
if (result.code === 0)
|
|
@@ -52,6 +51,9 @@ const PROBE_FEATURE = `Feature: Unitbob World profile
|
|
|
52
51
|
|
|
53
52
|
Scenario: supported state is clean at the next scenario boundary
|
|
54
53
|
Then the second World probe scenario sees clean state and fresh mocks
|
|
54
|
+
|
|
55
|
+
Scenario: outgoing HTTP does not leave the machine
|
|
56
|
+
Then the World probe cannot reach the network
|
|
55
57
|
`;
|
|
56
58
|
const PROBE_STEPS = `PROBE_VERSION = "unitbob-world-probe-#{Process.pid}"
|
|
57
59
|
PROBE_TIME_ZONE = Time.zone
|
|
@@ -84,6 +86,16 @@ Then('its integration assertion counter advances') do
|
|
|
84
86
|
expect(unitbob_session.assertions).to be > 0
|
|
85
87
|
end
|
|
86
88
|
|
|
89
|
+
# Spec 35-1, criterion 2. Checked here rather than trusted, because the failure
|
|
90
|
+
# it guards against is invisible from inside: a suite whose WebMock never came on
|
|
91
|
+
# passes exactly the same way, and only the other end of the wire ever finds out.
|
|
92
|
+
Then('the World probe cannot reach the network') do
|
|
93
|
+
require 'net/http'
|
|
94
|
+
expect {
|
|
95
|
+
Net::HTTP.get(URI('http://unitbob-world-probe.invalid/'))
|
|
96
|
+
}.to raise_error(WebMock::NetConnectNotAllowedError)
|
|
97
|
+
end
|
|
98
|
+
|
|
87
99
|
Then('the second World probe scenario sees clean state and fresh mocks') do
|
|
88
100
|
quoted = @unitbob_connection.quote(PROBE_VERSION)
|
|
89
101
|
count = @unitbob_connection.select_value("SELECT COUNT(*) FROM schema_migrations WHERE version = #{quoted}").to_i
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { executable } from "../proc.js";
|
|
4
4
|
import { firstErrorLine } from "../runner/bootcheck.js";
|
|
5
|
+
import { projectRootAsSeenByThePlace, runInProject } from "../runner/place.js";
|
|
5
6
|
import { detectStructuralRunner } from "../runner/precheck.js";
|
|
6
7
|
import { graphPath } from "../files/mapBuild.js";
|
|
7
8
|
// Reading a router means booting the application, which on a large Rails app is
|
|
8
9
|
// tens of seconds. The same budget the other boot-shaped step uses.
|
|
9
10
|
const ROUTES_TIMEOUT_MS = 120_000;
|
|
11
|
+
// The router is the application's own, so asking it is a project command and
|
|
12
|
+
// goes where the project's dependencies live (spec 36, §5). Unlike every other
|
|
13
|
+
// caller this one is allowed to fail: silence is already a normal answer here,
|
|
14
|
+
// so a project whose container is misconfigured loses its route inventory and
|
|
15
|
+
// nothing else.
|
|
10
16
|
const defaultDeps = {
|
|
11
|
-
runCmd: (command, args, options) =>
|
|
12
|
-
cwd: options.cwd,
|
|
13
|
-
timeoutMs: ROUTES_TIMEOUT_MS,
|
|
14
|
-
env: { ...process.env, ...options.env },
|
|
15
|
-
}),
|
|
17
|
+
runCmd: (command, args, options) => runInProject(options.cwd, command, args, { timeoutMs: ROUTES_TIMEOUT_MS, env: options.env }),
|
|
16
18
|
};
|
|
17
19
|
export function routeInventoryPath(projectRoot) {
|
|
18
20
|
return join(projectRoot, '.unitbob', 'map-build', 'route_inventory.json');
|
|
@@ -202,16 +204,16 @@ end`;
|
|
|
202
204
|
// `default` means we leave RAILS_ENV alone and take whatever the project's own
|
|
203
205
|
// setup chooses.
|
|
204
206
|
async function askRouterOnce(projectRoot, deps, environment) {
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
? [local, ['runner', ROUTES_SCRIPT]]
|
|
207
|
+
const [command, args] = executable(join(projectRoot, 'bin', 'rails'))
|
|
208
|
+
? ['bin/rails', ['runner', ROUTES_SCRIPT]]
|
|
208
209
|
: ['bundle', ['exec', 'rails', 'runner', ROUTES_SCRIPT]];
|
|
210
|
+
const repoRoot = projectRootAsSeenByThePlace(projectRoot);
|
|
209
211
|
try {
|
|
210
212
|
return await deps.runCmd(command, args, {
|
|
211
213
|
cwd: projectRoot,
|
|
212
214
|
env: environment === 'test'
|
|
213
|
-
? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT:
|
|
214
|
-
: { UNITBOB_REPO_ROOT:
|
|
215
|
+
? { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: repoRoot }
|
|
216
|
+
: { UNITBOB_REPO_ROOT: repoRoot },
|
|
215
217
|
});
|
|
216
218
|
}
|
|
217
219
|
catch {
|
|
@@ -415,7 +417,7 @@ export function inventoryProblems(inventory, surfaces) {
|
|
|
415
417
|
// either printed by the router or copied out of the graph. A blank is not a gap
|
|
416
418
|
// to be helpfully filled — an invented `source_file` is the same failure as an
|
|
417
419
|
// invented address, one field over, and a `handler_symbol` swapped for another
|
|
418
|
-
// node that happens to exist passes the host's check while sending spec
|
|
420
|
+
// node that happens to exist passes the host's check while sending spec 38's
|
|
419
421
|
// trace into the wrong code.
|
|
420
422
|
const LINK_FIELDS = ['source_file', 'handler_symbol'];
|
|
421
423
|
// Not a link and nothing downstream reads it — but spec 32-7 took the authoring
|
|
@@ -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 44, §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
|
}
|
package/dist/verbs/mapPrepare.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ensureUnitbobIgnored, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
|
|
1
|
+
import { ensureUnitbobIgnored, ignoreExclusions, requireGraphify, runGraphifyExtractKeyless } from "../proc.js";
|
|
2
2
|
import { readFreshGraph, writeMapBuildRequest } from "../files/mapBuild.js";
|
|
3
3
|
import { describeRouteInventory, extractRouteInventory, } from "../surfaces/routeInventory.js";
|
|
4
4
|
import { Wire } from "../wire.js";
|
|
@@ -10,9 +10,20 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
10
10
|
runGraphifyExtractKeyless,
|
|
11
11
|
extractRouteInventory,
|
|
12
12
|
getRecipe: (name) => wire.getRecipe(name),
|
|
13
|
+
stdout: process.stdout,
|
|
13
14
|
...deps,
|
|
14
15
|
};
|
|
15
16
|
actual.ensureUnitbobIgnored(config.projectRoot);
|
|
17
|
+
// Said before the graph is built, because after it there is nothing left to
|
|
18
|
+
// see: whatever these patterns matched never becomes a node, and a subsystem
|
|
19
|
+
// that is missing from the map looks exactly like a subsystem that was never
|
|
20
|
+
// written. One line per pattern that actually took something; a pattern that
|
|
21
|
+
// matched nothing is not news (spec 35-1, criterion 1).
|
|
22
|
+
const exclusions = ignoreExclusions(config.projectRoot);
|
|
23
|
+
if (exclusions.length > 0) {
|
|
24
|
+
actual.stdout.write('Kept out of the graph by .graphifyignore — nothing below can appear on the map:\n' +
|
|
25
|
+
exclusions.map((entry) => ` ${entry.pattern} — ${entry.files} file${entry.files === 1 ? '' : 's'}\n`).join(''));
|
|
26
|
+
}
|
|
16
27
|
await actual.requireGraphify();
|
|
17
28
|
// Keyless: refresh the one canonical graph in place. No inference secret and no
|
|
18
29
|
// graph flags — semantic enrichment is host-LLM work, not a keyed LLM here.
|
|
@@ -31,7 +42,7 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
31
42
|
// Said out loud first, because asking a router means booting the application
|
|
32
43
|
// and that can take a minute or two with nothing on the screen. Silence from
|
|
33
44
|
// us there reads as a hang.
|
|
34
|
-
|
|
45
|
+
actual.stdout.write('Asking this project for the addresses it declares (this boots the application)…\n');
|
|
35
46
|
const inventory = await actual.extractRouteInventory(config.projectRoot);
|
|
36
47
|
const [decompose, relate, extractSurfaces, decomposeSurfaces] = await Promise.all([
|
|
37
48
|
actual.getRecipe('decompose'),
|
|
@@ -45,9 +56,9 @@ export async function mapPrepare(config, _args = [], deps) {
|
|
|
45
56
|
extract_surfaces: extractSurfaces,
|
|
46
57
|
decompose_surfaces: decomposeSurfaces,
|
|
47
58
|
}, inventory.status === 'written' ? inventory.path : undefined);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
actual.stdout.write(`Map build request written to ${packet.project_root}/.unitbob/map-build/request.json\n`);
|
|
60
|
+
actual.stdout.write(`${describeRouteInventory(inventory)}\n`);
|
|
61
|
+
actual.stdout.write(`Next: build BOTH lenses following the recipes in that request — the decompose map at ` +
|
|
51
62
|
`${packet.output_path} (recipes.decompose, recipes.relate), and the surface map at ` +
|
|
52
63
|
`${packet.surface_output_path} (recipes.extract_surfaces → ${packet.surfaces_path}, then ` +
|
|
53
64
|
'recipes.decompose_surfaces) — then run `unitbob put-map-build`.\n');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
|
+
import { placeProblem } from "../runner/place.js";
|
|
2
3
|
import { collectBuildProblems } from "./validateBuild.js";
|
|
3
4
|
import { PUBLISHED, uploadItem, withReview } from "../files/suiteBuildUpload.js";
|
|
4
5
|
import { Wire } from "../wire.js";
|
|
@@ -20,6 +21,12 @@ import { Wire } from "../wire.js";
|
|
|
20
21
|
// Returns the server's per-branch results so the caller can compose the first run
|
|
21
22
|
// on top of them (spec 32-4) without parsing the lines printed here.
|
|
22
23
|
export async function putSuiteBuild(config, _args = [], deps) {
|
|
24
|
+
// Spec 36, criterion 7. Publishing is followed immediately by a first run, so
|
|
25
|
+
// a place that cannot be used is not something to discover after the suite is
|
|
26
|
+
// stored on the server.
|
|
27
|
+
const unusable = placeProblem(config.projectRoot);
|
|
28
|
+
if (unusable)
|
|
29
|
+
throw new Error(`${unusable}\nNothing was uploaded.`);
|
|
23
30
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
24
31
|
// Spec 32-6: read branch by branch, so one unreadable entry neither hides the
|
|
25
32
|
// next branch's problems nor sinks a peer that is finished and correct.
|
|
@@ -39,7 +46,7 @@ export async function putSuiteBuild(config, _args = [], deps) {
|
|
|
39
46
|
// skipped by going straight to the upload — but reported the way every other
|
|
40
47
|
// local failure here is reported: against the branch it belongs to.
|
|
41
48
|
//
|
|
42
|
-
// Since spec
|
|
49
|
+
// Since spec 43 that check is exactly one question, and it is about a branch
|
|
43
50
|
// the answer has *no* entry for: everything else it used to ask is now asked
|
|
44
51
|
// of the server, by a dry run, before this command runs at all. So its
|
|
45
52
|
// problems can never land on a branch this loop visits, and they are reported
|
|
@@ -122,7 +129,7 @@ function printResult(result) {
|
|
|
122
129
|
const digest = result.suite_digest ? ` (${result.suite_digest})` : '';
|
|
123
130
|
return (`${result.suite_kind}: ${result.status}${digest}${tallies ? ` — ${tallies}` : ''}.` + printDowngrades(result));
|
|
124
131
|
}
|
|
125
|
-
// Spec
|
|
132
|
+
// Spec 43, §7. A capability every one of whose Scenarios the review objected to
|
|
126
133
|
// is stored `unguarded` by the publish. The run is standing right here when that
|
|
127
134
|
// is decided, so it is told here, in the server's own words — finding it on the
|
|
128
135
|
// map afterwards is how a run finishes believing it published a guarantee it did
|
package/dist/verbs/run.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { materializeGuardrails } from "../files/guardrails.js";
|
|
2
2
|
import { materializeBehavioral } from "../files/behavioral.js";
|
|
3
|
+
import { placeProblem } from "../runner/place.js";
|
|
4
|
+
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
3
5
|
import { validateStack } from "../runner/precheck.js";
|
|
4
6
|
import { runRspecSuite } from "../runner/rspec.js";
|
|
5
7
|
import { runVitestSuite } from "../runner/vitest.js";
|
|
@@ -28,7 +30,7 @@ function resolve(config, deps) {
|
|
|
28
30
|
getSuites: () => wire.getSuites(),
|
|
29
31
|
postRunsBatch: (runs) => wire.postRunsBatch(runs),
|
|
30
32
|
// The whole envelope, support files and all: a branch is a set of files
|
|
31
|
-
// since spec
|
|
33
|
+
// since spec 43, §6, and picking `path` and `content` out of it here was
|
|
32
34
|
// where the rest of them used to be lost.
|
|
33
35
|
materializeStructural: (projectRoot, item) => materializeGuardrails(projectRoot, {
|
|
34
36
|
suite_digest: item.suite_digest,
|
|
@@ -44,6 +46,12 @@ function resolve(config, deps) {
|
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
async function execute(config, d, only) {
|
|
49
|
+
// Spec 36, criterion 7. Before the first suite is fetched: a run that cannot
|
|
50
|
+
// happen where this project's dependencies live has nothing honest to file,
|
|
51
|
+
// and the whole batch would go up as suite errors describing the wrong thing.
|
|
52
|
+
const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
|
|
53
|
+
if (unusable)
|
|
54
|
+
throw new Error(`${unusable}\nNothing was run and no results were filed.`);
|
|
47
55
|
const suites = await d.getSuites();
|
|
48
56
|
const ready = suites.filter((item) => item.status === 'ready');
|
|
49
57
|
const selected = only === null ? ready : select(ready, only);
|
|
@@ -137,7 +145,7 @@ export function runStructuralByRunner(projectRoot, runner, suitePaths) {
|
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
// Every file of the branch, in the order the envelope carries them. A structural
|
|
140
|
-
// branch is one file per assignment since spec
|
|
148
|
+
// branch is one file per assignment since spec 43, §6, and running only the main
|
|
141
149
|
// one would execute a fraction of what the map says is guarded.
|
|
142
150
|
function artifactPaths(file) {
|
|
143
151
|
return [file.path, ...(file.support_files ?? []).map((entry) => entry.path)];
|