Skip to content
Open
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
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,54 @@ Config file locations:
- `skeleton [path]` or `tree [path]` - **(New)** View the structural tree of a project with file headers and symbol definitions directly in your terminal.
- `[path]` - Start the MCP server (stdio) for the specified path (defaults to current directory).

### Including paths excluded by the workspace `.gitignore`

If your workspace `.gitignore` excludes a sub-directory that you still want
indexed (common in monorepos where sub-projects under `repos/`, `packages/`,
or `vendor/` are gitignored at the top level), use `--include` or
`CONTEXTPLUS_EXTRA_ROOTS` to add the paths back.

**CLI form** (repeatable):

```bash
bunx contextplus /path/to/workspace \
--include repos/lacuna \
--include repos/graphrag-core
```

**Environment variable** (fallback when no `--include` flag is set; uses the
system path separator — `:` on Unix, `;` on Windows):

```bash
CONTEXTPLUS_EXTRA_ROOTS=repos/lacuna:repos/graphrag-core \
bunx contextplus /path/to/workspace
```

**In `.mcp.json`** the env form is usually more ergonomic:

```json
{
"mcpServers": {
"contextplus": {
"command": "bunx",
"args": ["contextplus", "/path/to/workspace"],
"env": {
"CONTEXTPLUS_EXTRA_ROOTS": "repos/lacuna:repos/graphrag-core"
}
}
}
}
```

Each path listed is walked **independently** of the workspace root, with a
fresh ignore scope. Each path's own `.gitignore` is respected. Paths are
validated at startup; invalid entries (non-existent, not a directory,
outside the workspace) emit a stderr warning and are skipped.

Nested `.gitignore` files inside the workspace and inside each extra root
are loaded and merged with inherited rules, matching `git` and `ripgrep`
behavior.

### From Source

```bash
Expand Down
81 changes: 81 additions & 0 deletions src/core/extra-roots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// CLI/env argument parsing for the extraRoots config.
// Pure module — no side effects, safe to import from tests.

import { realpathSync, statSync } from "fs";
import { delimiter, isAbsolute, resolve, sep } from "path";

export interface ParseExtraRootsInput {
argv: string[];
env: NodeJS.ProcessEnv | Record<string, string | undefined>;
rootDir: string;
}

export interface ParseExtraRootsResult {
accepted: string[];
warnings: string[];
}

function extractIncludeFlags(argv: string[]): string[] {
const out: string[] = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--include" && i + 1 < argv.length) {
out.push(argv[i + 1]);
i++;
} else if (argv[i].startsWith("--include=")) {
out.push(argv[i].slice("--include=".length));
}
}
return out;
}

export function parseExtraRoots(input: ParseExtraRootsInput): ParseExtraRootsResult {
const accepted: string[] = [];
const warnings: string[] = [];
const rootAbs = resolve(input.rootDir);
let rootReal = rootAbs;
try {
rootReal = realpathSync(rootAbs);
} catch {
// rootDir doesn't exist; fall through
}

const fromCli = extractIncludeFlags(input.argv);
const raw = fromCli.length > 0
? fromCli
: (input.env.CONTEXTPLUS_EXTRA_ROOTS ?? "")
.split(delimiter)
.filter((s) => s.length > 0);

for (const entry of raw) {
const abs = isAbsolute(entry) ? entry : resolve(rootAbs, entry);
let real = abs;
try {
real = realpathSync(abs);
} catch {
// doesn't exist - statSync below will catch and warn
}

if (real === rootReal) {
warnings.push(`contextplus: extraRoot '${entry}' equals the workspace root — skipping`);
continue;
}
if (!real.startsWith(rootReal + sep)) {
warnings.push(`contextplus: extraRoot '${entry}' is outside the workspace root — skipping`);
continue;
}
let stats;
try {
stats = statSync(real);
} catch {
warnings.push(`contextplus: extraRoot '${entry}' does not exist — skipping`);
continue;
}
if (!stats.isDirectory()) {
warnings.push(`contextplus: extraRoot '${entry}' is not a directory — skipping`);
continue;
}
accepted.push(real);
}

return { accepted, warnings };
}
145 changes: 131 additions & 14 deletions src/core/walker.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Gitignore-aware recursive directory walker with depth control
// Returns filtered file paths respecting project ignore patterns
// Returns filtered file paths respecting project ignore patterns (nested-gitignore-aware)

import { readdir, readFile, stat } from "fs/promises";
import { join, relative, resolve } from "path";
import { readdir, readFile, realpath, stat } from "fs/promises";
import { join, relative, resolve, sep } from "path";
import ignore, { type Ignore } from "ignore";

export interface WalkOptions {
Expand All @@ -18,6 +18,13 @@ export interface FileEntry {
depth: number;
}

interface IgnoreScope {
scopeRoot: string;
ig: Ignore;
}

type IgnoreChain = IgnoreScope[];

const ALWAYS_IGNORE = new Set([
"node_modules",
".git",
Expand All @@ -38,20 +45,37 @@ const ALWAYS_IGNORE = new Set([
".parcel-cache",
]);

async function loadIgnoreRules(rootDir: string): Promise<Ignore> {
const ig = ignore();
async function loadLocalScope(dir: string): Promise<IgnoreScope | null> {
try {
const content = await readFile(join(rootDir, ".gitignore"), "utf-8");
ig.add(content);
const content = await readFile(join(dir, ".gitignore"), "utf-8");
return { scopeRoot: dir, ig: ignore().add(content) };
} catch {
return null;
}
return ig;
}

function isIgnoredInChain(absPath: string, isDir: boolean, chain: IgnoreChain): boolean {
// Walk scopes from outermost to innermost. Each scope's patterns are evaluated
// against paths relative to that scope's directory. Later scopes can re-include
// paths that earlier scopes excluded (gitignore negation crosses scope boundaries).
let state: "ignored" | "included" = "included";
for (const scope of chain) {
let rel = relative(scope.scopeRoot, absPath).replace(/\\/g, "/");
if (!rel || rel.startsWith("..")) continue;
// Mark directories with a trailing slash so anchored directory patterns
// like `/build/` match the directory itself (and short-circuit descent).
if (isDir) rel += "/";
const result = scope.ig.test(rel);
if (result.unignored) state = "included";
else if (result.ignored) state = "ignored";
}
return state === "ignored";
}

async function walkRecursive(
dir: string,
rootDir: string,
ig: Ignore,
chain: IgnoreChain,
depth: number,
maxDepth: number,
results: FileEntry[],
Expand All @@ -64,19 +88,22 @@ async function walkRecursive(

const fullPath = join(dir, entry.name);
const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
if (ig.ignores(relPath)) continue;

const isDir = entry.isDirectory();
if (isIgnoredInChain(fullPath, isDir, chain)) continue;

results.push({ path: fullPath, relativePath: relPath, isDirectory: isDir, depth });

if (isDir) await walkRecursive(fullPath, rootDir, ig, depth + 1, maxDepth, results);
if (isDir) {
const localScope = await loadLocalScope(fullPath);
const childChain = localScope ? [...chain, localScope] : chain;
await walkRecursive(fullPath, rootDir, childChain, depth + 1, maxDepth, results);
}
}
}

export async function walkDirectory(options: WalkOptions): Promise<FileEntry[]> {
const rootDir = resolve(options.rootDir);
const startDir = options.targetPath ? resolve(rootDir, options.targetPath) : rootDir;
const ig = await loadIgnoreRules(rootDir);
const results: FileEntry[] = [];

try {
Expand All @@ -85,7 +112,23 @@ export async function walkDirectory(options: WalkOptions): Promise<FileEntry[]>
return results;
}

await walkRecursive(startDir, rootDir, ig, 0, options.depthLimit ?? 0, results);
// Build the initial chain from rootDir down to startDir so ancestor scopes apply
// at the start of the walk.
const chain: IgnoreChain = [];
const rootScope = await loadLocalScope(rootDir);
if (rootScope) chain.push(rootScope);

if (startDir !== rootDir) {
const segments = relative(rootDir, startDir).split(/[\\/]/).filter(Boolean);
let cursor = rootDir;
for (const segment of segments) {
cursor = join(cursor, segment);
const scope = await loadLocalScope(cursor);
if (scope) chain.push(scope);
}
}

await walkRecursive(startDir, rootDir, chain, 0, options.depthLimit ?? 0, results);
return results;
}

Expand All @@ -101,3 +144,77 @@ export function groupByDirectory(entries: FileEntry[]): Map<string, FileEntry[]>
}
return groups;
}

let GLOBAL_EXTRA_ROOTS: string[] = [];

export function setExtraRoots(paths: string[]): void {
GLOBAL_EXTRA_ROOTS = [...paths];
}

export function getExtraRoots(): string[] {
return [...GLOBAL_EXTRA_ROOTS];
}

export interface WalkRootsOptions {
rootDir: string;
extraRoots?: string[];
depthLimit?: number;
targetPath?: string;
}

export async function walkRoots(options: WalkRootsOptions): Promise<FileEntry[]> {
const rootDir = resolve(options.rootDir);
let rootReal = rootDir;
try {
rootReal = await realpath(rootDir);
} catch {
// rootDir doesn't exist; fall through with unresolved value
}

const extraRoots = options.extraRoots ?? GLOBAL_EXTRA_ROOTS;
const seen = new Set<string>();
const results: FileEntry[] = [];

const primary = await walkDirectory({
rootDir,
depthLimit: options.depthLimit,
targetPath: options.targetPath,
});
for (const entry of primary) {
if (seen.has(entry.path)) continue;
seen.add(entry.path);
results.push(entry);
}

// targetPath constrains the primary walk only — extraRoots are always walked in full.
for (const extra of extraRoots) {
const extraAbs = resolve(rootDir, extra);
let extraReal = extraAbs;
try {
extraReal = await realpath(extraAbs);
} catch {
// doesn't exist; will fall through to the prefix check on unresolved path
}
if (extraReal !== rootReal && !extraReal.startsWith(rootReal + sep)) {
throw new Error(`walkRoots: extraRoot "${extra}" resolves outside workspace root`);
}
const depthOffset = relative(rootReal, extraReal).split(/[\\/]/).filter(Boolean).length;
const extraEntries = await walkDirectory({
rootDir: extraReal,
depthLimit: options.depthLimit,
});
for (const entry of extraEntries) {
if (seen.has(entry.path)) continue;
seen.add(entry.path);
const workspaceRel = relative(rootReal, entry.path).replace(/\\/g, "/");
results.push({
path: entry.path,
relativePath: workspaceRel,
isDirectory: entry.isDirectory,
depth: entry.depth + depthOffset,
});
}
}

return results;
}
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { z } from "zod";
import { createEmbeddingTrackerController } from "./core/embedding-tracker.js";
import { parseExtraRoots } from "./core/extra-roots.js";
import { createIdleMonitor, getIdleShutdownMs, getParentPollMs, isBrokenPipeError, runCleanup, startParentMonitor } from "./core/process-lifecycle.js";
import { setExtraRoots } from "./core/walker.js";
import { getContextTree } from "./tools/context-tree.js";
import { getFileSkeleton } from "./tools/file-skeleton.js";
import { ensureMcpDataDir, cancelAllEmbeddings } from "./core/embeddings.js";
Expand Down Expand Up @@ -39,6 +41,16 @@ const passthroughArgs = process.argv.slice(2);
const ROOT_DIR = passthroughArgs[0] && !SUB_COMMANDS.includes(passthroughArgs[0])
? resolve(passthroughArgs[0])
: process.cwd();

const extraRootsResult = parseExtraRoots({
argv: passthroughArgs,
env: process.env,
rootDir: ROOT_DIR,
});
for (const warning of extraRootsResult.warnings) {
process.stderr.write(`${warning}\n`);
}
setExtraRoots(extraRootsResult.accepted);
const INSTRUCTIONS_SOURCE_URL = "https://contextplus.vercel.app/api/instructions";
const INSTRUCTIONS_RESOURCE_URI = "contextplus://instructions";
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
Expand Down
4 changes: 2 additions & 2 deletions src/tools/blast-radius.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Dependency graph analyzer to trace symbol usage across the codebase
// Finds every file and line where a function, class, or variable is referenced

import { walkDirectory } from "../core/walker.js";
import { walkRoots } from "../core/walker.js";
import { isSupportedFile } from "../core/parser.js";
import { readFile } from "fs/promises";

Expand All @@ -18,7 +18,7 @@ interface SymbolUsage {
}

export async function getBlastRadius(options: BlastRadiusOptions): Promise<string> {
const entries = await walkDirectory({ rootDir: options.rootDir, depthLimit: 0 });
const entries = await walkRoots({ rootDir: options.rootDir, depthLimit: 0 });
const files = entries.filter((e) => !e.isDirectory && isSupportedFile(e.path));
const usages: SymbolUsage[] = [];
const symbolPattern = new RegExp(`\\b${escapeRegex(options.symbolName)}\\b`, "g");
Expand Down
4 changes: 2 additions & 2 deletions src/tools/context-tree.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Structural tree generator with file headers, symbols, and depth control
// Dynamic token-aware pruning: Level 0 (files only) to Level 2 (deep context)

import { walkDirectory, type FileEntry } from "../core/walker.js";
import { walkRoots, type FileEntry } from "../core/walker.js";
import { analyzeFile, formatSymbol, isSupportedFile } from "../core/parser.js";

export interface ContextTreeOptions {
Expand Down Expand Up @@ -105,7 +105,7 @@ function pruneHeaders(node: TreeNode): void {
}

export async function getContextTree(options: ContextTreeOptions): Promise<string> {
const entries = await walkDirectory({
const entries = await walkRoots({
rootDir: options.rootDir,
targetPath: options.targetPath,
depthLimit: options.depthLimit,
Expand Down
Loading