Skip to content

Commit a78dbf5

Browse files
committed
refactor(build): replace relative-depth self-reference guessing with Node's own package self-referencing
The previous commit's fix for the cross-context (in-process test vs compiled dist/) self-relative-path problem was a "try depth N, then depth N+1" heuristic (resolveOwnPackageSiblingPath / resolveMonorepoSiblingPath). It worked, but it's not the correct tool for the job: Node has a purpose-built, standard mechanism for exactly this -- a package importing its own files by its own published name ("self-referencing"), resolved via the package.json "exports" map the same way any external "@loopover/x/..." import would be. That's robust by construction (walks up through node_modules from the calling file's own location, landing on the one real file regardless of that file's current directory depth) rather than by guessing candidate depths. Adds a minimal "exports" map to both packages' package.json (own package.json + the one or two other self-referenced files each needs) and switches every "own package.json"-style self-reference -- bin/loopover-mcp.ts (package.json, CHANGELOG.md), lib/version.ts's static JSON import, lib/status.ts's two require() calls, and bin/loopover-miner-mcp.ts -- to import.meta.resolve()/self-referencing require() against the published package name instead. The one remaining depth-guessing fallback (lib/status.ts's monorepo-sibling loopover-engine lookup) is intentionally left as-is: it's a last-resort fallback for when real node_modules resolution has already failed, so a self-referencing lookup (which uses the identical resolution mechanism) wouldn't diversify the fallback at all -- trying both plausible sibling-directory depths is the actually-correct strategy for that specific case, not a shortcut. Validated: typecheck clean, both packages rebuild cleanly, full unsharded suite still 21,541/21,541 passing, and both self-referencing subpaths resolve correctly via direct node -e checks.
1 parent a93cd53 commit a78dbf5

5 files changed

Lines changed: 39 additions & 26 deletions

File tree

packages/loopover-mcp/bin/loopover-mcp.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createHash } from "node:crypto";
55
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
66
import { homedir } from "node:os";
77
import { delimiter, dirname, join } from "node:path";
8-
import { fileURLToPath, pathToFileURL } from "node:url";
8+
import { fileURLToPath } from "node:url";
99
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
1010
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1111
import { buildFeasibilityVerdict, buildPrTextLint, buildGateDispositions, buildPublicPrBodyDraft } from "@loopover/engine";
@@ -48,25 +48,24 @@ import { redactKnownLocalPaths, redactLocalPath } from "../lib/redact-local-path
4848
// side by side unaliased would read as the same function (#6238).
4949
import { recordMcpToolCall as recordLocalMcpToolCall } from "../lib/telemetry.js";
5050

51-
// Path to a file next to this package's own root, computed relative to THIS module's own on-disk
52-
// location -- which differs by one directory level depending on how this file is currently running:
53-
// imported in-process (e.g. by the vitest unit tests that exercise the CLI dispatcher + stdio tools
54-
// directly against bin/loopover-mcp.ts, per the invokedPath check below) resolves relative to the
55-
// real source bin/, while a real CLI invocation resolves relative to the compiled dist/bin/ (one
56-
// level deeper, since the 2026-07-24 dist/ migration; see tsconfig.json's outDir comment). A single
57-
// hardcoded relative depth can only ever be correct for one of those two contexts, so this tries
58-
// both, preferring whichever the current on-disk layout actually has.
59-
function resolveOwnPackageSiblingPath(...segments: string[]): URL {
60-
const here = dirname(fileURLToPath(import.meta.url));
61-
const fromBin = join(here, "..", ...segments);
62-
if (existsSync(fromBin)) return pathToFileURL(fromBin);
63-
return pathToFileURL(join(here, "..", "..", ...segments));
51+
// Self-referencing package import (Node's own mechanism for "resolve a file that belongs to my own
52+
// package, correctly, regardless of my own current location on disk") -- requires the "exports" map
53+
// in this package's own package.json (the "./x": "./x" entries there are what make this resolvable
54+
// at all). Robust by construction, not by guessing: whether this file is running as the real source
55+
// bin/loopover-mcp.ts (imported in-process by tests that exercise the CLI dispatcher + stdio tools
56+
// directly, per the invokedPath check below) or as the compiled dist/bin/loopover-mcp.js (a real CLI
57+
// invocation), import.meta.resolve walks up from THIS file's own location through node_modules the
58+
// same way any external consumer's "@loopover/mcp/..." import would, landing on the one real
59+
// package.json/CHANGELOG.md either way -- no relative-depth arithmetic, so a future directory move
60+
// can never silently break this again the way the pre-dist/-migration relative path did.
61+
function resolveOwnPackageFile(specifier: string): URL {
62+
return new URL(import.meta.resolve(specifier));
6463
}
6564

6665
// Read name/version from this package's own package.json (always present in any install --
6766
// global, npx, or local -- npm ships it regardless of the "files" allowlist) instead of hand-synced
6867
// literals, so a release bump never has a second place to forget.
69-
const ownPackageJson = JSON.parse(readFileSync(resolveOwnPackageSiblingPath("package.json"), "utf8"));
68+
const ownPackageJson = JSON.parse(readFileSync(resolveOwnPackageFile("@loopover/mcp/package.json"), "utf8"));
7069

7170
const defaultApiUrl = "https://api.loopover.ai";
7271
const legacyDefaultApiUrls = new Set([
@@ -86,7 +85,7 @@ const decisionPackCacheSchemaVersion = 1;
8685
const decisionPackCacheMaxEntries = 25;
8786
const decisionPackCacheMaxBytes = 512 * 1024;
8887
const cliTextFileMaxBytes = 1024 * 1024;
89-
const changelogPath = resolveOwnPackageSiblingPath("CHANGELOG.md");
88+
const changelogPath = resolveOwnPackageFile("@loopover/mcp/CHANGELOG.md");
9089
const cliArgs = process.argv.slice(2);
9190

9291
// #7764: true only when this file is the process entrypoint (`node .../loopover-mcp.js`, incl. via the npm

packages/loopover-mcp/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
"bin": {
2929
"loopover-mcp": "dist/bin/loopover-mcp.js"
3030
},
31+
"exports": {
32+
"./package.json": "./package.json",
33+
"./CHANGELOG.md": "./CHANGELOG.md"
34+
},
3135
"files": [
3236
"dist",
3337
"scripts",

packages/loopover-miner/bin/loopover-miner-mcp.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
#!/usr/bin/env node
22
import { readFileSync, realpathSync } from "node:fs";
3-
import { dirname, join } from "node:path";
43
import { fileURLToPath } from "node:url";
54
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
65
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -52,12 +51,17 @@ import { captureMinerErrorAndFlush, initMinerSentry } from "../lib/sentry.js";
5251
// from ORB's hosted, maintainer-authenticated loopover_get_outcome_calibration tool.
5352

5453
// Read the version from this package's own package.json (always shipped) rather than a hand-synced
55-
// literal, so a release bump never has a second place to forget -- same approach as the mcp harness.
56-
// Resolve via fileURLToPath(import.meta.url) (a string) rather than `new URL(...)` so the path never
57-
// materializes as a `URL` object -- the repo-root tsconfig this file is also checked under (its type
58-
// surface is imported by the MCP unit tests) resolves the global `URL` to a shape whose iterator lacks
59-
// `[Symbol.dispose]`, which readFileSync's node typings reject; a plain string sidesteps that entirely.
60-
const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "../package.json");
54+
// literal, so a release bump never has a second place to forget. Self-referencing package import
55+
// (requires the "exports" map in this package's own package.json) -- robust by construction to
56+
// however this file is currently running, whether as the real source bin/loopover-miner-mcp.ts
57+
// (imported in-process by test/unit/miner-mcp-*.test.ts) or the compiled dist/bin/loopover-miner-mcp.js
58+
// (a real CLI invocation): import.meta.resolve walks up from THIS file's own location through
59+
// node_modules the same way an external "@loopover/miner/..." import would, landing on the one real
60+
// package.json either way -- no relative-path arithmetic to break if this file ever moves again.
61+
// fileURLToPath (a plain string), not `new URL(...)` -- the repo-root tsconfig this file is also
62+
// checked under (its type surface is imported by the MCP unit tests) resolves the global `URL` to a
63+
// shape whose iterator lacks `[Symbol.dispose]`, which readFileSync's node typings reject.
64+
const packageJsonPath = fileURLToPath(import.meta.resolve("@loopover/miner/package.json"));
6165
const ownPackageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
6266

6367
/** Optional filters accepted by loopover_miner_get_audit_feed (#5158). */

packages/loopover-miner/lib/status.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export function buildEngineVersionDisplay(readInstalled: () => string | null = r
132132
if (installed) return installed;
133133
try {
134134
/* v8 ignore next -- package.json declares @loopover/engine in every supported miner build */
135-
return (requireFromHere()("../package.json") as PackageJsonShape).dependencies?.[ENGINE_PACKAGE] ?? null;
135+
return (requireFromHere()("@loopover/miner/package.json") as PackageJsonShape).dependencies?.[ENGINE_PACKAGE] ?? null;
136136
} catch {
137137
/* v8 ignore next -- import metadata/package resolution failure is bundler-only; normal Node tests resolve it */
138138
return null;
@@ -275,7 +275,7 @@ function checkEngineVersionSkew(): DoctorCheck {
275275

276276
/** The minimum Node major version from the package's `engines.node` floor (e.g. ">=22.13.0" → 22). */
277277
function requiredNodeMajor(): number {
278-
const engines = (requireFromHere()("../package.json") as PackageJsonShape).engines;
278+
const engines = (requireFromHere()("@loopover/miner/package.json") as PackageJsonShape).engines;
279279
/* v8 ignore next -- package.json's required engines.node is a build-time invariant */
280280
const match = typeof engines?.node === "string" ? engines.node.match(/(\d+)/) : null;
281281
/* v8 ignore next -- a matching engines.node floor always includes the captured major */

packages/loopover-miner/lib/version.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import ownPackageJson from "../package.json" with { type: "json" };
1+
// Self-referencing package import (requires the "exports" map in this package's own package.json) --
2+
// robust by construction to however this file is currently running, whether as the real source
3+
// lib/version.ts (imported in-process by tests) or the compiled dist/lib/version.js (a real CLI
4+
// invocation): resolution walks up from THIS file's own location through node_modules the same way an
5+
// external "@loopover/miner/..." import would, landing on the one real package.json either way -- no
6+
// relative-path arithmetic to break if this file ever moves again.
7+
import ownPackageJson from "@loopover/miner/package.json" with { type: "json" };
28

39
/** Package.json semver at import time — the laptop npm-install default. */
410
export const MINER_PACKAGE_VERSION: string = ownPackageJson.version;

0 commit comments

Comments
 (0)