unitbob 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +51 -36
- 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 +1 -1
- 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 +62 -11
- package/dist/verbs/suiteReviewPrepare.js +14 -0
- package/dist/wire.js +1 -1
- package/package.json +1 -1
- package/plugin/codex/agents/suite-repair-worker.toml +1 -1
package/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
|
|
@@ -18,7 +18,7 @@ const LABEL = {
|
|
|
18
18
|
// This used to refuse when an installed file differed from the bundled one, to
|
|
19
19
|
// protect a definition the user had edited by hand. The case it actually met was
|
|
20
20
|
// the ordinary one: an upgrade from an older release, where every file differs
|
|
21
|
-
// and every install therefore failed. Spec
|
|
21
|
+
// and every install therefore failed. Spec 44, §1.6.
|
|
22
22
|
//
|
|
23
23
|
// The refusal became actively harmful once the workflows started asking a role
|
|
24
24
|
// whether this session can see it. A stale role answers that question exactly
|
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)];
|
package/dist/verbs/runLocal.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { branchRunner, readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
2
2
|
import { digestOf, failureSet, readRunState, rememberFailures } from "../runner/failureDigest.js";
|
|
3
|
+
import { placeProblem } from "../runner/place.js";
|
|
4
|
+
import { placeAdvice } from "../runner/placeAdvice.js";
|
|
5
|
+
import { runnerEnvironmentPlaceProblem } from "../runner/placeEnvironment.js";
|
|
3
6
|
import { validateStack } from "../runner/precheck.js";
|
|
4
7
|
import { runBddSuite } from "../runner/bdd.js";
|
|
5
8
|
import { runStructuralByRunner } from "./run.js";
|
|
@@ -12,6 +15,13 @@ export async function runLocal(config, args = [], deps) {
|
|
|
12
15
|
stdout: process.stdout,
|
|
13
16
|
...deps,
|
|
14
17
|
};
|
|
18
|
+
// Spec 36, criteria 7 and 6. A run that cannot reach the place its
|
|
19
|
+
// dependencies live in has nothing to report but noise — and neither has one
|
|
20
|
+
// whose runner was installed somewhere else, which looks ready because
|
|
21
|
+
// readiness here is a file existing.
|
|
22
|
+
const unusable = placeProblem(config.projectRoot) ?? runnerEnvironmentPlaceProblem(config.projectRoot);
|
|
23
|
+
if (unusable)
|
|
24
|
+
throw new Error(unusable);
|
|
15
25
|
const request = readSuiteBuildRequest(config.projectRoot);
|
|
16
26
|
const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
|
|
17
27
|
const wanted = selectBranches(request, args);
|
|
@@ -124,7 +134,12 @@ async function runOneBranch(config, d, suiteKind, output) {
|
|
|
124
134
|
: await d.runStructural(config.projectRoot, runner, suitePaths);
|
|
125
135
|
}
|
|
126
136
|
catch (err) {
|
|
127
|
-
|
|
137
|
+
// The second and last dead end (spec 36, §7.1). This one does not throw —
|
|
138
|
+
// it prints and returns zero, so it never reaches the one `catch` that adds
|
|
139
|
+
// this advice everywhere else. And it is the case that matters most: the
|
|
140
|
+
// suite exists by now, and the person is trying to run it.
|
|
141
|
+
const advice = placeAdvice(config.projectRoot);
|
|
142
|
+
d.stdout.write(`The runner could not start: ${err.message}\n${advice ? `\n${advice}\n` : ''}`);
|
|
128
143
|
return null;
|
|
129
144
|
}
|
|
130
145
|
d.stdout.write(report(result));
|
|
@@ -163,7 +178,7 @@ function outputTail(result) {
|
|
|
163
178
|
}
|
|
164
179
|
// The suite blob's own project-relative paths, exactly as the runners expect
|
|
165
180
|
// them: the main file first, then every other file of the branch. The main file
|
|
166
|
-
// stopped being the whole suite in spec
|
|
181
|
+
// stopped being the whole suite in spec 43, §6 — a branch is one file per
|
|
167
182
|
// assignment now — and running it alone would exercise a fraction of what the
|
|
168
183
|
// answer claims to guard.
|
|
169
184
|
//
|
|
@@ -4,9 +4,12 @@ import { materializeBehavioralWorld } from "../files/behavioral.js";
|
|
|
4
4
|
import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
|
|
5
5
|
import { bddStepLoading } from "../runner/bdd.js";
|
|
6
6
|
import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
|
|
7
|
-
import { anyStackPrecheck, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck } from "../runner/precheck.js";
|
|
7
|
+
import { anyStackPrecheck, behavioralHarnessNotice, detectBddRunner, detectStructuralRunner, runnerReadyPrecheck, } from "../runner/precheck.js";
|
|
8
8
|
import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
|
|
9
|
+
import { placeProblem } from "../runner/place.js";
|
|
10
|
+
import { alignRunnerEnvironmentWithPlace } from "../runner/placeEnvironment.js";
|
|
9
11
|
import { ensureRunner, ensureStructuralRunner } from "../runner/provision.js";
|
|
12
|
+
import { ToolchainUnavailableError } from "../runner/toolchain.js";
|
|
10
13
|
import { probeBehavioralWorld } from "../runner/worldProbe.js";
|
|
11
14
|
import { Wire } from "../wire.js";
|
|
12
15
|
// The complete envelope for one branch, or null when this machine cannot
|
|
@@ -71,9 +74,30 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
71
74
|
stdout: process.stdout,
|
|
72
75
|
...deps,
|
|
73
76
|
};
|
|
77
|
+
// Spec 36, criterion 7. Before the first byte is written and long before the
|
|
78
|
+
// first call to the server: a place that cannot be used is a fact we can learn
|
|
79
|
+
// now, and learning it after a suite has been generated and run means the
|
|
80
|
+
// evidence disappeared with the container.
|
|
81
|
+
//
|
|
82
|
+
// Not a `ToolchainUnavailableError`: the place is named in the config and the
|
|
83
|
+
// message below already says what to do about it. Suggesting a container to
|
|
84
|
+
// somebody whose container is the problem is noise.
|
|
85
|
+
const unusable = placeProblem(config.projectRoot);
|
|
86
|
+
if (unusable)
|
|
87
|
+
throw new Error(`${unusable}\nNothing was written and nothing was uploaded.`);
|
|
74
88
|
const check = actual.precheck(config.projectRoot);
|
|
89
|
+
// Deliberately a plain stop. This one says "none of the three stacks is here",
|
|
90
|
+
// which is read off files — a Gemfile, a package.json, a requirements.txt —
|
|
91
|
+
// and those are on this machine whatever place the run happens in. A container
|
|
92
|
+
// is never the answer to it.
|
|
75
93
|
if (!check.ok)
|
|
76
94
|
throw new Error(check.message ?? 'Unsupported runtime.');
|
|
95
|
+
// An environment installed somewhere else is not an environment (spec 36, §6).
|
|
96
|
+
// Here, where it can be built again, and before anything asks whether a runner
|
|
97
|
+
// is ready.
|
|
98
|
+
const replaced = alignRunnerEnvironmentWithPlace(config.projectRoot);
|
|
99
|
+
if (replaced)
|
|
100
|
+
actual.stdout.write(`${replaced}\n`);
|
|
77
101
|
// Ruby only. This wrote `unitbob_helper.rb` and `rspec.opts` into every
|
|
78
102
|
// project it touched, so a Flask app and a NestJS app each came away with a
|
|
79
103
|
// Ruby file they never asked for and cannot run — the product leaving another
|
|
@@ -93,16 +117,17 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
93
117
|
const provisioned = await actual.ensureStructuralRunner(config.projectRoot, check.runner);
|
|
94
118
|
if (provisioned.status === 'fixable') {
|
|
95
119
|
const steps = provisioned.checklist?.length ? `\n - ${provisioned.checklist.join('\n - ')}` : '';
|
|
96
|
-
throw new
|
|
97
|
-
`${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded
|
|
120
|
+
throw new ToolchainUnavailableError(`The ${check.runner} runner could not be installed under .unitbob/, and nothing can run without it: ` +
|
|
121
|
+
`${provisioned.message ?? 'provisioning failed'}${steps}\nNothing was written and nothing was uploaded.`, config.projectRoot);
|
|
98
122
|
}
|
|
99
123
|
setupNotices.push(...(provisioned.checklist ?? []));
|
|
100
124
|
// Confirm rather than assume. Provisioning reporting success and the runner
|
|
101
125
|
// actually being startable are two different facts, and this is the cheap
|
|
102
126
|
// one to check before a whole generation is built on it.
|
|
103
127
|
const ready = actual.confirmRunner(config.projectRoot, check.runner);
|
|
104
|
-
if (!ready.ok)
|
|
105
|
-
throw new
|
|
128
|
+
if (!ready.ok) {
|
|
129
|
+
throw new ToolchainUnavailableError(ready.message ?? `The ${check.runner} runner is not available.`, config.projectRoot);
|
|
130
|
+
}
|
|
106
131
|
}
|
|
107
132
|
// Spec 32-6. Before anything is fetched or written, find out whether the suite
|
|
108
133
|
// would get off the ground at all. It runs here, after the boot helper exists
|
|
@@ -118,8 +143,15 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
118
143
|
// the same thing: on Python that would shell out to pytest all over again.
|
|
119
144
|
const structuralRunner = check.runner ?? null;
|
|
120
145
|
const boot = await actual.bootCheck(config.projectRoot, structuralRunner);
|
|
121
|
-
if (boot.status === 'broken')
|
|
122
|
-
|
|
146
|
+
if (boot.status === 'broken') {
|
|
147
|
+
// "Your environment is not ready" is the one of the two that a container can
|
|
148
|
+
// answer — the toolchain is missing here and may be sitting in one. A defect
|
|
149
|
+
// found in the code is a defect wherever it runs, and offering a container
|
|
150
|
+
// for it would be the noise this spec is trying to remove.
|
|
151
|
+
throw boot.cause === 'environment_not_ready'
|
|
152
|
+
? new ToolchainUnavailableError(bootFinding(boot, structuralRunner), config.projectRoot)
|
|
153
|
+
: new Error(bootFinding(boot, structuralRunner));
|
|
154
|
+
}
|
|
123
155
|
actual.stdout.write(bootFinding(boot, structuralRunner));
|
|
124
156
|
const packets = await actual.getSuitePacketsBatch();
|
|
125
157
|
// Spec 32-1: Zero-touch sidecar provision for behavioral BDD runners during build preflight.
|
|
@@ -140,8 +172,14 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
140
172
|
fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
|
|
141
173
|
continue;
|
|
142
174
|
}
|
|
175
|
+
// Every BDD runner with a connector-owned harness gets it here, before its
|
|
176
|
+
// branch is offered to the host (spec 35-1). Only Ruby is probed by
|
|
177
|
+
// running it: the Ruby World integrates deeply with Rails, and the probe
|
|
178
|
+
// needs an application to integrate with. The JS and Python harnesses do
|
|
179
|
+
// one thing — refuse connections that leave the machine — and their
|
|
180
|
+
// guards are executed for real in the connector's own suite.
|
|
181
|
+
materializeBehavioralWorld(config.projectRoot, runner);
|
|
143
182
|
if (runner === 'cucumber') {
|
|
144
|
-
materializeBehavioralWorld(config.projectRoot);
|
|
145
183
|
const probe = await actual.worldProbe(config.projectRoot);
|
|
146
184
|
if (probe.status === 'fixable') {
|
|
147
185
|
fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
|
|
@@ -157,7 +195,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
157
195
|
const manifest = actual.runnerEnvelope(packet, runner, config.projectRoot);
|
|
158
196
|
if (!manifest)
|
|
159
197
|
return { packet, runner, branch: null };
|
|
160
|
-
// Spec
|
|
198
|
+
// Spec 44, §3.2. The rule for which step files this runner loads travels
|
|
161
199
|
// with the branch that will be written against it, so nobody has to read
|
|
162
200
|
// the connector's own source to find it out — which is exactly what two
|
|
163
201
|
// coordinators did.
|
|
@@ -213,6 +251,12 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
213
251
|
for (const { packet, runner, branch } of prepared) {
|
|
214
252
|
if (!branch || packet.suite_kind !== 'behavioral' || !runner)
|
|
215
253
|
continue;
|
|
254
|
+
// Before the steps are written, not after they misbehave: what this runner
|
|
255
|
+
// does and does not load is the fact a worker needs while deciding what a
|
|
256
|
+
// step may assume (spec 35-1, criterion 2).
|
|
257
|
+
const harness = behavioralHarnessNotice(runner);
|
|
258
|
+
if (harness)
|
|
259
|
+
actual.stdout.write(harness);
|
|
216
260
|
actual.stdout.write(branch.step_loading
|
|
217
261
|
? stepLoadingNotice(runner, branch.step_loading)
|
|
218
262
|
// Said rather than left blank. This connector has no strategy for that
|
|
@@ -245,7 +289,7 @@ export async function suitePrepare(config, args = [], deps) {
|
|
|
245
289
|
}
|
|
246
290
|
}
|
|
247
291
|
// The runner's own rule for which step files it will load, in the words of the
|
|
248
|
-
// side that loads them (spec
|
|
292
|
+
// side that loads them (spec 44, §3.2). The same object is in `request.json`, on
|
|
249
293
|
// the behavioral branch; this is the copy the coordinator sees without opening a
|
|
250
294
|
// file.
|
|
251
295
|
//
|
|
@@ -291,7 +335,8 @@ function bootFinding(boot, runner) {
|
|
|
291
335
|
// Not checked is not broken, and nothing downstream may treat it as such.
|
|
292
336
|
// Conflating the two would block honest projects — the whole reason this
|
|
293
337
|
// state is named for what happened rather than for what we know.
|
|
294
|
-
|
|
338
|
+
const said = boot.detail ? `\n\n ${boot.detail}\n` : '';
|
|
339
|
+
return `${NOT_CHECKED_REASON[boot.reason]}${said} Generation continues.${caveat}\n`;
|
|
295
340
|
}
|
|
296
341
|
const headline = boot.cause === 'defect_in_code'
|
|
297
342
|
? 'Found a defect that stops your test suite from starting.'
|
|
@@ -348,6 +393,12 @@ const NOT_CHECKED_REASON = {
|
|
|
348
393
|
'stopped on an error of its own before loading anything. Nothing was learned about your code either way.',
|
|
349
394
|
timed_out: 'Did not check whether the suite can start: loading it took too long and was stopped.',
|
|
350
395
|
nothing_to_load: 'Did not check whether the suite can start: there was nothing to load yet.',
|
|
396
|
+
// Spec 36, criterion 8. Docker refused, or the container went away between the
|
|
397
|
+
// check and the spawn. Nothing here says anything about the project, and the
|
|
398
|
+
// one thing this must never turn into is "we found a defect in your code".
|
|
399
|
+
place_failed: 'Did not check whether the suite can start: the place this project runs in did not carry the command ' +
|
|
400
|
+
'out. That is a fault of the container or the docker daemon, not of your code, and nothing was learned ' +
|
|
401
|
+
'about your code either way.',
|
|
351
402
|
};
|
|
352
403
|
function knownDefectContext(args) {
|
|
353
404
|
const defect = option(args, '--known-defect=');
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { copyBehavioralRunnerEnvironment, filesLostOnMaterialize, materializeBehavioral, } from "../files/behavioral.js";
|
|
6
6
|
import { runBddSuite } from "../runner/bdd.js";
|
|
7
7
|
import { boundReport } from "../runner/boundReport.js";
|
|
8
|
+
import { placeOf } from "../runner/place.js";
|
|
8
9
|
import { branchRunner, readHostSuiteOutputs, readSuiteBuildRequest, reviewRequestPath, writeBehavioralReviewRequest, } from "../files/suiteBuild.js";
|
|
9
10
|
export async function suiteReviewPrepare(config, _args = [], deps) {
|
|
10
11
|
const actual = {
|
|
@@ -54,6 +55,19 @@ async function runCandidateInProject(projectRoot, output, revision) {
|
|
|
54
55
|
return { revision, run_result: report };
|
|
55
56
|
}
|
|
56
57
|
async function runCandidateAtRevision(projectRoot, output, revision) {
|
|
58
|
+
// Spec 36, Non-Goals. The worktree below is created under the system's
|
|
59
|
+
// temporary directory — outside anything a container has mounted, so in there
|
|
60
|
+
// it does not exist at all. Said plainly rather than run into. The obvious
|
|
61
|
+
// repair, moving the worktree under `.unitbob/`, puts a whole second copy of
|
|
62
|
+
// the application inside the tree graphify scans, and a copy left behind by a
|
|
63
|
+
// failure builds the next map out of two applications.
|
|
64
|
+
const place = placeOf(projectRoot);
|
|
65
|
+
if (place.kind === 'docker') {
|
|
66
|
+
throw new Error("Reviewing at a fixed revision is not supported while this project's tests run inside a container " +
|
|
67
|
+
`(\`${place.container}\`): the review needs a git worktree outside the project, which the container ` +
|
|
68
|
+
'cannot see. Review against the working tree instead (drop the fixed revision), or run this project ' +
|
|
69
|
+
'on this machine.');
|
|
70
|
+
}
|
|
57
71
|
const resolved = execFileSync('git', ['rev-parse', '--verify', revision], {
|
|
58
72
|
cwd: projectRoot,
|
|
59
73
|
encoding: 'utf8',
|
package/dist/wire.js
CHANGED
|
@@ -86,7 +86,7 @@ export class Wire {
|
|
|
86
86
|
// carries one result per suite_kind.
|
|
87
87
|
//
|
|
88
88
|
// `dryRun` is the same route, the same body and the same server-side
|
|
89
|
-
// validation, stopped before the first write (spec
|
|
89
|
+
// validation, stopped before the first write (spec 43, §1). It answers
|
|
90
90
|
// `would_publish` instead of `created`, and it is deliberately not a route of
|
|
91
91
|
// its own: a second route would grow a second implementation, which is the
|
|
92
92
|
// defect this whole spec removes.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitbob",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@ markers, or paths. Do not edit production code, host-owned shared files, the
|
|
|
21
21
|
connector-owned harness, or another slice.
|
|
22
22
|
|
|
23
23
|
After every owned edit, run
|
|
24
|
-
`npx -y --loglevel=error unitbob@0.
|
|
24
|
+
`npx -y --loglevel=error unitbob@0.6.0 run-local <branch>` and inspect the machine
|
|
25
25
|
report. Look only at examples or scenarios matching your owned paths or case
|
|
26
26
|
markers. Do not require a green exit code from the whole branch: foreign failures
|
|
27
27
|
and an already-confirmed product red do not widen your scope. Repeat the bounded
|