@vitest-agent/engine 0.2.10 → 0.3.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/index.d.ts CHANGED
@@ -1370,6 +1370,15 @@ export declare const OutputRendererLive: Layer.Layer<OutputRenderer, never, neve
1370
1370
  * @public
1371
1371
  */
1372
1372
  export declare const APP_NAMESPACE = "vitest-agent";
1373
+ /**
1374
+ * Where the XDG data root lands when `XDG_DATA_HOME` is unset:
1375
+ * `$HOME/.local/share/vitest-agent` — the XDG spec default. `AppDirs` alone
1376
+ * would fall back to `$HOME/.vitest-agent`. Shared by `PathResolutionLive`
1377
+ * (reporter/MCP route) and `resolveHookPaths` (hook/sidecar route) so both
1378
+ * routes land in the same directory.
1379
+ * @public
1380
+ */
1381
+ export declare const DATA_FALLBACK_DIR = ".local/share/vitest-agent";
1373
1382
  /**
1374
1383
  * Composite layer providing every service `resolveDataPath` requires:
1375
1384
  * `AppDirs` (XDG path resolution), `VitestAgentConfigFile` (TOML
@@ -2418,6 +2427,21 @@ export interface SessionContext {
2418
2427
  * @public
2419
2428
  */
2420
2429
  export declare const parseSessionEnvExports: (content: string) => Record<string, string>;
2430
+ /**
2431
+ * The synchronous filesystem reads {@link recoverSessionContextFromSessionEnv}
2432
+ * performs. Each member throws on a missing or unreadable path, exactly as
2433
+ * the `node:fs` call it stands in for; recovery treats a throw as "skip".
2434
+ *
2435
+ * @public
2436
+ */
2437
+ export interface SessionEnvFileSystem {
2438
+ /** Entry names directly under `path` (`readdirSync`). */
2439
+ readonly readDirectory: (path: string) => ReadonlyArray<string>;
2440
+ /** The UTF-8 text of the file at `path` (`readFileSync(path, "utf8")`). */
2441
+ readonly readFile: (path: string) => string;
2442
+ /** Modification time of the file at `path`, in epoch milliseconds (`statSync(path).mtimeMs`). */
2443
+ readonly mtimeMs: (path: string) => number;
2444
+ }
2421
2445
  /**
2422
2446
  * Recover a {@link SessionContext} from the newest session-env hook file
2423
2447
  * whose `VITEST_AGENT_PROJECT_DIR` matches `projectDir`.
@@ -2431,12 +2455,15 @@ export declare const parseSessionEnvExports: (content: string) => Record<string,
2431
2455
  *
2432
2456
  * @param opts - `projectDir` to match against; `homeDir` is the caller's
2433
2457
  * home directory (ambient input — the MCP bin passes `os.homedir()`),
2434
- * under which `.claude/session-env` is read
2458
+ * under which `.claude/session-env` is read; `fileSystem` replaces the
2459
+ * `node:fs` reads (tests pass an in-memory volume) and defaults to the
2460
+ * real disk
2435
2461
  * @public
2436
2462
  */
2437
2463
  export declare const recoverSessionContextFromSessionEnv: (opts: {
2438
2464
  readonly projectDir: string;
2439
2465
  readonly homeDir: string;
2466
+ readonly fileSystem?: SessionEnvFileSystem;
2440
2467
  }) => SessionContext | null;
2441
2468
  //#endregion
2442
2469
  //#region src/project-dir.d.ts
@@ -2641,9 +2668,26 @@ export declare const resolveDataPath: (projectDir: string, options?: ResolveData
2641
2668
  * Always returns a non-empty string — falls back to the cwd basename
2642
2669
  * (or `"anonymous-project"`) so callers don't have to handle the
2643
2670
  * empty case.
2671
+ *
2672
+ * Synchronous and reads the real disk through `node:fs`; Effect callers
2673
+ * that already hold a `FileSystem` use
2674
+ * {@link resolveProjectKeyFromCwdEffect}, which applies the same rules.
2644
2675
  * @public
2645
2676
  */
2646
2677
  export declare const resolveProjectKeyFromCwd: (cwd: string) => string;
2678
+ /**
2679
+ * {@link resolveProjectKeyFromCwd} read through the ambient
2680
+ * `FileSystem` service instead of `node:fs` — same upward walk to the
2681
+ * nearest `package.json`, same precedence, same basename fallback.
2682
+ *
2683
+ * Never fails: a filesystem error while probing or reading is treated like
2684
+ * a missing or malformed `package.json`, exactly as the synchronous form
2685
+ * does.
2686
+ *
2687
+ * @param cwd - Absolute directory to start the upward walk from.
2688
+ * @public
2689
+ */
2690
+ export declare const resolveProjectKeyFromCwdEffect: (cwd: string) => Effect.Effect<string, never, FileSystem.FileSystem>;
2647
2691
  //#endregion
2648
2692
  //#region src/utils/resolve-workspace-key.d.ts
2649
2693
  /**
package/index.js CHANGED
@@ -22,7 +22,7 @@ import { LoggerLive, resolveLogFile, resolveLogLevel } from "./layers/LoggerLive
22
22
  import { OutputRenderer } from "./services/OutputRenderer.js";
23
23
  import { OutputRendererLive } from "./layers/OutputRendererLive.js";
24
24
  import { OutputPipelineLive } from "./layers/OutputPipelineLive.js";
25
- import { APP_NAMESPACE, PathResolutionLive } from "./layers/PathResolutionLive.js";
25
+ import { APP_NAMESPACE, DATA_FALLBACK_DIR, PathResolutionLive } from "./layers/PathResolutionLive.js";
26
26
  import { PerClientSessionMapReader, PerClientSessionMapWriter } from "./services/PerClientSessionMap.js";
27
27
  import { PerClientSessionMapReaderLive, PerClientSessionMapWriterLive } from "./layers/PerClientSessionMapLive.js";
28
28
  import { ProjectDiscovery } from "./services/ProjectDiscovery.js";
@@ -55,9 +55,9 @@ import { resolveProjectDir } from "./project-dir.js";
55
55
  import { assembleHistoryRecord, assembleManifest } from "./sql/assemblers.js";
56
56
  import { _resetMigrationCacheForTesting, ensureMigrated } from "./utils/ensure-migrated.js";
57
57
  import { computeFailureSignature, normalizeAssertionShape } from "./utils/failure-signature.js";
58
- import { resolveProjectKeyFromCwd } from "./utils/resolve-project-key-from-cwd.js";
58
+ import { resolveProjectKeyFromCwd, resolveProjectKeyFromCwdEffect } from "./utils/resolve-project-key-from-cwd.js";
59
59
  import { DATABASE_FILENAME, resolveDataPath } from "./utils/resolve-data-path.js";
60
60
  import { resolveWorkspaceKey } from "./utils/resolve-workspace-key.js";
61
61
  import { CURRENT_ENGINE_VERSION } from "./version.js";
62
62
 
63
- export { APP_NAMESPACE, AgentContext, CURRENT_ENGINE_VERSION, ConfigLive, DATABASE_FILENAME, DATA_DB_FILENAME, DataReader, DataReaderLive, DataStore, DataStoreLive, DetailResolver, DetailResolverLive, DiscoveryRegistry, DiscoveryRegistryLive, EnvironmentDetector, EnvironmentDetectorLive, EnvironmentDetectorTest, ExecutorResolver, ExecutorResolverLive, FormatSelector, FormatSelectorLive, HistoryTracker, HistoryTrackerLive, HistoryTrackerTest, IDEMPOTENCY_ROOT_SENTINEL, INLINE_ATTACHMENT_BODY_CAP_BYTES, LoggerLive, NodePlatformLayer, OutputPipelineLive, OutputRenderer, OutputRendererLive, PROJECT_MIGRATIONS, PathResolutionLive, PerClientSessionMapReader, PerClientSessionMapReaderLive, PerClientSessionMapWriter, PerClientSessionMapWriterLive, PlatformLive, ProjectDiscovery, ProjectDiscoveryLive, ProjectDiscoveryTest, ProjectIdentity, ProjectIdentityLive, ProjectIdentityTest, REGISTRY_DB_FILENAME, RunContext, RunContext$, RunContextLive, RunContextService, RunContextTest, SESSIONS_DB_FILENAME, SidecarPlatformLive, VitestAgentConfigFile, _resetMigrationCacheForTesting, assembleHistoryRecord, assembleManifest, classifyEnvironment, computeFailureSignature, deriveIdempotencyKey, dispatchRecordTddArtifactEffect, endAgentEffect, ensureMigrated, formatTriageEffect, formatWrapupEffect, historyKey, makeSqliteStack, migration as migration0001, migration$1 as migration0002, normalizeAssertionShape, parseAndValidateTurnPayload, parseSessionEnvExports, recordRunWorkspaceChangesEffect, recordSessionEnd, recordSessionStart, recordTddArtifactByTaskIdEffect, recordTddArtifactEffect, recordTurnEffect, recoverSessionContextFromSessionEnv, registerAgentEffect, migration$2 as registryMigration0001, resolveDataPath, resolveHookPaths, resolveLogFile, resolveLogLevel, resolveProjectDir, resolveProjectIdentityFromCandidates, resolveProjectKeyFromCwd, resolveSessionForRecording, resolveSessionMapPath, resolveWorkspaceKey, migration$3 as sessionMapMigration0001 };
63
+ export { APP_NAMESPACE, AgentContext, CURRENT_ENGINE_VERSION, ConfigLive, DATABASE_FILENAME, DATA_DB_FILENAME, DATA_FALLBACK_DIR, DataReader, DataReaderLive, DataStore, DataStoreLive, DetailResolver, DetailResolverLive, DiscoveryRegistry, DiscoveryRegistryLive, EnvironmentDetector, EnvironmentDetectorLive, EnvironmentDetectorTest, ExecutorResolver, ExecutorResolverLive, FormatSelector, FormatSelectorLive, HistoryTracker, HistoryTrackerLive, HistoryTrackerTest, IDEMPOTENCY_ROOT_SENTINEL, INLINE_ATTACHMENT_BODY_CAP_BYTES, LoggerLive, NodePlatformLayer, OutputPipelineLive, OutputRenderer, OutputRendererLive, PROJECT_MIGRATIONS, PathResolutionLive, PerClientSessionMapReader, PerClientSessionMapReaderLive, PerClientSessionMapWriter, PerClientSessionMapWriterLive, PlatformLive, ProjectDiscovery, ProjectDiscoveryLive, ProjectDiscoveryTest, ProjectIdentity, ProjectIdentityLive, ProjectIdentityTest, REGISTRY_DB_FILENAME, RunContext, RunContext$, RunContextLive, RunContextService, RunContextTest, SESSIONS_DB_FILENAME, SidecarPlatformLive, VitestAgentConfigFile, _resetMigrationCacheForTesting, assembleHistoryRecord, assembleManifest, classifyEnvironment, computeFailureSignature, deriveIdempotencyKey, dispatchRecordTddArtifactEffect, endAgentEffect, ensureMigrated, formatTriageEffect, formatWrapupEffect, historyKey, makeSqliteStack, migration as migration0001, migration$1 as migration0002, normalizeAssertionShape, parseAndValidateTurnPayload, parseSessionEnvExports, recordRunWorkspaceChangesEffect, recordSessionEnd, recordSessionStart, recordTddArtifactByTaskIdEffect, recordTddArtifactEffect, recordTurnEffect, recoverSessionContextFromSessionEnv, registerAgentEffect, migration$2 as registryMigration0001, resolveDataPath, resolveHookPaths, resolveLogFile, resolveLogLevel, resolveProjectDir, resolveProjectIdentityFromCandidates, resolveProjectKeyFromCwd, resolveProjectKeyFromCwdEffect, resolveSessionForRecording, resolveSessionMapPath, resolveWorkspaceKey, migration$3 as sessionMapMigration0001 };
@@ -606,14 +606,28 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
606
606
  const runs = yield* sql`SELECT id FROM test_runs WHERE project = ${project} ORDER BY timestamp DESC LIMIT 1`;
607
607
  return runs.length === 0 ? null : runs[0].id;
608
608
  });
609
- const attachmentsFor = (column, ownerId) => Effect.gen(function* () {
610
- return (column === "annotation_id" ? yield* sql`SELECT content_type, path, body, body_encoding, byte_size FROM attachments WHERE annotation_id = ${ownerId} ORDER BY id` : yield* sql`SELECT content_type, path, body, body_encoding, byte_size FROM attachments WHERE artifact_id = ${ownerId} ORDER BY id`).map((r) => ({
611
- ...r.content_type !== null && { contentType: r.content_type },
612
- ...r.path !== null && { path: r.path },
613
- ...r.body !== null && { body: r.body },
614
- ...r.body_encoding !== null && { bodyEncoding: r.body_encoding },
615
- byteSize: r.byte_size
616
- }));
609
+ /**
610
+ * Load the attachments of every owner in `ownerIds` with a single query,
611
+ * grouped by owner id. Per-owner order follows `attachments.id`. An empty
612
+ * owner list issues no query.
613
+ */
614
+ const attachmentsForMany = (column, ownerIds) => Effect.gen(function* () {
615
+ const grouped = /* @__PURE__ */ new Map();
616
+ if (ownerIds.length === 0) return grouped;
617
+ const rows = column === "annotation_id" ? yield* sql`SELECT annotation_id AS owner_id, content_type, path, body, body_encoding, byte_size FROM attachments WHERE annotation_id IN ${sql.in(ownerIds)} ORDER BY id` : yield* sql`SELECT artifact_id AS owner_id, content_type, path, body, body_encoding, byte_size FROM attachments WHERE artifact_id IN ${sql.in(ownerIds)} ORDER BY id`;
618
+ for (const r of rows) {
619
+ const attachment = {
620
+ ...r.content_type !== null && { contentType: r.content_type },
621
+ ...r.path !== null && { path: r.path },
622
+ ...r.body !== null && { body: r.body },
623
+ ...r.body_encoding !== null && { bodyEncoding: r.body_encoding },
624
+ byteSize: r.byte_size
625
+ };
626
+ const list = grouped.get(r.owner_id);
627
+ if (list === void 0) grouped.set(r.owner_id, [attachment]);
628
+ else list.push(attachment);
629
+ }
630
+ return grouped;
617
631
  });
618
632
  const getAnnotationsForTest = (project, fullName, options) => Effect.gen(function* () {
619
633
  yield* Effect.logDebug("getAnnotationsForTest").pipe(Effect.annotateLogs({
@@ -631,9 +645,10 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
631
645
  WHERE tm.run_id = ${runId} AND tc.full_name = ${fullName}
632
646
  AND (${modulePath} IS NULL OR tm.relative_module_id = ${modulePath})
633
647
  ORDER BY ta.id`;
648
+ const attachmentsByOwner = yield* attachmentsForMany("annotation_id", rows.map((r) => r.id));
634
649
  const out = [];
635
650
  for (const r of rows) {
636
- const attachments = yield* attachmentsFor("annotation_id", r.id);
651
+ const attachments = attachmentsByOwner.get(r.id) ?? [];
637
652
  out.push({
638
653
  id: r.id,
639
654
  type: r.type,
@@ -668,9 +683,10 @@ const DataReaderLive = Layer.effect(DataReader, Effect.gen(function* () {
668
683
  WHERE tm.run_id = ${runId} AND tc.full_name = ${fullName}
669
684
  AND (${modulePath} IS NULL OR tm.relative_module_id = ${modulePath})
670
685
  ORDER BY ta.id`;
686
+ const attachmentsByOwner = yield* attachmentsForMany("artifact_id", rows.map((r) => r.id));
671
687
  const out = [];
672
688
  for (const r of rows) {
673
- const attachments = yield* attachmentsFor("artifact_id", r.id);
689
+ const attachments = attachmentsByOwner.get(r.id) ?? [];
674
690
  out.push({
675
691
  id: r.id,
676
692
  type: r.type,
@@ -11,12 +11,24 @@ import { AppDirs, Xdg } from "@effected/xdg";
11
11
  */
12
12
  const APP_NAMESPACE = "vitest-agent";
13
13
  /**
14
+ * Where the XDG data root lands when `XDG_DATA_HOME` is unset:
15
+ * `$HOME/.local/share/vitest-agent` — the XDG spec default. `AppDirs` alone
16
+ * would fall back to `$HOME/.vitest-agent`. Shared by `PathResolutionLive`
17
+ * (reporter/MCP route) and `resolveHookPaths` (hook/sidecar route) so both
18
+ * routes land in the same directory.
19
+ * @public
20
+ */
21
+ const DATA_FALLBACK_DIR = `.local/share/${APP_NAMESPACE}`;
22
+ /**
14
23
  * `AppDirs` over the ambient `Xdg` environment, bound to a `const` so the
15
24
  * layer memoizes by reference (calling `AppDirs.layer(...)` inline at two
16
25
  * provide sites would resolve two independent services). Requires
17
26
  * `FileSystem` + `Path` at the edge.
18
27
  */
19
- const AppDirsLive = AppDirs.layer({ namespace: APP_NAMESPACE }).pipe(Layer.provide(Xdg.layer));
28
+ const AppDirsLive = AppDirs.layer({
29
+ namespace: APP_NAMESPACE,
30
+ fallbackDir: DATA_FALLBACK_DIR
31
+ }).pipe(Layer.provide(Xdg.layer));
20
32
  /**
21
33
  * Composite layer providing every service `resolveDataPath` requires:
22
34
  * `AppDirs` (XDG path resolution), `VitestAgentConfigFile` (TOML
@@ -43,4 +55,4 @@ const PathResolutionLive = (projectDir) => {
43
55
  };
44
56
 
45
57
  //#endregion
46
- export { APP_NAMESPACE, PathResolutionLive };
58
+ export { APP_NAMESPACE, DATA_FALLBACK_DIR, PathResolutionLive };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/engine",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Shared services, layers, SQLite data layer, migrations and platform resolution for the vitest-agent CLI and MCP server.",
6
6
  "keywords": [
@@ -42,14 +42,14 @@
42
42
  "dependencies": {
43
43
  "@effect/platform-node": "4.0.0-rc.118",
44
44
  "@effect/sql-sqlite-node": "4.0.0-rc.118",
45
- "@effected/config-file": "^0.13.0",
45
+ "@effected/config-file": "^0.13.1",
46
46
  "@effected/engine": "^0.2.0",
47
47
  "@effected/glob": "^0.9.0",
48
48
  "@effected/jsonc": "^0.14.0",
49
49
  "@effected/toml": "^0.10.0",
50
- "@effected/walker": "^0.13.0",
51
- "@effected/workspaces": "^0.30.1",
52
- "@effected/xdg": "^0.8.0",
50
+ "@effected/walker": "^0.14.0",
51
+ "@effected/workspaces": "^0.30.2",
52
+ "@effected/xdg": "^0.8.1",
53
53
  "@effected/yaml": "^0.18.0",
54
54
  "@vitest-agent/sdk": "5.1.3",
55
55
  "effect": "4.0.0-rc.118",
@@ -1,4 +1,4 @@
1
- import { APP_NAMESPACE } from "../layers/PathResolutionLive.js";
1
+ import { APP_NAMESPACE, DATA_FALLBACK_DIR } from "../layers/PathResolutionLive.js";
2
2
  import { ProjectIdentityNotResolvableError } from "@vitest-agent/sdk";
3
3
  import { ConfigProvider, Effect, FileSystem, Layer, Path } from "effect";
4
4
  import { AppDirs, Xdg } from "@effected/xdg";
@@ -22,12 +22,6 @@ const SESSIONS_DB_FILENAME = "sessions.db";
22
22
  * @public
23
23
  */
24
24
  const REGISTRY_DB_FILENAME = "registry.db";
25
- /**
26
- * Where the XDG data root lands when `XDG_DATA_HOME` is unset:
27
- * `$HOME/.local/share/vitest-agent` — the XDG spec default the sidecar has
28
- * always used. (`AppDirs` alone would fall back to `$HOME/.vitest-agent`.)
29
- */
30
- const HOOK_DATA_FALLBACK_DIR = `.local/share/${APP_NAMESPACE}`;
31
25
  /** Directory under the home dir that holds the per-client `sessions.db` fallback. */
32
26
  const SESSION_MAP_HOME_DIR = `.${APP_NAMESPACE}`;
33
27
  const nonEmpty = (value) => value !== void 0 && value.length > 0 ? value : void 0;
@@ -48,7 +42,7 @@ const hookAppDirs = (env) => {
48
42
  const XdgLive = Xdg.layer.pipe(Layer.provide(ConfigProvider.layer(provider)));
49
43
  return AppDirs.layer({
50
44
  namespace: APP_NAMESPACE,
51
- fallbackDir: HOOK_DATA_FALLBACK_DIR
45
+ fallbackDir: DATA_FALLBACK_DIR
52
46
  }).pipe(Layer.provide(XdgLive));
53
47
  };
54
48
  /**
@@ -32,6 +32,12 @@ const parseSessionEnvExports = (content) => {
32
32
  }
33
33
  return out;
34
34
  };
35
+ /** The default {@link SessionEnvFileSystem}: the real disk through `node:fs`. */
36
+ const nodeSessionEnvFileSystem = {
37
+ readDirectory: (path) => readdirSync(path),
38
+ readFile: (path) => readFileSync(path, "utf8"),
39
+ mtimeMs: (path) => statSync(path).mtimeMs
40
+ };
35
41
  /**
36
42
  * Recover a {@link SessionContext} from the newest session-env hook file
37
43
  * whose `VITEST_AGENT_PROJECT_DIR` matches `projectDir`.
@@ -45,15 +51,18 @@ const parseSessionEnvExports = (content) => {
45
51
  *
46
52
  * @param opts - `projectDir` to match against; `homeDir` is the caller's
47
53
  * home directory (ambient input — the MCP bin passes `os.homedir()`),
48
- * under which `.claude/session-env` is read
54
+ * under which `.claude/session-env` is read; `fileSystem` replaces the
55
+ * `node:fs` reads (tests pass an in-memory volume) and defaults to the
56
+ * real disk
49
57
  * @public
50
58
  */
51
59
  const recoverSessionContextFromSessionEnv = (opts) => {
60
+ const fs = opts.fileSystem ?? nodeSessionEnvFileSystem;
52
61
  const root = join(opts.homeDir, ".claude", "session-env");
53
62
  const wantDir = resolve(opts.projectDir);
54
63
  let entries;
55
64
  try {
56
- entries = readdirSync(root);
65
+ entries = fs.readDirectory(root);
57
66
  } catch {
58
67
  return null;
59
68
  }
@@ -61,15 +70,15 @@ const recoverSessionContextFromSessionEnv = (opts) => {
61
70
  for (const entry of entries) {
62
71
  const file = join(root, entry, "vitest-agent-hook.sh");
63
72
  try {
64
- const st = statSync(file);
65
- const env = parseSessionEnvExports(readFileSync(file, "utf8"));
73
+ const mtimeMs = fs.mtimeMs(file);
74
+ const env = parseSessionEnvExports(fs.readFile(file));
66
75
  const chatId = env.VITEST_AGENT_CHAT_ID;
67
76
  const conversationId = env.VITEST_AGENT_CONVERSATION_ID;
68
77
  const mainAgentId = env.VITEST_AGENT_MAIN_AGENT_ID ?? env.VITEST_AGENT_AGENT_ID;
69
78
  const fileProjectDir = env.VITEST_AGENT_PROJECT_DIR;
70
79
  if (chatId === void 0 || chatId.length === 0 || conversationId === void 0 || conversationId.length === 0 || mainAgentId === void 0 || mainAgentId.length === 0 || fileProjectDir === void 0 || resolve(fileProjectDir) !== wantDir) continue;
71
- if (best === null || st.mtimeMs > best.mtimeMs) best = {
72
- mtimeMs: st.mtimeMs,
80
+ if (best === null || mtimeMs > best.mtimeMs) best = {
81
+ mtimeMs,
73
82
  ctx: {
74
83
  chatId,
75
84
  conversationId,
@@ -1,5 +1,5 @@
1
1
  import { VitestAgentConfigFile } from "../services/Config.js";
2
- import { resolveProjectKeyFromCwd } from "./resolve-project-key-from-cwd.js";
2
+ import { resolveProjectKeyFromCwdEffect } from "./resolve-project-key-from-cwd.js";
3
3
  import { VitestAgentConfig, normalizeWorkspaceKey } from "@vitest-agent/sdk";
4
4
  import { Effect, FileSystem } from "effect";
5
5
  import { AppDirs } from "@effected/xdg";
@@ -54,7 +54,7 @@ const resolveDataPath = (projectDir, options = {}) => Effect.gen(function* () {
54
54
  return join(loaded.cacheDir, DATABASE_FILENAME);
55
55
  }
56
56
  const dataRoot = yield* (yield* AppDirs).ensureData;
57
- const key = loaded.projectKey ? normalizeWorkspaceKey(loaded.projectKey) : resolveProjectKeyFromCwd(projectDir);
57
+ const key = loaded.projectKey ? normalizeWorkspaceKey(loaded.projectKey) : yield* resolveProjectKeyFromCwdEffect(projectDir);
58
58
  const dir = join(dataRoot, key);
59
59
  yield* fs.makeDirectory(dir, { recursive: true });
60
60
  return join(dir, DATABASE_FILENAME);
@@ -1,4 +1,5 @@
1
1
  import { gitUrlToProjectKey, normalizeWorkspaceKey } from "@vitest-agent/sdk";
2
+ import { Effect, FileSystem } from "effect";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { dirname, join } from "node:path";
4
5
 
@@ -24,6 +25,27 @@ const readRepositoryUrl = (parsed) => {
24
25
  return null;
25
26
  };
26
27
  /**
28
+ * The key a `package.json` body names: the canonical `host__path` form of
29
+ * `repository.url` when present and canonicalizable, else the normalized
30
+ * `name`, else `null` (no usable field, or malformed JSON).
31
+ */
32
+ const keyFromPackageJson = (content) => {
33
+ try {
34
+ const parsed = JSON.parse(content);
35
+ const repoUrl = readRepositoryUrl(parsed);
36
+ if (repoUrl !== null) {
37
+ const key = gitUrlToProjectKey(repoUrl);
38
+ if (key !== null) return key;
39
+ }
40
+ if (typeof parsed.name === "string" && parsed.name.length > 0) return normalizeWorkspaceKey(parsed.name);
41
+ } catch {}
42
+ return null;
43
+ };
44
+ const basenameKey = (cwd) => {
45
+ const basename = cwd.split("/").filter((segment) => segment.length > 0).pop() ?? "anonymous-project";
46
+ return normalizeWorkspaceKey(basename);
47
+ };
48
+ /**
27
49
  * Compute the project key for a workspace by reading `package.json`
28
50
  * fields directly. Returns the canonical `host__path` form when a git
29
51
  * remote URL is present, otherwise the normalized package name.
@@ -31,22 +53,50 @@ const readRepositoryUrl = (parsed) => {
31
53
  * Always returns a non-empty string — falls back to the cwd basename
32
54
  * (or `"anonymous-project"`) so callers don't have to handle the
33
55
  * empty case.
56
+ *
57
+ * Synchronous and reads the real disk through `node:fs`; Effect callers
58
+ * that already hold a `FileSystem` use
59
+ * {@link resolveProjectKeyFromCwdEffect}, which applies the same rules.
34
60
  * @public
35
61
  */
36
62
  const resolveProjectKeyFromCwd = (cwd) => {
37
63
  const pkgPath = findNearestPackageJson(cwd);
38
- if (pkgPath !== null) try {
39
- const parsed = JSON.parse(readFileSync(pkgPath, "utf-8"));
40
- const repoUrl = readRepositoryUrl(parsed);
41
- if (repoUrl !== null) {
42
- const key = gitUrlToProjectKey(repoUrl);
43
- if (key !== null) return key;
44
- }
45
- if (typeof parsed.name === "string" && parsed.name.length > 0) return normalizeWorkspaceKey(parsed.name);
46
- } catch {}
47
- const basename = cwd.split("/").filter((segment) => segment.length > 0).pop() ?? "anonymous-project";
48
- return normalizeWorkspaceKey(basename);
64
+ if (pkgPath !== null) {
65
+ let content = null;
66
+ try {
67
+ content = readFileSync(pkgPath, "utf-8");
68
+ } catch {}
69
+ const key = content === null ? null : keyFromPackageJson(content);
70
+ if (key !== null) return key;
71
+ }
72
+ return basenameKey(cwd);
49
73
  };
74
+ /**
75
+ * {@link resolveProjectKeyFromCwd} read through the ambient
76
+ * `FileSystem` service instead of `node:fs` — same upward walk to the
77
+ * nearest `package.json`, same precedence, same basename fallback.
78
+ *
79
+ * Never fails: a filesystem error while probing or reading is treated like
80
+ * a missing or malformed `package.json`, exactly as the synchronous form
81
+ * does.
82
+ *
83
+ * @param cwd - Absolute directory to start the upward walk from.
84
+ * @public
85
+ */
86
+ const resolveProjectKeyFromCwdEffect = (cwd) => Effect.gen(function* () {
87
+ const fs = yield* FileSystem.FileSystem;
88
+ let dir = cwd;
89
+ while (true) {
90
+ const candidate = join(dir, PACKAGE_JSON);
91
+ if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
92
+ const content = yield* fs.readFileString(candidate).pipe(Effect.orElseSucceed(() => null));
93
+ return (content === null ? null : keyFromPackageJson(content)) ?? basenameKey(cwd);
94
+ }
95
+ const parent = dirname(dir);
96
+ if (parent === dir) return basenameKey(cwd);
97
+ dir = parent;
98
+ }
99
+ });
50
100
 
51
101
  //#endregion
52
- export { resolveProjectKeyFromCwd };
102
+ export { resolveProjectKeyFromCwd, resolveProjectKeyFromCwdEffect };
package/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  //#region src/version.ts
2
2
  /** Build-time injected package version (see architecture.md D36). @public */
3
- const CURRENT_ENGINE_VERSION = "0.2.10";
3
+ const CURRENT_ENGINE_VERSION = "0.3.0";
4
4
 
5
5
  //#endregion
6
6
  export { CURRENT_ENGINE_VERSION };