diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24b0e68 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Checkout must stay LF everywhere: biome's format gate and the docs-as-code +# evals compare exact bytes, and the default autocrlf=true on Windows would +# turn every file CRLF and fail them. +* text=auto eol=lf +*.so binary +*.dylib binary +*.dll binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83d890b..bf445dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,32 @@ jobs: - name: Hot-path bench run: npx tsx src/cli.ts bench + + test-windows: + name: test (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type-check + run: npx tsc --noEmit + + - name: Lint + run: npx biome check src test + + - name: Unit and integration tests + run: npx vitest run + + - name: Offline eval gates + run: npx tsx src/cli.ts eval all + + - name: Hot-path bench + run: npx tsx src/cli.ts bench diff --git a/AGENT_USAGE.md b/AGENT_USAGE.md index 189e1ff..d1ac33e 100644 --- a/AGENT_USAGE.md +++ b/AGENT_USAGE.md @@ -191,7 +191,9 @@ You should see exactly 8 tools listed. If multiple agents in the same repo will hit graphCTX at the same time (Claude Desktop + Cursor + a CI worker), start one daemon and point all of them at the same Unix socket. There is no TCP listener; the path is -filesystem-local only. +filesystem-local only. On Windows the same flag works: the path is mapped +into the named-pipe namespace (`\\.\pipe\...`) automatically, since Node +has no Unix-socket support there. ```bash # start once per machine / repo diff --git a/README.md b/README.md index 280762a..1690849 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ npx tsx src/cli.ts bench Expected current state: -- `npx vitest run`: 292 tests pass. +- `npx vitest run`: 297 tests pass. - `npm run pack:smoke`: packed tarball installs, runs the demo, and serves MCP from a clean temp app. - `npx tsx src/cli.ts eval all`: 19 gate suites pass. diff --git a/docs/STATUS.md b/docs/STATUS.md index fa0566b..9e3b03c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -76,9 +76,9 @@ |---|---|---| | `hook ` p95 | < 150ms | 15.51ms ✅ | -_Last updated: Private-key scanning now covers DSA, encrypted PEM, and PGP private key block headers, MCP `resolve_conflict` now evaluates user, workspace, and current-session facts together, `graphctx resolve` now refuses non-open-loop facts instead of silently superseding durable memory, Storage migration 0006 adds a composite invalidation conflict lookup index, Session-scoped invalidation now keeps identical open loops isolated across sessions while deduping repeats within a session, Demo seed memory now uses Runtime.rememberFact, open-loop session memory now carries current git anchors, CLI `tui --tab` now fails fast on invalid tabs, CLI/MCP fact-kind validation uses a shared `FACT_KINDS` domain enum, TUI promotion uses Runtime/Probation gates instead of direct activation, Runtime/TUI/CLI/MCP explicit remember paths share safe git-anchored invalidating writes, Runtime/TUI/MCP forget paths share git-anchored temporal closeout, MCP forget stamps git closeout anchors, retrieval candidates are scope-filtered before semantic reranking, retrieval candidates receive final workspace/session scope filtering, superseded facts close temporal validity with `t_expired` and commit anchors, retrieval recency ranking uses the runtime clock, ranking/conflict tests use deterministic fact IDs and clocks, vector embedding cache uses the runtime clock, outcome telemetry uses an injectable clock, injection anti-repetition ledger uses the runtime clock, promotion verification timestamps use the runtime clock, promotion gates rescan secret-shaped fact identity, why renders invalidating commit closeout anchors, revert revalidation respects branch representation, revert revalidation skips bad historical anchors, atomic temporal lifecycle closeout anchors, TUI read model redacts secret fact text, conflict notes redact identity/object secrets, structured why redacts evidence chain metadata, structured why redacts fact identity and scope strings, structured why redacts promotion audit text, structured why redacts git anchors, structured why redacts evidence payloads and tags, why output shows expired/invalidated temporal closeout, send-edge safety scans fact tags for secrets, command cards avoid false HEAD verification claims, low-trust claims blocked from PreToolUse guardrail injection, candidate facts kept out of vector search until promotion, lifecycle-synced vector index entries for expired/reactivated facts, secret-redacted semantic rerank text, retriever-level send-unsafe fact suppression, tag-update secret sensitivity restamping, secret-redacted FTS/vector fact indexing, FTS path-query fail-soft handling, runtime-level injection context redaction, hook tool-result redaction before injection context, shared retrieval-context redaction for CLI/MCP/hooks, atomic invalidation effects, supported-by temporal evidence edges, tool-arg redaction before retrieval, hook prompt redaction before retrieval, transcript-tail redaction before retrieval, symlink-safe workspace config reads, symlink-safe local store paths, symlink-safe AGENTS.md boot capsule writes, symlink-safe generic adapter marker writes, symlink-safe OpenCode config writes, symlink-safe Cursor mcp writes, symlink-safe Claude settings writes, workspace-confined CI workflow extraction, workspace-confined agent prose extraction, workspace-confined Docker/Compose extraction, workspace-confined test config extraction, workspace-confined tooling config extraction, workspace-confined tsconfig extraction, workspace-confined editorconfig extraction, workspace-confined runtime version extraction, workspace-confined lockfile extraction, workspace-confined package script evidence, shared realpath workspace evidence checks, symlink-aware injection staleness checks, generated-marker symlink skip, procedure verifier secret scanning, extraction subject secret scanning, hook session-id redaction, MCP session-reference secret refusal, open-loop session metadata secret refusal, explicit memory metadata secret refusal, packageManager canonical facts, package-manager-aware script extraction, runtime-version deterministic extraction, multi-cookie session redaction, MCP error redaction, fail-closed retrieval for unvalidated commit-scoped facts, semantic CLI recall ranking, auth/cookie header secret scanning, scoped semantic retrieval expansion, workspace-confined injection staleness checks, package metadata extraction, high-trust test/Docker/tooling/tsconfig deterministic extraction, high-trust Python toolchain extraction, high-trust pyproject package-manager extraction, pyproject dependency-group Python command extraction, high-entropy redaction hardening, repo-id-isolated temporal validity, measured hook latency, fail-closed adapter install/uninstall, typed CLI error formatting, answer-bearing coding query expansion, Python package-manager retrieval expansion, redacted TUI selected-fact detail panels, TUI page/jump keyboard navigation, composed TUI empty states with operator status surfaces, SessionStart suppression of superseded user preferences, chunk-safe TUI key decoding with left/right and j/k navigation, bounded secret-redacted TUI prompt input, vector-safe commit-hygiene query expansion, searchable TUI memory lists, and tox/nox Python test harness extraction. 292 tests, 19 gate suites green, all I1-I9 hold._ +_Last updated: Windows is now a first-class dev platform (homedir-derived user-global paths, named-pipe daemon sockets, shim-free dev-tool spawns in eval suites, forward-slash generated-marker facts, windows-latest CI job), Private-key scanning now covers DSA, encrypted PEM, and PGP private key block headers, MCP `resolve_conflict` now evaluates user, workspace, and current-session facts together, `graphctx resolve` now refuses non-open-loop facts instead of silently superseding durable memory, Storage migration 0006 adds a composite invalidation conflict lookup index, Session-scoped invalidation now keeps identical open loops isolated across sessions while deduping repeats within a session, Demo seed memory now uses Runtime.rememberFact, open-loop session memory now carries current git anchors, CLI `tui --tab` now fails fast on invalid tabs, CLI/MCP fact-kind validation uses a shared `FACT_KINDS` domain enum, TUI promotion uses Runtime/Probation gates instead of direct activation, Runtime/TUI/CLI/MCP explicit remember paths share safe git-anchored invalidating writes, Runtime/TUI/MCP forget paths share git-anchored temporal closeout, MCP forget stamps git closeout anchors, retrieval candidates are scope-filtered before semantic reranking, retrieval candidates receive final workspace/session scope filtering, superseded facts close temporal validity with `t_expired` and commit anchors, retrieval recency ranking uses the runtime clock, ranking/conflict tests use deterministic fact IDs and clocks, vector embedding cache uses the runtime clock, outcome telemetry uses an injectable clock, injection anti-repetition ledger uses the runtime clock, promotion verification timestamps use the runtime clock, promotion gates rescan secret-shaped fact identity, why renders invalidating commit closeout anchors, revert revalidation respects branch representation, revert revalidation skips bad historical anchors, atomic temporal lifecycle closeout anchors, TUI read model redacts secret fact text, conflict notes redact identity/object secrets, structured why redacts evidence chain metadata, structured why redacts fact identity and scope strings, structured why redacts promotion audit text, structured why redacts git anchors, structured why redacts evidence payloads and tags, why output shows expired/invalidated temporal closeout, send-edge safety scans fact tags for secrets, command cards avoid false HEAD verification claims, low-trust claims blocked from PreToolUse guardrail injection, candidate facts kept out of vector search until promotion, lifecycle-synced vector index entries for expired/reactivated facts, secret-redacted semantic rerank text, retriever-level send-unsafe fact suppression, tag-update secret sensitivity restamping, secret-redacted FTS/vector fact indexing, FTS path-query fail-soft handling, runtime-level injection context redaction, hook tool-result redaction before injection context, shared retrieval-context redaction for CLI/MCP/hooks, atomic invalidation effects, supported-by temporal evidence edges, tool-arg redaction before retrieval, hook prompt redaction before retrieval, transcript-tail redaction before retrieval, symlink-safe workspace config reads, symlink-safe local store paths, symlink-safe AGENTS.md boot capsule writes, symlink-safe generic adapter marker writes, symlink-safe OpenCode config writes, symlink-safe Cursor mcp writes, symlink-safe Claude settings writes, workspace-confined CI workflow extraction, workspace-confined agent prose extraction, workspace-confined Docker/Compose extraction, workspace-confined test config extraction, workspace-confined tooling config extraction, workspace-confined tsconfig extraction, workspace-confined editorconfig extraction, workspace-confined runtime version extraction, workspace-confined lockfile extraction, workspace-confined package script evidence, shared realpath workspace evidence checks, symlink-aware injection staleness checks, generated-marker symlink skip, procedure verifier secret scanning, extraction subject secret scanning, hook session-id redaction, MCP session-reference secret refusal, open-loop session metadata secret refusal, explicit memory metadata secret refusal, packageManager canonical facts, package-manager-aware script extraction, runtime-version deterministic extraction, multi-cookie session redaction, MCP error redaction, fail-closed retrieval for unvalidated commit-scoped facts, semantic CLI recall ranking, auth/cookie header secret scanning, scoped semantic retrieval expansion, workspace-confined injection staleness checks, package metadata extraction, high-trust test/Docker/tooling/tsconfig deterministic extraction, high-trust Python toolchain extraction, high-trust pyproject package-manager extraction, pyproject dependency-group Python command extraction, high-entropy redaction hardening, repo-id-isolated temporal validity, measured hook latency, fail-closed adapter install/uninstall, typed CLI error formatting, answer-bearing coding query expansion, Python package-manager retrieval expansion, redacted TUI selected-fact detail panels, TUI page/jump keyboard navigation, composed TUI empty states with operator status surfaces, SessionStart suppression of superseded user preferences, chunk-safe TUI key decoding with left/right and j/k navigation, bounded secret-redacted TUI prompt input, vector-safe commit-hygiene query expansion, searchable TUI memory lists, and tox/nox Python test harness extraction. 297 tests, 19 gate suites green, all I1-I9 hold._ -_Quality counters: Tests: 292. Gate suites: 19._ +_Quality counters: Tests: 297. Gate suites: 19._ _Runtime-clock note: direct CLI, eval, and benchmark retrievers pass `Runtime.clock`, so pull and push retrieval use the same recency clock seam._ @@ -119,4 +119,4 @@ now have explicit scanners instead of relying only on entropy fallback._ | Code quality | ✅ | new `eval quality` passes 6/6: full-repo Biome, strict TS config/scripts, shared command-surface helpers for CLI help/docs/README reachability, eval-suite runner/test coverage, final README docs-as-code, and generated migration packaging guard | _Loop note: composite metric = (failing_gates × 100) + (un-perfected aspects); within-aspect -measured gains are recorded in the `memory` graph. Tests: 292, gate suites: 19 (`eval all` includes run/memory/promote/drift/retrieval/gate/security/branch/temporal/conflict/procedure/mcp/storage/telemetry/provenance/resilience/benchmarks/cli-docs-demo/quality)._ +measured gains are recorded in the `memory` graph. Tests: 297, gate suites: 19 (`eval all` includes run/memory/promote/drift/retrieval/gate/security/branch/temporal/conflict/procedure/mcp/storage/telemetry/provenance/resilience/benchmarks/cli-docs-demo/quality)._ diff --git a/src/adapters/claude-code/install.ts b/src/adapters/claude-code/install.ts index 0140ef8..317e72f 100644 --- a/src/adapters/claude-code/install.ts +++ b/src/adapters/claude-code/install.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { AdapterError } from "../../core/errors.js"; import type { InstallOptions } from "../adapter.js"; @@ -25,9 +26,7 @@ interface ClaudeSettings { // event payload on stdin. Idempotent: replaces any prior graphctx entries. export function installClaudeHooks(opts: InstallOptions): { settingsPath: string } { const bin = opts.binPath ?? "graphctx"; - const dir = opts.global - ? join(process.env.HOME ?? "", ".claude") - : join(opts.workspaceDir, ".claude"); + const dir = opts.global ? join(homedir(), ".claude") : join(opts.workspaceDir, ".claude"); mkdirSync(dir, { recursive: true }); const settingsPath = join(dir, "settings.json"); assertWritableConfigPath(settingsPath, "Claude settings.json file"); @@ -58,9 +57,7 @@ export function installClaudeHooks(opts: InstallOptions): { settingsPath: string } export function uninstallClaudeHooks(opts: InstallOptions): void { - const dir = opts.global - ? join(process.env.HOME ?? "", ".claude") - : join(opts.workspaceDir, ".claude"); + const dir = opts.global ? join(homedir(), ".claude") : join(opts.workspaceDir, ".claude"); const settingsPath = join(dir, "settings.json"); if (!existsSync(settingsPath)) return; assertWritableConfigPath(settingsPath, "Claude settings.json file"); @@ -84,9 +81,7 @@ export function uninstallClaudeHooks(opts: InstallOptions): void { } export function hasClaudeGraphctxHooks(opts: InstallOptions): boolean { - const dir = opts.global - ? join(process.env.HOME ?? "", ".claude") - : join(opts.workspaceDir, ".claude"); + const dir = opts.global ? join(homedir(), ".claude") : join(opts.workspaceDir, ".claude"); const settingsPath = join(dir, "settings.json"); if (!existsSync(settingsPath)) return false; if (isSymlink(settingsPath)) return false; diff --git a/src/adapters/skill/index.ts b/src/adapters/skill/index.ts index 153d38d..3723cb7 100644 --- a/src/adapters/skill/index.ts +++ b/src/adapters/skill/index.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join } from "node:path"; import { ValidationError } from "../../core/errors.js"; import { assertWritableConfigPath } from "../config-path.js"; @@ -31,11 +32,7 @@ export function isSkillClient(value: string): value is SkillClient { return (SKILL_CLIENTS as readonly string[]).includes(value); } -export function skillPath( - client: SkillClient, - workspaceDir: string, - homeDir = process.env.HOME ?? "", -): string { +export function skillPath(client: SkillClient, workspaceDir: string, homeDir = homedir()): string { switch (client) { case "claude": return join(workspaceDir, ".claude", "skills", "graphctx", "SKILL.md"); diff --git a/src/cli.ts b/src/cli.ts index 30291e2..61a06fb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Command } from "commander"; import { writeAgentsCapsule } from "./adapters/boot-capsule.js"; @@ -281,7 +281,7 @@ program .option("--mcp", "run as MCP server (stdio JSON-RPC)") .option( "--socket ", - "(daemon mode) bind to a local Unix socket; one server, many concurrent clients", + "(daemon mode) bind to a local Unix socket (win32: named pipe); one server, many clients", ) .option("-C, --cwd ", "workspace directory", process.cwd()) .description("run the graphCTX MCP server (stdio by default, 8 tools)") @@ -915,7 +915,7 @@ function handleCliError(e: unknown): never { function logError(e: unknown): void { try { - const dir = join(process.env.HOME ?? "", ".local", "share", "graphctx", "logs"); + const dir = join(homedir(), ".local", "share", "graphctx", "logs"); mkdirSync(dir, { recursive: true }); const line = `${isoNow()} ${e instanceof GraphCtxError ? `[${e.code}] ` : ""}${(e as Error)?.message ?? String(e)}\n`; writeFileSync(join(dir, "hook-errors.log"), line, { flag: "a" }); diff --git a/src/config/config.ts b/src/config/config.ts index cf6e85d..641ade9 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; import { ConfigError } from "../core/errors.js"; import { symlinkedPathComponent } from "../core/path-safety.js"; @@ -93,7 +94,7 @@ function readWorkspaceJsonIfSafe(path: string, workspaceDir: string): unknown { function expandPath(p: string, workspaceDir: string): string { let out = p; - if (out.startsWith("~")) out = join(process.env.HOME ?? "", out.slice(1)); + if (out.startsWith("~")) out = join(homedir(), out.slice(1)); if (out.includes("~/.local/share/graphctx")) { out = out.replace(/^.*\.local\/share\/graphctx/, dataDir()); } diff --git a/src/eval/dev-tools.ts b/src/eval/dev-tools.ts new file mode 100644 index 0000000..d331618 --- /dev/null +++ b/src/eval/dev-tools.ts @@ -0,0 +1,20 @@ +import { join } from "node:path"; + +// JS entry points for the dev tools the eval suites and tests spawn. +// Spawned via process.execPath instead of the node_modules/.bin shims: on +// win32 those shims are .cmd batch files, which execFile refuses to spawn +// without shell:true (CVE-2024-27980 hardening), failing with EINVAL. +// Running the current node binary against each tool's real entry is +// shim-free, injection-safe, and identical on every platform. + +export function tsxEntry(repoRoot: string): string { + return join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"); +} + +export function vitestEntry(repoRoot: string): string { + return join(repoRoot, "node_modules", "vitest", "vitest.mjs"); +} + +export function biomeEntry(repoRoot: string): string { + return join(repoRoot, "node_modules", "@biomejs", "biome", "bin", "biome"); +} diff --git a/src/eval/suites/adapters-mcp.ts b/src/eval/suites/adapters-mcp.ts index 1842288..ef356c1 100644 --- a/src/eval/suites/adapters-mcp.ts +++ b/src/eval/suites/adapters-mcp.ts @@ -24,16 +24,12 @@ import { McpServer } from "../../mcp/server.js"; import { MCP_TOOLS } from "../../mcp/tools.js"; import type { Runtime } from "../../runtime.js"; import { classifyOutcome } from "../../telemetry/outcomes.js"; +import { tsxEntry } from "../dev-tools.js"; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", "..", ".."); const cliPath = join(repoRoot, "src", "cli.ts"); -const tsxBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "tsx.cmd" : "tsx", -); +const tsxEntryPath = tsxEntry(repoRoot); const EXPECTED_MCP_TOOL_NAMES = [ "remember", @@ -1160,19 +1156,23 @@ function runServeMcpStdio(workspaceDir: string): { } { try { execFileSync("git", ["-C", workspaceDir, "init", "-q"], { stdio: "ignore" }); - const stdout = execFileSync(tsxBin, [cliPath, "serve", "--mcp", "-C", workspaceDir], { - cwd: repoRoot, - env: { ...process.env, GRAPHCTX_USER_ID: "mcp-stdio-eval" }, - input: [ - "{bad json", - JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), - JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), - "", - ].join("\n"), - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - timeout: 20000, - }); + const stdout = execFileSync( + process.execPath, + [tsxEntryPath, cliPath, "serve", "--mcp", "-C", workspaceDir], + { + cwd: repoRoot, + env: { ...process.env, GRAPHCTX_USER_ID: "mcp-stdio-eval" }, + input: [ + "{bad json", + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + "", + ].join("\n"), + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 20000, + }, + ); const responses = stdout .trim() .split(/\n+/) diff --git a/src/eval/suites/cli-docs-demo.ts b/src/eval/suites/cli-docs-demo.ts index 7babbc3..6a9a4cd 100644 --- a/src/eval/suites/cli-docs-demo.ts +++ b/src/eval/suites/cli-docs-demo.ts @@ -18,6 +18,7 @@ import { defaultConfig } from "../../config/defaults.js"; import { McpServer } from "../../mcp/server.js"; import { MCP_TOOLS } from "../../mcp/tools.js"; import { EXPECTED_COMMANDS, commandsFromHelp } from "../command-surface.js"; +import { tsxEntry, vitestEntry } from "../dev-tools.js"; import { EVAL_GATE_SUITES } from "../registry.js"; import { runAdaptersMcpEval } from "./adapters-mcp.js"; @@ -80,18 +81,8 @@ const EXPECTED_MCP_TOOLS = [ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", "..", ".."); const cliPath = join(repoRoot, "src", "cli.ts"); -const tsxBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "tsx.cmd" : "tsx", -); -const vitestBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "vitest.cmd" : "vitest", -); +const tsxEntryPath = tsxEntry(repoRoot); +const vitestEntryPath = vitestEntry(repoRoot); const fixtureRepo = join(repoRoot, "fixtures", "repo-pnpm-web"); const GIT_ENV: NodeJS.ProcessEnv = { @@ -305,7 +296,7 @@ export function formatCliDocsDemoReport(r: CliDocsDemoReport): string { function cli(args: string[], input?: string, env: NodeJS.ProcessEnv = process.env): CliResult { try { - const stdout = execFileSync(tsxBin, [cliPath, ...args], { + const stdout = execFileSync(process.execPath, [tsxEntryPath, cliPath, ...args], { cwd: repoRoot, env: { ...env, GRAPHCTX_USER_ID: "cli-docs-demo-eval" }, input, @@ -397,7 +388,7 @@ function mcpCheckPairs(markdown: string): Array<[number, number]> { } function vitestListCount(): number { - const stdout = execFileSync(vitestBin, ["list"], { + const stdout = execFileSync(process.execPath, [vitestEntryPath, "list"], { cwd: repoRoot, env: { ...process.env, GRAPHCTX_USER_ID: "cli-docs-demo-eval" }, encoding: "utf8", diff --git a/src/eval/suites/code-quality.ts b/src/eval/suites/code-quality.ts index b4067dc..c80dcbc 100644 --- a/src/eval/suites/code-quality.ts +++ b/src/eval/suites/code-quality.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { EXPECTED_COMMANDS, EXPECTED_COMMAND_SET, commandsFromHelp } from "../command-surface.js"; +import { biomeEntry, tsxEntry } from "../dev-tools.js"; import { EVAL_GATE_SUITES, type EvalGateSuite } from "../registry.js"; // Final code-quality gate. This keeps the cheap deterministic checks close to @@ -74,18 +75,8 @@ const EXPECTED_EVAL_TESTS: Record = { const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", "..", ".."); const cliPath = join(repoRoot, "src", "cli.ts"); -const tsxBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "tsx.cmd" : "tsx", -); -const biomeBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "biome.cmd" : "biome", -); +const tsxEntryPath = tsxEntry(repoRoot); +const biomeEntryPath = biomeEntry(repoRoot); export function runCodeQualityEval(): CodeQualityReport { const detail: string[] = []; @@ -96,7 +87,7 @@ export function runCodeQualityEval(): CodeQualityReport { detail.push(`${ok ? "✓" : "✗"} ${name}${note ? ` — ${note}` : ""}`); }; - const fullBiome = command(biomeBin, ["check", "."]); + const fullBiome = command(process.execPath, [biomeEntryPath, "check", "."]); check( "full-repo Biome check has zero lint/format debt", fullBiome.status === 0, @@ -129,7 +120,7 @@ export function runCodeQualityEval(): CodeQualityReport { .join(",")}`, ); - const help = command(tsxBin, [cliPath, "--help"]); + const help = command(process.execPath, [tsxEntryPath, cliPath, "--help"]); const helpCommands = commandsFromHelp(help.stdout); const statusRow = tableRow(readRepo("docs/STATUS.md"), "cli.ts"); const readme = readRepo("README.md"); diff --git a/src/eval/suites/core-memory-lifecycle.ts b/src/eval/suites/core-memory-lifecycle.ts index 1fffef6..1c3385c 100644 --- a/src/eval/suites/core-memory-lifecycle.ts +++ b/src/eval/suites/core-memory-lifecycle.ts @@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { tsxEntry } from "../dev-tools.js"; // Core-memory lifecycle gate. This suite intentionally drives the REAL CLI // entry point over throwaway repos: remember -> recall -> why, plus the open @@ -27,12 +28,7 @@ interface CliResult { const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", "..", ".."); const cliPath = join(repoRoot, "src", "cli.ts"); -const tsxBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "tsx.cmd" : "tsx", -); +const tsxEntryPath = tsxEntry(repoRoot); const GIT_ENV: NodeJS.ProcessEnv = { ...process.env, @@ -46,7 +42,7 @@ const GIT_ENV: NodeJS.ProcessEnv = { function cli(args: string[], input?: string): CliResult { try { - const stdout = execFileSync(tsxBin, [cliPath, ...args], { + const stdout = execFileSync(process.execPath, [tsxEntryPath, cliPath, ...args], { cwd: repoRoot, env: { ...process.env, GRAPHCTX_USER_ID: "core-memory-eval" }, input, diff --git a/src/eval/suites/provenance-why.ts b/src/eval/suites/provenance-why.ts index b13d18d..f978ea6 100644 --- a/src/eval/suites/provenance-why.ts +++ b/src/eval/suites/provenance-why.ts @@ -11,6 +11,7 @@ import { EdgesRepo } from "../../store/edges.repo.js"; import { EpisodesRepo } from "../../store/episodes.repo.js"; import { FactsRepo } from "../../store/facts.repo.js"; import { PromotionsRepo } from "../../store/promotions.repo.js"; +import { tsxEntry } from "../dev-tools.js"; export interface ProvenanceWhyReport { checks: number; @@ -31,12 +32,7 @@ interface CliResult { const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", "..", ".."); const cliPath = join(repoRoot, "src", "cli.ts"); -const tsxBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "tsx.cmd" : "tsx", -); +const tsxEntryPath = tsxEntry(repoRoot); const fixtureRepo = join(repoRoot, "fixtures", "repo-pnpm-web"); const clock = fixedClock("2026-01-01T00:00:00.000Z"); @@ -253,7 +249,7 @@ function fact(over: Partial): NewFact { function cli(args: string[], input?: string): CliResult { try { - const stdout = execFileSync(tsxBin, [cliPath, ...args], { + const stdout = execFileSync(process.execPath, [tsxEntryPath, cliPath, ...args], { cwd: repoRoot, env: { ...process.env, GRAPHCTX_USER_ID: "provenance-eval" }, input, diff --git a/src/eval/suites/resilience-failsoft.ts b/src/eval/suites/resilience-failsoft.ts index f96107c..a7d7bc3 100644 --- a/src/eval/suites/resilience-failsoft.ts +++ b/src/eval/suites/resilience-failsoft.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url"; import { handleHook } from "../../adapters/claude-code/hooks.js"; import { nullProvider, resolveProvider } from "../../llm/provider.js"; import { Runtime } from "../../runtime.js"; +import { tsxEntry } from "../dev-tools.js"; export interface ResilienceFailsoftReport { checks: number; @@ -33,12 +34,7 @@ interface CliResult { const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, "..", "..", ".."); const cliPath = join(repoRoot, "src", "cli.ts"); -const tsxBin = join( - repoRoot, - "node_modules", - ".bin", - process.platform === "win32" ? "tsx.cmd" : "tsx", -); +const tsxEntryPath = tsxEntry(repoRoot); const fixtureRepo = join(repoRoot, "fixtures", "repo-pnpm-web"); const GIT_ENV: NodeJS.ProcessEnv = { @@ -556,7 +552,7 @@ async function evaluateProviderFailsoft(): Promise<{ function cli(args: string[], input?: string, env: NodeJS.ProcessEnv = process.env): CliResult { try { - const stdout = execFileSync(tsxBin, [cliPath, ...args], { + const stdout = execFileSync(process.execPath, [tsxEntryPath, cliPath, ...args], { cwd: repoRoot, env, input, diff --git a/src/extract/deterministic/generated-markers.ts b/src/extract/deterministic/generated-markers.ts index e11230b..c5a4590 100644 --- a/src/extract/deterministic/generated-markers.ts +++ b/src/extract/deterministic/generated-markers.ts @@ -1,5 +1,5 @@ import { lstatSync, readFileSync, readdirSync } from "node:fs"; -import { join, relative } from "node:path"; +import { join, relative, sep } from "node:path"; import type { NewFact } from "../../core/types.js"; import { type ExtractContext, type Extractor, structuredFact } from "./types.js"; @@ -46,7 +46,7 @@ export const generatedMarkersExtractor: Extractor = { try { const head = readFileSync(full, "utf8").slice(0, 600); if (GENERATED_MARKERS.some((m) => head.includes(m))) { - generated.push(relative(ctx.workspaceDir, full)); + generated.push(relative(ctx.workspaceDir, full).split(sep).join("/")); } } catch { // skip unreadable diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 51a13ea..9cf2eba 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,5 +1,6 @@ import { existsSync, unlinkSync } from "node:fs"; import { createServer } from "node:net"; +import { join } from "node:path"; import { createInterface } from "node:readline"; import { buildRider } from "../adapters/channel.js"; import type { Capsule } from "../core/types.js"; @@ -138,13 +139,18 @@ export class McpServer { } // Daemon mode: serve line-delimited JSON-RPC over a local Unix domain - // socket. Each connecting client is a fresh JSON-RPC session that shares + // socket (a named pipe on win32, where Node's net has no AF_UNIX support). + // Each connecting client is a fresh JSON-RPC session that shares // the same Runtime/DB with all the others, so Claude Desktop, Cursor, Codex // and a CI agent can all attach to one process without spawning a new // server per client. Bind path is local-filesystem only — there is no TCP // listener and there is no network exposure. async serveSocket(socketPath: string): Promise { - if (existsSync(socketPath)) { + const listenPath = resolveSocketPath(socketPath); + // Named pipes have no filesystem entry: nothing stale to unlink, and the + // pipe disappears with its listener. + const isNamedPipe = process.platform === "win32"; + if (!isNamedPipe && existsSync(socketPath)) { try { unlinkSync(socketPath); } catch { @@ -186,12 +192,12 @@ export class McpServer { }); await new Promise((ok, reject) => { server.once("error", reject); - server.listen(socketPath, () => { + server.listen(listenPath, () => { server.off("error", reject); ok(); }); }); - process.stderr.write(`graphctx mcp daemon listening on ${socketPath}\n`); + process.stderr.write(`graphctx mcp daemon listening on ${listenPath}\n`); await new Promise((resolve) => { const stop = () => { try { @@ -200,7 +206,7 @@ export class McpServer { resolve(); } try { - if (existsSync(socketPath)) unlinkSync(socketPath); + if (!isNamedPipe && existsSync(socketPath)) unlinkSync(socketPath); } catch { /* noop */ } @@ -223,6 +229,19 @@ export class McpServer { } } +// Maps a filesystem-style socket path into the win32 named-pipe namespace +// (\\.\pipe\...): Node's net has no AF_UNIX support on Windows, and listening +// on a plain file path fails with EACCES. POSIX paths and already-namespaced +// pipe paths pass through unchanged. Clients must connect to the same mapped +// path, so this is exported for the CLI, tests, and any embedding caller. +export function resolveSocketPath(socketPath: string): string { + if (process.platform !== "win32") return socketPath; + if (socketPath.startsWith("\\\\.\\pipe\\") || socketPath.startsWith("\\\\?\\pipe\\")) { + return socketPath; + } + return join("\\\\.\\pipe", socketPath); +} + function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { if (!value || typeof value !== "object") return false; const req = value as Record; diff --git a/test/adapters/home-paths.test.ts b/test/adapters/home-paths.test.ts new file mode 100644 index 0000000..797964a --- /dev/null +++ b/test/adapters/home-paths.test.ts @@ -0,0 +1,71 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + installClaudeHooks, + uninstallClaudeHooks, +} from "../../src/adapters/claude-code/install.js"; +import { skillPath } from "../../src/adapters/skill/index.js"; + +// On native Windows HOME is normally unset (the profile lives in USERPROFILE), +// so any user-global path derived from process.env.HOME silently degrades to a +// relative path and lands wherever the process happens to run. User-global +// destinations must derive from os.homedir(), which resolves on every platform. + +describe("user-global paths derive from os.homedir(), not $HOME", () => { + let savedHome: string | undefined; + let savedProfile: string | undefined; + + beforeEach(() => { + savedHome = process.env.HOME; + savedProfile = process.env.USERPROFILE; + }); + + afterEach(() => { + if (savedHome === undefined) { + // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined" in process.env + delete process.env.HOME; + } else { + process.env.HOME = savedHome; + } + if (savedProfile === undefined) { + // biome-ignore lint/performance/noDelete: same env-coercion reason as HOME above + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = savedProfile; + } + }); + + it("skillPath(codex) stays absolute when HOME is unset", () => { + // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined" in process.env + delete process.env.HOME; + const p = skillPath("codex", join(tmpdir(), "ws-unused")); + expect(isAbsolute(p)).toBe(true); + expect(p).toBe(join(homedir(), ".codex", "skills", "graphctx", "SKILL.md")); + }); + + it.runIf(process.platform === "win32")( + "global claude hook install lands in the user profile when HOME is unset", + () => { + const fakeProfile = mkdtempSync(join(tmpdir(), "gctx-profile-")); + try { + // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined" in process.env + delete process.env.HOME; + process.env.USERPROFILE = fakeProfile; + const { settingsPath } = installClaudeHooks({ + workspaceDir: join(tmpdir(), "ws-unused"), + global: true, + binPath: "graphctx", + }); + expect(settingsPath).toBe(join(fakeProfile, ".claude", "settings.json")); + expect(existsSync(settingsPath)).toBe(true); + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + expect(settings.hooks.SessionStart).toBeDefined(); + uninstallClaudeHooks({ workspaceDir: join(tmpdir(), "ws-unused"), global: true }); + } finally { + rmSync(fakeProfile, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/test/config/expand-path.test.ts b/test/config/expand-path.test.ts new file mode 100644 index 0000000..d12ff04 --- /dev/null +++ b/test/config/expand-path.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { loadConfig } from "../../src/config/config.js"; + +// `~` in configured storage paths must expand via os.homedir(): on native +// Windows HOME is unset, and expanding against process.env.HOME turned the +// path relative, silently re-rooting the user store inside the workspace. + +describe("config path expansion", () => { + let workDir: string; + let savedHome: string | undefined; + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "gctx-config-")); + savedHome = process.env.HOME; + }); + + afterEach(() => { + if (savedHome === undefined) { + // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined" in process.env + delete process.env.HOME; + } else { + process.env.HOME = savedHome; + } + rmSync(workDir, { recursive: true, force: true }); + }); + + it("expands ~ against os.homedir() even when HOME is unset", () => { + // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined" in process.env + delete process.env.HOME; + const { paths } = loadConfig({ + workspaceDir: workDir, + overrides: { storage: { user_db: "~/gctx-test/user.db" } }, + }); + expect(paths.userDb).toBe(join(homedir(), "gctx-test", "user.db")); + }); + + it("keeps resolving bare relative paths against the workspace", () => { + const { paths } = loadConfig({ + workspaceDir: workDir, + overrides: { storage: { user_db: ".graphctx/custom.db" } }, + }); + expect(paths.userDb).toBe(join(workDir, ".graphctx", "custom.db")); + }); +}); diff --git a/test/extract/extractors.test.ts b/test/extract/extractors.test.ts index 442ede1..b410115 100644 --- a/test/extract/extractors.test.ts +++ b/test/extract/extractors.test.ts @@ -621,6 +621,19 @@ describe("deterministic extractors", () => { expect(String(g!.subject)).toContain("g.ts"); }); + it("generated-markers emits forward-slash paths on every platform", () => { + mkdirSync(join(dir, "src", "gen"), { recursive: true }); + writeFileSync( + join(dir, "src", "gen", "deep.ts"), + "// @generated DO NOT EDIT\nexport const x = 1;\n", + ); + const { res } = extract(); + const g = res.inserted.find((f) => f.predicate === "do_not_edit"); + expect(g).toBeDefined(); + expect(String(g!.subject)).toBe("src/gen/deep.ts"); + expect(String(g!.subject)).not.toContain("\\"); + }); + it("generated-markers does not follow symlinked directories outside the workspace", () => { const outside = mkdtempSync(join(tmpdir(), "gctx-ex-outside-")); try { diff --git a/test/mcp/socket.test.ts b/test/mcp/socket.test.ts index 653538d..1135821 100644 --- a/test/mcp/socket.test.ts +++ b/test/mcp/socket.test.ts @@ -4,11 +4,13 @@ import { createConnection } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { McpServer } from "../../src/mcp/server.js"; +import { McpServer, resolveSocketPath } from "../../src/mcp/server.js"; -// Daemon mode: line-delimited JSON-RPC over a local Unix socket. Verifies the -// listener binds locally (no TCP), advertises the same 8 tools, and handles two -// concurrent clients against the same backing process. +// Daemon mode: line-delimited JSON-RPC over a local Unix socket (a named pipe +// on win32 — pipes have no filesystem entry, so readiness is probed by +// connecting, not by existsSync). Verifies the listener binds locally (no +// TCP), advertises the same 8 tools, and handles two concurrent clients +// against the same backing process. describe("mcp daemon socket", () => { let workDir: string; @@ -57,11 +59,12 @@ describe("mcp daemon socket", () => { }) .finally(() => serveDone?.()); - // wait for the socket to appear - for (let i = 0; i < 50 && !existsSync(socket); i++) { + // wait for the listener to accept connections + for (let i = 0; i < 50 && !(await canConnect(socket)); i++) { await new Promise((r) => setTimeout(r, 20)); } - expect(existsSync(socket)).toBe(true); + expect(await canConnect(socket)).toBe(true); + if (process.platform !== "win32") expect(existsSync(socket)).toBe(true); const reply = await rpc(socket, [ { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, @@ -82,12 +85,23 @@ describe("mcp daemon socket", () => { }, 15000); }); +async function canConnect(socketPath: string): Promise { + return await new Promise((resolve) => { + const conn = createConnection(resolveSocketPath(socketPath)); + conn.once("connect", () => { + conn.end(); + resolve(true); + }); + conn.once("error", () => resolve(false)); + }); +} + async function rpc( socketPath: string, requests: Array>, ): Promise> { return await new Promise((resolve, reject) => { - const conn = createConnection(socketPath); + const conn = createConnection(resolveSocketPath(socketPath)); let buf = ""; const out: Array<{ id?: number; result?: unknown }> = []; const wantedIds = new Set(requests.map((r) => r.id as number | undefined).filter(Boolean)); diff --git a/test/resilience/hook-degrades.test.ts b/test/resilience/hook-degrades.test.ts index bfb15c2..7dd84eb 100644 --- a/test/resilience/hook-degrades.test.ts +++ b/test/resilience/hook-degrades.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { handleHook } from "../../src/adapters/claude-code/hooks.js"; +import { tsxEntry } from "../../src/eval/dev-tools.js"; import { Runtime } from "../../src/runtime.js"; const here = dirname(fileURLToPath(import.meta.url)); @@ -25,13 +26,17 @@ afterEach(() => { // I9: a broken graphCTX must degrade to no memory, never a broken agent. function runHook(event: string, payload: object): { status: number; stdout: string } { try { - const stdout = execFileSync("npx", ["tsx", cliPath, "hook", event, "-C", dir], { - input: JSON.stringify(payload), - cwd: repoRoot, - encoding: "utf8", - stdio: ["pipe", "pipe", "ignore"], - timeout: 30000, - }); + const stdout = execFileSync( + process.execPath, + [tsxEntry(repoRoot), cliPath, "hook", event, "-C", dir], + { + input: JSON.stringify(payload), + cwd: repoRoot, + encoding: "utf8", + stdio: ["pipe", "pipe", "ignore"], + timeout: 30000, + }, + ); return { status: 0, stdout }; } catch (e) { const err = e as { status?: number; stdout?: string };