Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 101 additions & 1 deletion packages/cli/src/cobol-proleap-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,20 @@
* the result reports the final JAR + wrapper class paths.
* - Idempotency: a second call with the JAR + wrapper class already in
* place skips without re-running the build.
* - Wrapper Java source resolution across install shapes (bundled CLI
* `dist/java/`, source checkout, legacy per-package).
*/

import assert from "node:assert/strict";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { test } from "node:test";
import {
DEFAULT_PROCESS_API,
defaultVendorDir,
findWrapperJavaSourceFrom,
type ProcessApi,
type ProcessResult,
resolveWrapperJavaSource,
runSetupCobolProleap,
} from "./cobol-proleap-setup.js";

Expand Down Expand Up @@ -200,3 +204,99 @@ test("defaultVendorDir: resolves under ~/.codehub/vendor/proleap", () => {
test("DEFAULT_PROCESS_API is exported for the cli action", () => {
assert.equal(typeof DEFAULT_PROCESS_API.run, "function");
});

// ---------------------------------------------------------------------------
// Wrapper Java source resolution — must find `dist/java/cobol_to_scip.java`
// in the bundled-CLI layout (PR #189), not only the pre-collapse monorepo
// shapes. Use an injectable `exists` + path.join so the assertions are
// platform-agnostic (backslashes on Windows).
// ---------------------------------------------------------------------------

// The bundled CLI runs from `dist/` and ships the wrapper at `dist/java/`.
// The walk-up must find it on the FIRST hop (the module's own dir + `java/`).
test("findWrapperJavaSourceFrom: resolves dist/java relative to the bundled module", () => {
const distDir = join("/opt", "node_modules", "@opencodehub", "cli", "dist");
const expected = join(distDir, "java", "cobol_to_scip.java");
const seen = new Set([expected]);
const resolved = findWrapperJavaSourceFrom(distDir, (p) => seen.has(p));
assert.equal(resolved, expected);
});

// A chunked bundle can run from a nested dir (e.g. `dist/commands`); the
// walk-up must climb to `dist/java/cobol_to_scip.java`.
test("findWrapperJavaSourceFrom: walks up to dist/java from a nested bundle dir", () => {
const distDir = join("/opt", "cli", "dist");
const nested = join(distDir, "commands");
const expected = join(distDir, "java", "cobol_to_scip.java");
const seen = new Set([expected]);
const resolved = findWrapperJavaSourceFrom(nested, (p) => seen.has(p));
assert.equal(resolved, expected);
});

// Source-checkout fallback: the CLI runs from `packages/cli/dist`, the wrapper
// lives in the sibling `packages/cobol-proleap/java/` workspace tree.
test("findWrapperJavaSourceFrom: falls back to the monorepo sibling layout", () => {
const distDir = join("/repo", "packages", "cli", "dist");
const expected = join("/repo", "packages", "cobol-proleap", "java", "cobol_to_scip.java");
const seen = new Set([expected]);
const resolved = findWrapperJavaSourceFrom(distDir, (p) => seen.has(p));
assert.equal(resolved, expected);
});

// When nothing exists, the resolver returns the bundled-CLI path so the caller
// emits a clean "wrapper Java source not found" error (not a bare ENOENT).
test("findWrapperJavaSourceFrom: returns the dist/java path when nothing exists", () => {
const distDir = join("/opt", "cli", "dist");
const resolved = findWrapperJavaSourceFrom(distDir, () => false);
assert.equal(resolved, join(distDir, "java", "cobol_to_scip.java"));
});

// Smoke test the production entry point: in the source/test build it resolves
// to an existing `cobol_to_scip.java` via the monorepo fallback. Confirms the
// real `import.meta.url` wiring resolves a file that is actually on disk.
test("resolveWrapperJavaSource: resolves to an existing cobol_to_scip.java in the dev tree", () => {
const resolved = resolveWrapperJavaSource();
assert.equal(dirname(resolved).endsWith("java"), true, `unexpected dir: ${resolved}`);
assert.match(resolved, /cobol_to_scip\.java$/);
});

// The "wrapper Java source not found" error must NOT point at a --java-source
// flag (never wired on the `codehub setup` command). It should name the
// bundled location + a reinstall remedy. Drive the build far enough to hit the
// wrapper pre-flight, with a javaSourcePath that does not exist.
test("runSetupCobolProleap: missing wrapper source error names the bundled path, not --java-source", async () => {
const script = makeScript({
toolResponses: new Map([
["git --version", { code: 0, stdout: "git version 2.40.0", stderr: "" }],
["mvn --version", { code: 0, stdout: "Apache Maven 3.8.6", stderr: "" }],
["javac --version", { code: 0, stdout: "javac 21.0.1", stderr: "" }],
["git clone", { code: 0, stdout: "", stderr: "" }],
["mvn install", { code: 0, stdout: "BUILD SUCCESS", stderr: "" }],
]),
fsReaddir: new Map([
["/tmp/codehub-proleap-abcdef/cobol-parser/target", ["proleap-cobol-parser-4.0.0.jar"]],
]),
// The built jar exists, but the wrapper source path does NOT — so the
// pre-flight `exists(javaSource)` check fails.
fsFiles: new Set([
"/tmp/codehub-proleap-abcdef/cobol-parser/target/proleap-cobol-parser-4.0.0.jar",
]),
});
const proc = makeProcessApi(script);
await assert.rejects(
runSetupCobolProleap({
processApi: proc,
vendorDir: "/test/vendor",
javaSourcePath: "/nope/cobol_to_scip.java",
log: () => undefined,
}),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.match(err.message, /wrapper Java source not found/);
assert.match(err.message, /dist\/java\/cobol_to_scip\.java/);
// Must NOT advertise an unreachable CLI flag.
assert.doesNotMatch(err.message, /--java-source/);
return true;
},
);
});
102 changes: 82 additions & 20 deletions packages/cli/src/cobol-proleap-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
* 3. `mvn install -DskipTests` to build the JAR. Target artifact is
* `<tmp>/target/proleap-cobol-parser-<ver>.jar`.
* 4. `javac -cp <jar> cobol_to_scip.java` — compile the wrapper class
* (the `.java` source ships under `packages/cobol-proleap/java/`).
* (the `.java` source ships inside `@opencodehub/cli` at `dist/java/`,
* copied there at build time from `packages/cobol-proleap/java/`).
* 5. Atomic rename the JAR + compiled wrapper into
* `~/.codehub/vendor/proleap/{proleap-cobol-parser-<ver>.jar,
* cobol_to_scip.class}`.
Expand All @@ -22,6 +23,7 @@
*/

import { spawn } from "node:child_process";
import { statSync } from "node:fs";
import {
copyFile as fsCopyFile,
mkdir as fsMkdir,
Expand All @@ -32,7 +34,7 @@ import {
stat as fsStat,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

export const COBOL_PROLEAP_REPO_URL = "https://github.com/uwol/cobol-parser";
Expand Down Expand Up @@ -221,7 +223,7 @@ export async function runSetupCobolProleap(
await cleanup(proc, workDir);
throw new Error(
`codehub setup --cobol-proleap: wrapper Java source not found at ${javaSource}. ` +
"Re-install @opencodehub/cobol-proleap or pass --java-source.",
"It ships inside @opencodehub/cli at dist/java/cobol_to_scip.java — reinstall the CLI to restore it.",
);
}
// Compile into the workDir so a failure doesn't pollute vendor/.
Expand Down Expand Up @@ -358,21 +360,76 @@ async function cleanup(proc: ProcessApi, dir: string): Promise<void> {
}
}

/** Cheap synchronous file-existence probe used only during path resolution. */
function fileExistsSync(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}

/**
* Resolve the wrapper Java source (`cobol_to_scip.java`).
*
* Three install shapes are covered, tried in order:
*
* 1. **Bundled CLI (the published-npm case).** Since PR #189 collapsed the
* monorepo into a single tarball, the wrapper source is copied into the
* CLI's own `dist/java/` (see `packages/cli/tsup.config.ts` onSuccess).
* The bundle runs from `dist/`, so we walk up from `import.meta.url`
* looking for `java/cobol_to_scip.java` — exactly the
* {@link resolveVendorWasmsDir}-style walk-up the WASM grammars use. This
* is the only shape an npm-installed user ever hits; without it every
* `codehub setup --cobol-proleap` fails with "wrapper Java source not
* found".
* 2. **Monorepo / source checkout.** The CLI runs from
* `packages/cli/dist` while `cobol_to_scip.java` lives in the sibling
* `packages/cobol-proleap/java/` workspace tree.
* 3. **Legacy per-package install.** `node_modules/@opencodehub/cobol-proleap/
* java/cobol_to_scip.java`, retained for pre-collapse layouts.
*
* Returns the first hit. If nothing exists on disk, falls back to the
* bundled-CLI path so the caller reports a clean "wrapper Java source not
* found" error rather than a bare ENOENT.
*/
export function resolveWrapperJavaSource(): string {
const startDir = dirname(fileURLToPath(import.meta.url));
return findWrapperJavaSourceFrom(startDir, fileExistsSync);
}

/**
* Resolve the wrapper Java source shipped in @opencodehub/cobol-proleap.
* Walks up from the installed CLI until it finds
* `packages/cobol-proleap/java/cobol_to_scip.java` (repo checkout) or
* `node_modules/@opencodehub/cobol-proleap/java/cobol_to_scip.java` (installed).
* Pure walk-up resolver, separated from {@link resolveWrapperJavaSource} so it
* is unit-testable without depending on the test module's own
* `import.meta.url`. `startDir` is the directory the CLI module runs from;
* `exists` is an injectable file-existence probe (defaults to a real
* `statSync`). See {@link resolveWrapperJavaSource} for the install-shape
* rationale.
*/
function resolveWrapperJavaSource(): string {
const thisFile = fileURLToPath(import.meta.url);
const dir = dirname(thisFile);
export function findWrapperJavaSourceFrom(
startDir: string,
exists: (path: string) => boolean = fileExistsSync,
): string {
// 1. Bundled deployment: walk up from the module looking for
// `java/cobol_to_scip.java` (lands at `dist/java/...`).
{
let dir = startDir;
for (let i = 0; i < 6; i += 1) {
const candidate = join(dir, "java", "cobol_to_scip.java");
if (exists(candidate)) return candidate;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}

// 2 & 3. Source-checkout / legacy per-package fallbacks.
const candidates = [
() => join(dir, "..", "..", "cobol-proleap", "java", "cobol_to_scip.java"),
() => join(dir, "..", "..", "..", "cobol-proleap", "java", "cobol_to_scip.java"),
() => join(startDir, "..", "..", "cobol-proleap", "java", "cobol_to_scip.java"),
() => join(startDir, "..", "..", "..", "cobol-proleap", "java", "cobol_to_scip.java"),
() =>
join(
dir,
startDir,
"..",
"..",
"..",
Expand All @@ -384,12 +441,17 @@ function resolveWrapperJavaSource(): string {
),
];
for (const fn of candidates) {
const p = resolve(fn());
// Sync existsSync is fine in this pre-flight path.
const { existsSync } = require("node:fs") as typeof import("node:fs");
if (existsSync(p)) return p;
// `join` (not `resolve`): the candidate is already an absolute,
// module-relative path with the `..` segments collapsed lexically.
// `resolve` would re-anchor a drive-less path to `process.cwd()`'s drive
// on Windows (injecting `C:`/`D:`), making a module-relative resolver
// depend on the cwd — a latent portability bug.
const p = fn();
if (exists(p)) return p;
}
// Fall back to the conventional repo layout; caller reports a clean
// "wrapper Java source not found" error if it's missing on disk.
return resolve(dir, "..", "..", "cobol-proleap", "java", "cobol_to_scip.java");

// Fall back to the bundled-CLI path; the caller reports a clean
// "wrapper Java source not found" error if it's missing on disk. `join`
// (not `resolve`) for the same cwd-independence reason as above.
return join(startDir, "java", "cobol_to_scip.java");
}
121 changes: 121 additions & 0 deletions packages/cli/src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,3 +496,124 @@ test("doctor surfaces no CODEHUB_STORE selector or optional-backend framing", as
await rm(home, { recursive: true, force: true });
}
});

// The lbug failure hint must carry the platform-support matrix (the shared
// `@opencodehub/storage` source of truth), not a bare "pnpm install" — on
// win32-arm64 / musl there is NO prebuilt, so a reinstall is futile and the
// hint must say so. Every lbug failure path threads through `lbugFailureHint`.
test("graph-db binding failure hint names the platform-support matrix, not a bare reinstall", async () => {
const home = await mkdtemp(join(tmpdir(), "codehub-doctor-lbug-hint-"));
try {
const checks = buildChecks({
home,
resolveBinding: (_root, pkg) => (pkg === "@ladybugdb/core" ? null : "/fake/duckdb"),
});
const lbug = checks.find((c) => c.name === "graph-db native binding");
assert.ok(lbug);
const result = await lbug.run();
assert.equal(result.status, "fail");
// The shared matrix string from @opencodehub/storage must be present so
// the user sees which platforms ship a prebuilt.
assert.match(result.hint ?? "", /Supported platforms:/);
assert.match(result.hint ?? "", /Windows x64/);
} finally {
await rm(home, { recursive: true, force: true });
}
});

// ---------------------------------------------------------------------------
// Embedder native binding (onnxruntime-node) — OPTIONAL, so absence is a
// NON-FATAL warn that degrades retrieval to BM25, never a hard fail.
// ---------------------------------------------------------------------------

// onnxruntime-node ships prebuilds for only ~5 targets (no Intel-mac, no musl).
// The real failure mode is a silent degrade to BM25 — the embedder open path
// catches the native-load error — so doctor must surface a `warn`, not a fail.
// Inject a loader that throws to exercise the absent-binding branch.
test("embedder binding check warns (not fails) when onnxruntime-node fails to load", async () => {
const home = await mkdtemp(join(tmpdir(), "codehub-doctor-onnx-miss-"));
try {
const checks = buildChecks({
home,
loadOnnxBinding: async () => {
throw new Error("Cannot find module 'onnxruntime-node'");
},
});
const emb = checks.find((c) => c.name === "embedder native binding");
assert.ok(emb, "embedder binding check must be registered when skipNative is false");
const result = await emb.run();
assert.equal(
result.status,
"warn",
`an absent OPTIONAL embedder binding is a soft warn; got ${result.status}: ${result.message}`,
);
assert.match(result.message, /BM25/);
// The hint must point at the remote-embedder escape hatch.
assert.match(result.hint ?? "", /CODEHUB_EMBEDDING_URL|CODEHUB_EMBEDDING_SAGEMAKER_ENDPOINT/);
} finally {
await rm(home, { recursive: true, force: true });
}
});

// A successful binding load (exports an InferenceSession constructor) is `ok`.
test("embedder binding check reports ok when onnxruntime-node loads with InferenceSession", async () => {
const home = await mkdtemp(join(tmpdir(), "codehub-doctor-onnx-ok-"));
try {
const checks = buildChecks({
home,
loadOnnxBinding: async () => ({ InferenceSession: function fake() {} }),
});
const emb = checks.find((c) => c.name === "embedder native binding");
assert.ok(emb);
const result = await emb.run();
assert.equal(result.status, "ok", `expected ok; got ${result.status}: ${result.message}`);
} finally {
await rm(home, { recursive: true, force: true });
}
});

// A module that loads but exports no InferenceSession is a `warn` (degrade),
// never a crash — the embedder is optional.
test("embedder binding check warns when the module loads but exports no InferenceSession", async () => {
const home = await mkdtemp(join(tmpdir(), "codehub-doctor-onnx-noctor-"));
try {
const checks = buildChecks({
home,
loadOnnxBinding: async () => ({}),
});
const emb = checks.find((c) => c.name === "embedder native binding");
assert.ok(emb);
const result = await emb.run();
assert.equal(result.status, "warn", `expected warn; got ${result.status}: ${result.message}`);
} finally {
await rm(home, { recursive: true, force: true });
}
});

// The optional embedder binding must NOT escalate the doctor exit code: with
// a valid registry, a clean scanner runner, and the graph binding present
// (real dev install), a failed embedder load yields at most a warn (exit ≤ 1),
// never a blocking fail. This is the load-bearing "optional capability" guard.
test("embedder binding failure does not block the doctor exit (exit <= 1)", async () => {
const home = await mkdtemp(join(tmpdir(), "codehub-doctor-onnx-nonblock-"));
try {
await mkdir(join(home, ".codehub"), { recursive: true });
await writeFile(join(home, ".codehub", "registry.json"), JSON.stringify({}));
const prev = process.exitCode;
const report = await runDoctor({
home,
skipNative: true,
runCommand: okRunCommand,
});
// skipNative drops the real native probes; assert the embedder check is
// gated by skipNative too (no row, no exit-code contribution).
process.exitCode = prev;
const names = report.rows.map((r) => r.name);
assert.ok(
!names.includes("embedder native binding"),
"embedder binding probe is a native check — skipNative must drop it",
);
} finally {
await rm(home, { recursive: true, force: true });
}
});
Loading
Loading