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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file.

### Added
- `mex telemetry disable` and `mex telemetry enable`, writing the same `~/.mex/config.json` key as `mex config set telemetry on|off`. `mex telemetry --help` and `mex telemetry status` now name the `DO_NOT_TRACK=1` and `MEX_TELEMETRY=0` env opt-outs and say which one is in effect; previously the only switch lived under `config` and the env vars appeared solely in the first-run notice (#110).
- Coverage reporting for source files no extractor indexes. `mex graph` now prints the recognized-but-unindexed file count grouped by extension after the build summary, with the full histogram behind `--json` as `unindexedSources`; `mex graph query` and `mex impact` add `filesIndexed` and `unindexedSources` coverage context to `TARGET_NOT_FOUND` records (only when it changes the record's meaning, so misses in fully covered repositories are unchanged); and `mex doctor` shows a Coverage line. A mixed repository used to build a complete-looking graph while every `.svelte`, `.vue` or `.go` file was silently absent, indistinguishable from an empty one (#163).

## [0.8.0] - 2026-09-02

Expand Down
14 changes: 13 additions & 1 deletion src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "./drift/index.js";
import { checkHeartbeat } from "./heartbeat.js";
import { readEvents } from "./events.js";
import { unindexedExtensionHistogram } from "./graph/corpus-policy.js";
import {
graphChangeDetail,
graphPrimaryDiagnostic,
Expand All @@ -25,20 +26,25 @@ export async function runDoctor(config: MexConfig): Promise<void> {
const heartbeat = checkHeartbeat(config);
const events = readEvents(config);
const graph = report.graphStatus;
const coverage = unindexedExtensionHistogram(config.projectRoot);

printLine("Drift", report.score >= 80 && errors === 0, `${report.score}/100 (${errors} errors, ${warnings} warnings)`);
printLine("Graph", graph.status === "fresh", graphStatusDetail(graph));
printLine("Coverage", coverage.total === 0, coverageDetail(coverage));
printLine("Heartbeat", heartbeat.ok, heartbeat.ok ? "HEARTBEAT_OK" : `${heartbeat.staleFiles.length} stale files, ${heartbeat.oldDailyMemoryFiles.length} old memory files`);
printLine("Events", true, `${events.length} logged event${events.length === 1 ? "" : "s"}`);
const hasConfig = existsSync(resolve(config.scaffoldRoot, "config.json"));
printLine("Config", true, hasConfig ? ".mex/config.json loaded with defaults for missing values" : "using defaults; no .mex/config.json found");

const graphNeedsAttention = graph.status !== "fresh";
const graphRecoveryCommand = graphRemediationCommand(graph);
if (errors || warnings || !heartbeat.ok || graphNeedsAttention) {
if (errors || warnings || !heartbeat.ok || graphNeedsAttention || coverage.total > 0) {
console.log();
console.log(chalk.bold("Next steps"));
if (errors || warnings) console.log(" Run `mex check` for drift details, then `mex sync` for targeted repair prompts.");
if (coverage.total > 0) {
console.log(" Source files above exist in the repository but are absent from the graph; no extractor ships for their language yet.");
}
if (graphNeedsAttention && graphRecoveryCommand) {
console.log(` Run \`${graphRecoveryCommand}\` to repair the graph explicitly.`);
} else if (graphNeedsAttention) {
Expand All @@ -50,6 +56,12 @@ export async function runDoctor(config: MexConfig): Promise<void> {
if (errors) process.exitCode = 1;
}

function coverageDetail(coverage: ReturnType<typeof unindexedExtensionHistogram>): string {
if (coverage.total === 0) return "all recognized source files are indexable";
const suffix = coverage.truncated ? ", walk stopped early" : "";
return `${coverage.total} source file(s) not indexable by any extractor${suffix}`;
}

function graphStatusDetail(graph: GraphAwareDriftReport["graphStatus"]): string {
const diagnostic = graphPrimaryDiagnostic(graph);
return `${graph.status}; ${graphChangeDetail(graph)}${diagnostic ? `; ${diagnostic}` : ""}`;
Expand Down
58 changes: 58 additions & 0 deletions src/graph/__tests__/cli-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1342,6 +1342,64 @@ describe("runGraphQuery", () => {
for (const result of results) expect(result).not.toHaveProperty("source");
});

it("keeps TARGET_NOT_FOUND bare when every recognized source file is indexed", () => {
const bareRoot = mkdtempSync(join(tmpdir(), "mex-cli-agent-bare-"));
try {
writeFileSync(join(bareRoot, "only.ts"), "export const only = 1;\n");
const graph = syntheticScopeGraph({
nodes: [],
sources: [{ path: "only.ts", content: "export const only = 1;\n" }],
searchNodes: () => [],
});
const bareDeps: AgentCommandDeps = {
open: () => ({ graph, db: deps.open!(bareRoot).db, close: () => {} }),
write: (line) => lines.push(line),
};
const records = capture(() => runGraphQuery("where-defined", "ghost", bareRoot, bareDeps, {}));
const error = records.find((r) => r.type === "error" && r.code === "TARGET_NOT_FOUND");
expect(error).toMatchObject({ type: "error", code: "TARGET_NOT_FOUND", target: "ghost" });
expect(error).not.toHaveProperty("filesIndexed");
expect(error).not.toHaveProperty("unindexedSources");
} finally {
rmSync(bareRoot, { recursive: true, force: true });
}
});

it("names unindexed source files on TARGET_NOT_FOUND so a miss is not confused with an unindexable file", async () => {
const mixedRoot = mkdtempSync(join(tmpdir(), "mex-cli-agent-coverage-"));
let mixedEngine: GraphEngine | null = null;
let db: ReturnType<typeof openSqlite> | null = null;
try {
writeFileSync(
join(mixedRoot, "api.ts"),
"export function fetchOrders(userId: string): string[] {\n return [userId];\n}\n",
);
writeFileSync(join(mixedRoot, "OrderList.svelte"), "<script lang=\"ts\">\nlet userId = '';\n</script>\n");
writeFileSync(join(mixedRoot, "main.go"), "package main\n");
mixedEngine = createGraphEngine({ rootDir: mixedRoot });
await mixedEngine.build(mixedRoot);
db = openSqlite(join(mixedRoot, ".mex", "graph.db"));
const mixedDeps: AgentCommandDeps = {
open: () => ({ graph: mixedEngine!, db: db!, close: () => {} }),
write: (line) => lines.push(line),
};

const records = capture(() => runGraphQuery("where-defined", "refreshOrders", mixedRoot, mixedDeps, {}));
const error = records.find((r) => r.type === "error" && r.code === "TARGET_NOT_FOUND");
expect(error).toMatchObject({
type: "error",
code: "TARGET_NOT_FOUND",
target: "refreshOrders",
filesIndexed: 1,
unindexedSources: { total: 2, byExtension: { ".go": 1, ".svelte": 1 } },
});
} finally {
db?.close();
mixedEngine?.close();
rmSync(mixedRoot, { recursive: true, force: true });
}
});

it("preserves the queried target on each result", () => {
const records = capture(() => runGraphQuery("who-calls", "helper", root, deps, {}));
const results = records.filter((r) => r.type === "result");
Expand Down
118 changes: 118 additions & 0 deletions src/graph/__tests__/coverage-histogram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
GRAPH_COVERAGE_LIMITS,
unindexedExtensionHistogram,
} from "../corpus-policy.js";

const roots: string[] = [];

afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

function temporaryRoot(prefix = "mex-graph-coverage-"): string {
const root = mkdtempSync(join(tmpdir(), prefix));
roots.push(root);
return root;
}

function source(root: string, path: string, contents: string): void {
const absolute = join(root, path);
mkdirSync(join(absolute, ".."), { recursive: true });
writeFileSync(absolute, contents);
}

describe("unindexedExtensionHistogram", () => {
it("reports recognized source extensions no extractor handles", () => {
const root = temporaryRoot();
source(root, "main.go", "package main\n");
source(root, "internal/server/server.go", "package server\n");
source(root, "src/App.svelte", "<script>let x = 1;</script>\n");
source(root, "src/api.ts", "export const api = true;\n");

const coverage = unindexedExtensionHistogram(root);

expect(coverage.total).toBe(3);
expect(coverage.entries).toEqual([
{ extension: ".go", files: 2 },
{ extension: ".svelte", files: 1 },
]);
expect(coverage.truncated).toBe(false);
});

it("ignores unsupported-but-uninteresting extensions, dotfiles and dot-directories", () => {
const root = temporaryRoot();
source(root, "README.md", "# readme\n");
source(root, "package.json", "{}\n");
source(root, "assets/logo.png", "png");
source(root, ".github/workflows/deploy.yml", "on: push\n");
source(root, ".gitignore", "node_modules\n");
source(root, "Dockerfile", "FROM node\n");
source(root, "src/api.ts", "export const api = true;\n");

const coverage = unindexedExtensionHistogram(root);

expect(coverage.total).toBe(0);
expect(coverage.entries).toEqual([]);
});

it("honors the repository's configured graph.ignore globs", () => {
const root = temporaryRoot();
source(root, "vendor/legacy.go", "package legacy\n");
source(root, "main.rb", "puts 1\n");
source(root, ".mex/config.json", JSON.stringify({
graph: { ignore: ["vendor/**"] },
}));

const coverage = unindexedExtensionHistogram(root);

expect(coverage.total).toBe(1);
expect(coverage.entries).toEqual([{ extension: ".rb", files: 1 }]);
});

it("is empty on a repository with nothing recognized at all", () => {
const root = temporaryRoot();
source(root, "notes.txt", "hello\n");

const coverage = unindexedExtensionHistogram(root);

expect(coverage.total).toBe(0);
});

it("stops the walk when the scan cap is crossed and reports truncation", () => {
const root = temporaryRoot();
const limits = { maxUnindexedFiles: 50, maxUnindexedEntries: 24 };
// Only counted files, so the processed-file cap maps exactly onto the total.
for (let i = 0; i < limits.maxUnindexedFiles + 10; i++) {
source(root, `generated/file-${i}.go`, "package main\n");
}

const coverage = unindexedExtensionHistogram(root, limits);

expect(coverage.truncated).toBe(true);
expect(coverage.total).toBe(limits.maxUnindexedFiles);
expect(coverage.entries[0]).toEqual({ extension: ".go", files: limits.maxUnindexedFiles });
});

it("caps the reported entries at maxUnindexedEntries, most common first", () => {
const root = temporaryRoot();
const limits = { maxUnindexedFiles: 1000, maxUnindexedEntries: 3 };
for (const extension of [".go", ".rb", ".java", ".kt", ".scala"]) {
source(root, `src/main${extension}`, "x\n");
}
source(root, "src/extra.go", "package extra\n");

const coverage = unindexedExtensionHistogram(root, limits);

expect(coverage.total).toBe(6);
expect(coverage.entries).toEqual([
{ extension: ".go", files: 2 },
{ extension: ".java", files: 1 },
{ extension: ".kt", files: 1 },
]);
expect(coverage.truncated).toBe(false);
});
});
38 changes: 36 additions & 2 deletions src/graph/cli-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { graphManifest } from "./engine-impl.js";
import type { GraphEngine, GraphNeighbor, IndexedFileInfo } from "./engine.js";
import type { SqliteDatabase } from "./db/sqlite.js";
import { isSupportedSourceFile, SUPPORTED_SOURCE_GLOB } from "./extraction/index.js";
import { unindexedExtensionHistogram } from "./corpus-policy.js";
import type { GraphEdge, GraphNode } from "./types.js";
import {
compactFact, groupByFile, planFileSource, readNodeSource, selectScope, sourceHash,
Expand Down Expand Up @@ -74,7 +75,10 @@ export function runImpact(
const fileNodes = nodesForFile(session, rootDir, target);
const roots = fileNodes.length > 0 ? fileNodes : resolveSymbol(session.graph, target);
if (roots.length === 0) {
writeJson(write, { type: "error", code: "TARGET_NOT_FOUND", target });
writeJson(write, {
type: "error", code: "TARGET_NOT_FOUND", target,
...targetNotFoundCoverage(session.graph, rootDir),
});
return;
}
if (fileNodes.length === 0 && roots.length > 1) {
Expand Down Expand Up @@ -164,7 +168,10 @@ export function runGraphQuery(
if (nodes.length === 0) {
if (relation === "who-calls"
&& emitUnresolvedCallers(session, write, target, opts)) return;
writeJson(write, { type: "error", code: "TARGET_NOT_FOUND", target });
writeJson(write, {
type: "error", code: "TARGET_NOT_FOUND", target,
...targetNotFoundCoverage(session.graph, rootDir),
});
return;
}

Expand Down Expand Up @@ -2227,6 +2234,33 @@ function liveUnindexedFiles(indexedFiles: IndexedFileInfo[], rootDir: string): s
.sort();
}

/**
* Coverage context for a `TARGET_NOT_FOUND` record.
*
* A miss and a typo currently emit the identical record, so an agent cannot
* tell "this symbol does not exist" from "this symbol lives in a file no
* extractor indexes". The context is emitted only when it changes the
* record's meaning — the store indexed nothing, or recognized source files
* were left unindexed — so ordinary misses in a healthy repository keep
* their exact prior shape. Absent otherwise, because this reporting must
* never fail the command that carries it.
*/
function targetNotFoundCoverage(graph: GraphEngine, rootDir: string): Record<string, unknown> {
const indexedFiles = graph.getIndexedFiles?.() ?? [];
const coverage = unindexedExtensionHistogram(rootDir);
if (coverage.total === 0 && indexedFiles.length > 0) return {};
const context: Record<string, unknown> = { filesIndexed: indexedFiles.length };
if (coverage.total > 0) {
context.unindexedSources = {
total: coverage.total,
byExtension: Object.fromEntries(
coverage.entries.map((entry) => [entry.extension, entry.files]),
),
};
}
return context;
}

function nodeRef(node: GraphNode): Record<string, string | number> {
return { id: node.id, kind: node.kind, name: node.name, file: node.filePath, line: node.startLine };
}
Expand Down
40 changes: 40 additions & 0 deletions src/graph/cli-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
type GraphMaintenanceResult,
} from "./maintenance.js";
import { inspectGraphStatus } from "./status.js";
import {
unindexedExtensionHistogram,
type GraphCoverageHistogram,
} from "./corpus-policy.js";

export interface GraphCommandOptions {
/** Project root to inspect or maintain (defaults to cwd). */
Expand Down Expand Up @@ -45,6 +49,7 @@ export async function runGraphRebuild(options: GraphCommandOptions = {}): Promis
export async function runGraph(options: GraphCommandOptions = {}): Promise<void> {
const rootDir = options.root ?? process.cwd();
const result = await rebuildGraph(rootDir);
const coverage = unindexedExtensionHistogram(rootDir);
if (options.json) {
console.log(JSON.stringify({
filesIndexed: result.filesIndexed,
Expand All @@ -56,6 +61,15 @@ export async function runGraph(options: GraphCommandOptions = {}): Promise<void>
partial: result.status.parseHealth.partial,
failed: result.status.parseHealth.failed,
},
...(coverage.total > 0
? {
unindexedSources: {
total: coverage.total,
byExtension: Object.fromEntries(coverage.entries.map((e) => [e.extension, e.files])),
truncated: coverage.truncated,
},
}
: {}),
...(result.skipped && result.skipped.length > 0 ? { skipped: result.skipped } : {}),
...(result.declinedInputs && result.declinedInputs.length > 0
? { declinedInputs: result.declinedInputs }
Expand All @@ -67,10 +81,36 @@ export async function runGraph(options: GraphCommandOptions = {}): Promise<void>
`Code graph built: ${result.nodesCreated} nodes, ${result.edgesCreated} edges `
+ `across ${result.filesIndexed} files in ${result.durationMs}ms → .mex/graph.db`,
);
printUnindexedSources(coverage);
printSkippedSources(result.skipped);
printDeclinedInputs(result.declinedInputs);
}

/**
* Name the source files no extractor handles, grouped by extension.
*
* `filesIndexed` alone cannot distinguish "nothing to index" from "everything
* except the languages we don't support" — the second looks identical to the
* first from `Code graph built: 0 nodes`, and an agent asking about a symbol
* that exists in a `.svelte` or `.go` file gets the same `TARGET_NOT_FOUND`
* a typo gets. Absent when the histogram found nothing, so existing outputs
* (and every script consuming them) are unchanged for fully supported repos.
*/
function printUnindexedSources(coverage: GraphCoverageHistogram): void {
if (coverage.total === 0) return;
const shown = coverage.entries.slice(0, MAX_SKIPPED_PATHS_SHOWN);
const breakdown = shown.map((entry) => `${entry.extension} (${entry.files})`).join(", ");
console.log(
`Not indexed: ${coverage.total} source file(s) have extensions no extractor handles: ${breakdown}`,
);
if (coverage.entries.length > shown.length) {
console.log(" …and more extensions (use --json for the full breakdown)");
}
if (coverage.truncated) {
console.log(" (count may be higher: the repository walk stopped early)");
}
}

/**
* Name the files that are deliberately missing from the graph.
*
Expand Down
Loading
Loading