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.
@@ -62,21 +62,31 @@ else
62
62
  end
63
63
  abort 'unitbob_helper: refusing to run against a non-test environment' unless Rails.env.test?
64
64
  `;
65
- // Write the suite blob's guardrail file at its own (validated) relative path.
66
- // The Ruby boot kit is materialized only for the rspec runner — Vitest and
67
- // pytest runs need no connector-written support files here (the runtime
68
- // pytest.ini lives outside this directory and is written by the pytest runner).
65
+ // Write every file of the suite blob at its own (validated) relative path. The
66
+ // Ruby boot kit is materialized only for the rspec runner — Vitest and pytest
67
+ // runs need no connector-written support files here (the runtime pytest.ini
68
+ // lives outside this directory and is written by the pytest runner).
69
+ //
70
+ // Every file, not just the main one (spec 42, §6.4). The directory is wiped
71
+ // first and only the main file was written back, so a published suite of four
72
+ // files came back as one and the run that followed it silently protected a
73
+ // quarter of what the map claimed.
69
74
  export function materializeGuardrails(projectRoot, suite) {
70
- assertGuardrailPath(suite.suite_file.path);
75
+ const files = [suite.suite_file, ...(suite.suite_file.support_files ?? [])];
76
+ for (const file of files)
77
+ assertGuardrailPath(file.path);
71
78
  const dir = join(projectRoot, GUARDRAILS_DIR);
72
79
  rmSync(dir, { recursive: true, force: true });
73
80
  mkdirSync(dir, { recursive: true });
74
- const suitePath = join(projectRoot, suite.suite_file.path);
75
- mkdirSync(dirname(suitePath), { recursive: true });
76
- writeFileSync(suitePath, suite.suite_file.content);
81
+ const written = files.map((file) => {
82
+ const path = join(projectRoot, file.path);
83
+ mkdirSync(dirname(path), { recursive: true });
84
+ writeFileSync(path, file.content);
85
+ return path;
86
+ });
77
87
  if (suite.runner_manifest.runner === 'rspec')
78
88
  materializeHelper(projectRoot);
79
- return { suitePath };
89
+ return { suitePath: written[0], supportPaths: written.slice(1) };
80
90
  }
81
91
  // Both Ruby flows boot the same way: the check flow writes the boot kit next to
82
92
  // the suite here, the suite-build flow writes it right after the precheck.
@@ -82,12 +82,27 @@ export function branchRunner(output) {
82
82
  }
83
83
  return runner;
84
84
  }
85
+ // What the reviewer actually read: the suite files, and the manifest that runs
86
+ // them. Nothing else (spec 42, §4).
87
+ //
88
+ // `test_metadata` used to be in here, and the server's copy of this formula
89
+ // stripped the review's own keys back out to match — two lists that had to stay
90
+ // identical for ever or every upload would break. The real cost was elsewhere,
91
+ // though: editing metadata declared the review stale. On noahsat-web,
92
+ // 2026-08-12 the reviewer was right that the steps drive only `PATCH`, the fix
93
+ // moved three `PUT` aliases into a deferred list, not one byte of the suite
94
+ // moved — and the run still paid for re-binding the candidate and a second
95
+ // reviewer pass, the most expensive step of the whole recipe, to satisfy the
96
+ // reviewer's own finding.
97
+ //
98
+ // `stableJson` sorts object keys and does nothing else. The server's
99
+ // `canonical_json` does the same, which is the only reason the two sides agree;
100
+ // a normalization added on one side alone would break every upload.
85
101
  export function suiteCandidateDigest(output) {
86
102
  return createHash('sha256')
87
103
  .update(stableJson({
88
104
  suite_file: output.suite_file,
89
105
  runner_manifest: output.runner_manifest,
90
- test_metadata: output.test_metadata,
91
106
  }))
92
107
  .digest('hex');
93
108
  }
@@ -0,0 +1,71 @@
1
+ import { readBehavioralReview } from "./suiteBuild.js";
2
+ // What travels to the server, and what "published" means when it answers. One
3
+ // module, because two commands ask those questions: `put-suite-build` sends the
4
+ // batch, and `validate-build` sends the same batch as a dry run so the server's
5
+ // verdict is about the exact bytes the publish will carry (spec 42, §3).
6
+ //
7
+ // A second assembly would be a second answer to "what are we uploading", and the
8
+ // dry run would then be checking something the publish does not send — which is
9
+ // worth less than not checking at all, because it reads as a verdict.
10
+ // The three outcomes that leave a branch published and current: a new version, an
11
+ // identical version already stored, or a reactivated one. Each returns the
12
+ // identity to run. Everything else — a rejected branch, a branch the host could
13
+ // not build, or a status this connector has never seen — fails closed and is
14
+ // never run, so a newer server can never trick an older connector into running
15
+ // something it does not understand.
16
+ export const PUBLISHED = new Set(['created', 'unchanged', 'restored']);
17
+ // The answer a dry run gives to a branch it would accept. A server that does not
18
+ // know `dry_run` answers one of `PUBLISHED` instead — which means it published —
19
+ // and `validate-build` says so rather than reporting a check that passed.
20
+ export const WOULD_PUBLISH = 'would_publish';
21
+ // One branch, as the upload sends it. `source_digest` comes from the request,
22
+ // never from the host's answer, so the host cannot claim a different map than
23
+ // the branch was given.
24
+ export function uploadItem(request, output, testMetadata) {
25
+ const sourceDigest = request.branches.find((branch) => branch.suite_kind === output.suite_kind)?.source_digest ?? '';
26
+ if (output.build_error) {
27
+ return { suite_kind: output.suite_kind, source_digest: sourceDigest, build_error: output.build_error };
28
+ }
29
+ return {
30
+ suite_kind: output.suite_kind,
31
+ source_digest: sourceDigest,
32
+ artifacts: {
33
+ suite_file: output.suite_file,
34
+ runner_manifest: output.runner_manifest,
35
+ test_metadata: testMetadata,
36
+ },
37
+ };
38
+ }
39
+ // The behavioral branch's uploaded metadata, with the independent review and the
40
+ // connector's own run evidence folded in.
41
+ //
42
+ // Throws for anything that leaves this branch unpublishable — a missing review,
43
+ // one bound to a different candidate, a defect the review called not_supplied.
44
+ // The caller turns that into one unpublished branch rather than a failed
45
+ // command: a blocked review is a fact about the behavioral suite, and the
46
+ // structural peer next to it is finished and correct. Sinking the whole upload
47
+ // with it forced the one workaround this contract exists to prevent — hand-editing
48
+ // the answer down to a single branch, which loses the peer candidate for real.
49
+ export function withReview(config, request, output) {
50
+ const review = readBehavioralReview(config.projectRoot, output);
51
+ const probe = review.known_defect_probe;
52
+ const qualityReview = review.bdd_quality_review;
53
+ if (!qualityReview || typeof qualityReview !== 'object') {
54
+ throw new Error('The separate behavioral review must contain a bdd_quality_review object.');
55
+ }
56
+ if (request.known_defect_context.status === 'supplied' && probe?.status === 'not_supplied') {
57
+ throw new Error('A known defect was supplied to suite-prepare, but the behavioral review marked it not_supplied.');
58
+ }
59
+ return {
60
+ ...output.test_metadata,
61
+ bdd_quality_review: {
62
+ ...qualityReview,
63
+ candidate_digest: review.candidate_digest,
64
+ },
65
+ ...(review.selection_review ? { selection_review: review.selection_review } : {}),
66
+ known_defect_probe: review.known_defect_probe,
67
+ known_defect_context: request.known_defect_context,
68
+ candidate_run: review.candidate_run,
69
+ ...(review.fixed_candidate_run ? { fixed_candidate_run: review.fixed_candidate_run } : {}),
70
+ };
71
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+ import { detectStructuralRunner } from "../runner/precheck.js";
4
5
  import { assertUnitbobPath } from "./artifactPath.js";
5
6
  export function workerPlanPath(projectRoot) {
6
7
  return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
@@ -33,8 +34,13 @@ export function readWorkerPlan(projectRoot) {
33
34
  }
34
35
  return parsed;
35
36
  }
37
+ const RUBY_HARNESS = {
38
+ behavioral: '.unitbob/behavioral/step_definitions/00_unitbob_world.rb',
39
+ structural: '.unitbob/structural/unitbob_helper.rb',
40
+ };
36
41
  export function validateWorkerPlanFiles(projectRoot) {
37
42
  const errors = [];
43
+ const rubyProject = detectStructuralRunner(projectRoot) === 'rspec';
38
44
  const plan = readWorkerPlan(projectRoot);
39
45
  let request;
40
46
  try {
@@ -86,12 +92,23 @@ export function validateWorkerPlanFiles(projectRoot) {
86
92
  errors.push(`${label}: done_when must be non-empty`);
87
93
  if (!isNonEmptyString(item?.harness_path))
88
94
  errors.push(`${label}: harness_path must be non-empty`);
89
- const expectedHarness = item?.branch === 'behavioral'
90
- ? '.unitbob/behavioral/step_definitions/00_unitbob_world.rb'
91
- : item?.branch === 'structural' ? '.unitbob/structural/unitbob_helper.rb' : null;
95
+ // Both connector-owned harness files are Ruby, and only a Ruby project has
96
+ // them: `unitbob_helper.rb` boots Rails for RSpec, and the behavioral World
97
+ // is materialized for cucumber alone. Demanding them everywhere refused
98
+ // every Python and JS plan over a file that does not exist and would mean
99
+ // nothing if it did — the same mistake as the stack gate that reported a
100
+ // Python project as no stack at all. Found 2026-08-12.
101
+ //
102
+ // What is required of the other stacks is what the rule was ever about: the
103
+ // harness is connector territory, under `.unitbob/`, not a file in the
104
+ // project.
105
+ const expectedHarness = rubyProject ? RUBY_HARNESS[item?.branch] ?? null : null;
92
106
  if (expectedHarness && item.harness_path !== expectedHarness) {
93
107
  errors.push(`${label}: harness_path must name the connector-owned ${expectedHarness}`);
94
108
  }
109
+ if (!expectedHarness && isNonEmptyString(item?.harness_path) && !item.harness_path.startsWith('.unitbob/')) {
110
+ errors.push(`${label}: harness_path must be a connector-owned path under .unitbob/ (got "${item.harness_path}")`);
111
+ }
95
112
  if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
96
113
  errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
97
114
  }
@@ -22,33 +22,96 @@ export const BDD_RUN_ARTIFACTS = [
22
22
  PYTEST_BDD_PLUGIN_NAME,
23
23
  PYTEST_INI_NAME,
24
24
  ];
25
+ // One name for the directory every strategy points its loader at, so the
26
+ // descriptors below and the commands below them cannot come to mean different
27
+ // directories.
28
+ const STEP_DEFINITIONS = 'step_definitions';
25
29
  const CUCUMBER_REPORT = join(BEHAVIORAL_ROOT, CUCUMBER_REPORT_NAME);
26
30
  const PYTEST_BDD_REPORT = join(BEHAVIORAL_ROOT, PYTEST_BDD_REPORT_NAME);
27
31
  const PYTEST_BDD_PLUGIN_FILE = join(BEHAVIORAL_ROOT, PYTEST_BDD_PLUGIN_NAME);
28
32
  const PYTEST_INI_FILE = join(BEHAVIORAL_ROOT, PYTEST_INI_NAME);
29
33
  const PYTEST_INI = '[pytest]\naddopts =\n';
34
+ // Load order is a fact about both Cucumbers and about neither pytest — pytest
35
+ // picks `conftest.py` up itself, so there is no trap to work around there.
36
+ const CUCUMBER_LOAD_ORDER = 'Files load in filename order, and the shared file is not special to the runner — `account_access` ' +
37
+ 'loads before `shared`. Open each capability file with an explicit require of the shared one rather ' +
38
+ 'than trusting the alphabet.';
30
39
  // The connector-owned BDD strategy table (spec 32): the `runner` enum names one
31
40
  // of these; the connector never executes a host-provided command string. Each
32
41
  // strategy runs the whole behavioral bundle and returns the raw machine-readable
33
42
  // report verbatim — the connector does no marker join and no aggregation.
43
+ //
44
+ // A strategy is its command *and* its loading rule. They are one entry so that a
45
+ // fourth runner cannot be added with only half of itself stated.
46
+ const BDD_STRATEGIES = {
47
+ cucumber: {
48
+ run: (projectRoot) => runCucumberRuby(projectRoot),
49
+ loading: {
50
+ step_files: '*.rb',
51
+ requirements: [
52
+ 'The connector points `--require` at `step_definitions/`, so every `.rb` file there is loaded. ' +
53
+ 'That explicit `--require` also switches off Cucumber\'s automatic loading of `features/support/`: ' +
54
+ 'a World or helper parked there is never evaluated, and every step then fails on a bare object.',
55
+ CUCUMBER_LOAD_ORDER,
56
+ ],
57
+ },
58
+ },
59
+ 'cucumber-js': {
60
+ run: (projectRoot) => runCucumberJs(projectRoot),
61
+ loading: {
62
+ step_files: '*.js',
63
+ requirements: [
64
+ 'Keep `step_definitions/` to CommonJS JavaScript and nothing else. The connector passes the whole ' +
65
+ 'directory to `--require`, and cucumber-js `require()`s every file it matches whatever the ' +
66
+ 'extension — a stray `.ts`, `.json` or `.md` left there is executed as JavaScript and aborts the ' +
67
+ 'entire run with a parse error, before a single scenario.',
68
+ 'The connector registers no TypeScript loader, so a `.ts` file cannot compile itself. If you want ' +
69
+ 'one, register the compiler from the file that sorts first — and remember the file registering it ' +
70
+ 'is itself loaded as plain JavaScript.',
71
+ CUCUMBER_LOAD_ORDER,
72
+ ],
73
+ },
74
+ },
75
+ 'pytest-bdd': {
76
+ run: (projectRoot, mainPath) => runPytestBdd(projectRoot, mainPath),
77
+ loading: {
78
+ step_files: 'test_*.py',
79
+ requirements: [
80
+ 'pytest collects `step_definitions/` under its own default, which is `test_*.py` and `*_test.py`. ' +
81
+ 'The connector writes no `python_files` setting and will not: a file named outside those two — ' +
82
+ '`<capability>_steps.py`, say — is simply not collected. No error, no scenarios, a green run ' +
83
+ 'over nothing.',
84
+ '`conftest.py` is picked up by pytest itself whatever else sits beside it, so shared fixtures ' +
85
+ 'belong there and there is no load-order trap to work around.',
86
+ ],
87
+ },
88
+ },
89
+ };
34
90
  export function runBddSuite(projectRoot, runner, mainPath) {
35
- switch (runner) {
36
- case 'cucumber':
37
- return runCucumberRuby(projectRoot);
38
- case 'cucumber-js':
39
- return runCucumberJs(projectRoot);
40
- case 'pytest-bdd':
41
- return runPytestBdd(projectRoot, mainPath);
42
- default:
43
- return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
91
+ const strategy = strategyFor(runner);
92
+ if (!strategy) {
93
+ return Promise.reject(new Error(`Unsupported BDD runner "${runner}" — rebuild the behavioral suite.`));
44
94
  }
95
+ return strategy.run(projectRoot, mainPath);
96
+ }
97
+ // How this runner loads step files, for whoever has to write one. Null for a
98
+ // runner this connector does not run, which is the same answer `runBddSuite`
99
+ // gives it.
100
+ export function bddStepLoading(runner) {
101
+ return strategyFor(runner)?.loading ?? null;
102
+ }
103
+ // `Object.hasOwn` rather than a bare index: the runner name arrives over the
104
+ // wire, and `constructor` would otherwise come back as a truthy strategy with no
105
+ // `run` on it.
106
+ function strategyFor(runner) {
107
+ return Object.hasOwn(BDD_STRATEGIES, runner) ? BDD_STRATEGIES[runner] : null;
45
108
  }
46
109
  // Ruby: `cucumber` with the built-in message formatter. The features and step
47
110
  // definitions both live under the behavioral root; --require points at the step
48
111
  // definitions so only the Unitbob bundle loads.
49
112
  async function runCucumberRuby(projectRoot) {
50
113
  const features = join(BEHAVIORAL_ROOT, 'features');
51
- const steps = join(BEHAVIORAL_ROOT, 'step_definitions');
114
+ const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
52
115
  const sidecarGemfile = join(projectRoot, BEHAVIORAL_ROOT, 'Gemfile');
53
116
  if (!existsSync(sidecarGemfile)) {
54
117
  throw missingRunner('Cucumber');
@@ -75,7 +138,7 @@ function missingRunner(name) {
75
138
  // to a file.
76
139
  async function runCucumberJs(projectRoot) {
77
140
  const features = join(BEHAVIORAL_ROOT, 'features');
78
- const steps = join(BEHAVIORAL_ROOT, 'step_definitions', '**', '*');
141
+ const steps = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS, '**', '*');
79
142
  const sidecarBin = join(projectRoot, BEHAVIORAL_ROOT, 'node_modules', '.bin', 'cucumber-js');
80
143
  if (!executable(sidecarBin)) {
81
144
  throw missingRunner('Cucumber JS');
@@ -103,7 +166,7 @@ async function runPytestBdd(projectRoot, mainPath) {
103
166
  writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
104
167
  writeFileSync(join(projectRoot, PYTEST_BDD_PLUGIN_FILE), PYTEST_BDD_PLUGIN);
105
168
  const command = await pickPython(projectRoot);
106
- const stepsDir = join(BEHAVIORAL_ROOT, 'step_definitions');
169
+ const stepsDir = join(BEHAVIORAL_ROOT, STEP_DEFINITIONS);
107
170
  const isVenvPytest = command.endsWith('/pytest');
108
171
  const args = isVenvPytest
109
172
  ? ['-c', PYTEST_INI_FILE, '-p', 'no:cacheprovider', '-p', pluginModule(), stepsDir, '--rootdir', projectRoot]
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { executable, runProcess } from "../proc.js";
4
+ import { locateRunner } from "./toolchain.js";
4
5
  import { GUARDRAILS_DIR, HELPER_FILE } from "../files/guardrails.js";
5
6
  import { PYTEST_INI, PYTEST_INI_FILE } from "./pytest.js";
6
7
  import { PROVISION_TIMEOUT_MS } from "./provision.js";
@@ -86,9 +87,14 @@ async function loadRubyHelper(projectRoot, helper, deps) {
86
87
  // and the global `bundle` was standing right there the whole time.
87
88
  const localBundle = join(projectRoot, 'bin', 'bundle');
88
89
  const command = executable(localBundle) ? localBundle : 'bundle';
90
+ // When Unitbob installed rspec-rails for itself, the gems this helper needs
91
+ // are resolved by the sidecar Gemfile, not the project's. Asking bundler
92
+ // without that variable would load a different set of gems than the run does,
93
+ // which is exactly the way a check ends up predicting the wrong thing.
94
+ const located = locateRunner(projectRoot, 'rspec');
89
95
  return classify(projectRoot, 'rspec', await attempt(deps, command, ['exec', 'ruby', '-e', `require ${JSON.stringify(helper)}`], {
90
96
  cwd: projectRoot,
91
- env: { RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
97
+ env: { ...located?.env, RAILS_ENV: 'test', UNITBOB_REPO_ROOT: projectRoot },
92
98
  }),
93
99
  // A clean load says nothing on stdout and exits 0. Anything else is the
94
100
  // suite failing to start.
@@ -110,10 +116,25 @@ async function pytestBootCheck(projectRoot, deps) {
110
116
  // first: this check must not fail because a different step was skipped.
111
117
  mkdirSync(join(projectRoot, dirname(PYTEST_INI_FILE)), { recursive: true });
112
118
  writeFileSync(join(projectRoot, PYTEST_INI_FILE), PYTEST_INI);
113
- for (const python of ['python3', 'python']) {
114
- const result = await attempt(deps, python, ['-m', 'pytest', '-c', PYTEST_INI_FILE, '--collect-only', '-q'], {
115
- cwd: projectRoot,
116
- });
119
+ // The same pytest the run will use, resolved once in `locateRunner` — the
120
+ // sidecar under `.unitbob/` when Unitbob installed one, else the machine's own
121
+ // interpreter. Asking a different interpreter than the run uses is how a check
122
+ // ends up answering about something nobody is going to execute.
123
+ //
124
+ // When it resolves nothing we still try the two interpreters by name rather
125
+ // than reporting `no_runner` from a lookup. The lookup is a prediction; the
126
+ // spawn is the fact, and a check that stops at its own prediction can be
127
+ // wrong in the one direction that costs the most — refusing a project that
128
+ // would have answered perfectly well.
129
+ const located = locateRunner(projectRoot, 'pytest');
130
+ const candidates = located
131
+ ? [located]
132
+ : [
133
+ { command: 'python3', args: ['-m', 'pytest'], env: undefined },
134
+ { command: 'python', args: ['-m', 'pytest'], env: undefined },
135
+ ];
136
+ for (const candidate of candidates) {
137
+ const result = await attempt(deps, candidate.command, [...candidate.args, '-c', PYTEST_INI_FILE, '--collect-only', '-q'], { cwd: projectRoot, env: candidate.env });
117
138
  if (result === null)
118
139
  continue; // this interpreter is not on the machine
119
140
  return classify(projectRoot, 'pytest', result, (proc) => pytestVerdict(proc.code));
@@ -148,7 +169,10 @@ function pytestVerdict(code) {
148
169
  // turn away the majority. A file that is genuinely unparseable is caught here
149
170
  // anyway, since `vitest list` has to parse it.
150
171
  async function vitestBootCheck(projectRoot, deps) {
151
- const local = join(projectRoot, 'node_modules', '.bin', 'vitest');
172
+ // A sidecar vitest counts as installed: it is ours, it is on disk, and it is
173
+ // the one the run will spawn. What stays out is `npx`, for the reason below.
174
+ const local = locateRunner(projectRoot, 'vitest')?.command
175
+ ?? join(projectRoot, 'node_modules', '.bin', 'vitest');
152
176
  // Only a vitest already installed in the project is used. Reaching for `npx`
153
177
  // would install a package to answer a question, and installing into the
154
178
  // user's project is not this check's business.
@@ -275,15 +299,17 @@ function hasProjectFrame(output, projectRoot, runner) {
275
299
  // asked of that file. Judging the whole output at once let `.venv/lib/...`
276
300
  // answer yes on the strength of its `lib/`, which is how a `TypeError` deep
277
301
  // inside a dependency came back as a defect in the user's code.
278
- const ownDirs = runner === 'vitest' ? ['src'] : ['app', 'lib'];
279
- const conventional = new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`);
302
+ const ownDirs = CONVENTIONAL_SOURCE_DIRS[runner] ?? [];
303
+ const conventional = ownDirs.length
304
+ ? new RegExp(`(^|[\\s"'(\\[/])(${ownDirs.join('|')})/`)
305
+ : null;
280
306
  for (const line of output.split('\n')) {
281
307
  // Wherever a dependency is installed, it is not this project's code — and
282
308
  // that has to be decided before anything below gets a chance to say yes.
283
309
  if (INSTALLED_DEPENDENCY.test(line))
284
310
  continue;
285
311
  // The conventional homes of business code, relative or absolute.
286
- if (conventional.test(line))
312
+ if (conventional?.test(line))
287
313
  return true;
288
314
  if (line.includes(projectRoot))
289
315
  return true;
@@ -303,6 +329,18 @@ function hasProjectFrame(output, projectRoot, runner) {
303
329
  }
304
330
  return false;
305
331
  }
332
+ // Where each stack conventionally keeps its business code. Only a stack that
333
+ // really has such a convention gets an entry: `app/` and `lib/` are Rails, and
334
+ // they used to be the fallback for everything that was not vitest, which meant a
335
+ // Python project got Rails's layout applied to its stack traces. Python names no
336
+ // fixed layout at all, so it is deliberately absent — the repository-file test
337
+ // below is the answer there, and it is the more reliable one anyway.
338
+ const CONVENTIONAL_SOURCE_DIRS = {
339
+ rspec: ['app', 'lib'],
340
+ cucumber: ['app', 'lib'],
341
+ vitest: ['src'],
342
+ 'cucumber-js': ['src'],
343
+ };
306
344
  // Where a dependency lives once installed — never the project's own code, in
307
345
  // any of the three languages. The last two are the languages' own installed
308
346
  // libraries: `…/lib/ruby/3.3.0/psych.rb` is a frame the `lib/` rule below would