diff --git a/CHANGELOG.md b/CHANGELOG.md index 912f7f20..dd60d7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/doctor.ts b/src/doctor.ts index c7b14555..15d8487a 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -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, @@ -25,9 +26,11 @@ export async function runDoctor(config: MexConfig): Promise { 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")); @@ -35,10 +38,13 @@ export async function runDoctor(config: MexConfig): Promise { 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) { @@ -50,6 +56,12 @@ export async function runDoctor(config: MexConfig): Promise { if (errors) process.exitCode = 1; } +function coverageDetail(coverage: ReturnType): 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}` : ""}`; diff --git a/src/graph/__tests__/cli-agent.test.ts b/src/graph/__tests__/cli-agent.test.ts index 23ffbdda..18d4de50 100644 --- a/src/graph/__tests__/cli-agent.test.ts +++ b/src/graph/__tests__/cli-agent.test.ts @@ -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 | null = null; + try { + writeFileSync( + join(mixedRoot, "api.ts"), + "export function fetchOrders(userId: string): string[] {\n return [userId];\n}\n", + ); + writeFileSync(join(mixedRoot, "OrderList.svelte"), "\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"); diff --git a/src/graph/__tests__/coverage-histogram.test.ts b/src/graph/__tests__/coverage-histogram.test.ts new file mode 100644 index 00000000..42d41306 --- /dev/null +++ b/src/graph/__tests__/coverage-histogram.test.ts @@ -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", "\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); + }); +}); diff --git a/src/graph/cli-agent.ts b/src/graph/cli-agent.ts index 57b957e1..ec606f93 100644 --- a/src/graph/cli-agent.ts +++ b/src/graph/cli-agent.ts @@ -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, @@ -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) { @@ -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; } @@ -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 { + const indexedFiles = graph.getIndexedFiles?.() ?? []; + const coverage = unindexedExtensionHistogram(rootDir); + if (coverage.total === 0 && indexedFiles.length > 0) return {}; + const context: Record = { 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 { return { id: node.id, kind: node.kind, name: node.name, file: node.filePath, line: node.startLine }; } diff --git a/src/graph/cli-graph.ts b/src/graph/cli-graph.ts index 04d9cb64..d6f297c9 100644 --- a/src/graph/cli-graph.ts +++ b/src/graph/cli-graph.ts @@ -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). */ @@ -45,6 +49,7 @@ export async function runGraphRebuild(options: GraphCommandOptions = {}): Promis export async function runGraph(options: GraphCommandOptions = {}): Promise { 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, @@ -56,6 +61,15 @@ export async function runGraph(options: GraphCommandOptions = {}): Promise 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 } @@ -67,10 +81,36 @@ export async function runGraph(options: GraphCommandOptions = {}): Promise `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. * diff --git a/src/graph/corpus-policy.ts b/src/graph/corpus-policy.ts index 5754e1b9..810b7e8a 100644 --- a/src/graph/corpus-policy.ts +++ b/src/graph/corpus-policy.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { globIterateSync, type GlobOptions } from "glob"; -import { SUPPORTED_SOURCE_GLOB } from "./extraction/grammars.js"; +import { isSupportedSourceFile, SUPPORTED_SOURCE_GLOB } from "./extraction/grammars.js"; /** One source of truth for repository files that participate in graph identity. */ export const GRAPH_CORPUS_IGNORE_GLOBS = Object.freeze([ @@ -327,3 +327,109 @@ export function graphCorpusPolicyHash(root: string): string { configuredIgnoreGlobs: configured, })).digest("hex"); } + +/** + * Extensions of programming-language source files no extractor indexes yet. + * + * Reporting filters through this list rather than counting every non-indexed + * extension, so READMEs, lockfiles, assets and configs do not drown the + * signal — the field report was about `.go`, `.svelte` and `.vue` files, not + * about `.md` or `.png`. Each extension stops being reported the moment an + * extractor claims it, because candidates are checked with + * {@link isSupportedSourceFile} against the live extension map, never against + * this list alone. + */ +export const OTHER_KNOWN_SOURCE_EXTENSIONS: ReadonlySet = new Set([ + ".astro", ".bash", ".c", ".cc", ".clj", ".cljs", ".coffee", ".cpp", ".cr", + ".cs", ".cxx", ".d", ".dart", ".elm", ".erl", ".ex", ".exs", ".f90", ".f95", + ".go", ".gradle", ".groovy", ".h", ".hh", ".hpp", ".hrl", ".hs", ".hxx", + ".java", ".jl", ".kt", ".kts", ".lua", ".m", ".mm", ".nim", ".pas", ".php", + ".pl", ".pm", ".proto", ".ps1", ".r", ".rb", ".scala", ".sol", ".sql", + ".svelte", ".swift", ".tcl", ".vim", ".vue", ".zsh", ".zig", +] as const); + +/** One line of the coverage histogram: an extension and how many files use it. */ +export interface GraphUnindexedExtension { + /** Lowercased extension including the leading dot, e.g. `".go"`. */ + extension: string; + /** Non-ignored repository files with this extension. */ + files: number; +} + +/** Bounded reporting: the highest-count extensions survive, never the run. */ +export const GRAPH_COVERAGE_LIMITS: GraphCoverageLimits = Object.freeze({ + maxUnindexedFiles: 20_000, + maxUnindexedEntries: 24, +} as const); + +export interface GraphCoverageLimits { + /** Cap on files the complementary walk may scan before it stops, truncated. */ + maxUnindexedFiles: number; + /** Cap on distinct extensions kept in the reported histogram. */ + maxUnindexedEntries: number; +} + +export interface GraphCoverageHistogram { + /** Total known-source files outside the indexed extensions (exact unless truncated). */ + total: number; + /** Most common first, capped at {@link GRAPH_COVERAGE_LIMITS.maxUnindexedEntries}. */ + entries: GraphUnindexedExtension[]; + /** True when the walk cap stopped the scan before the repository was finished. */ + truncated: boolean; +} + +/** + * Count the repository's recognized-but-unindexed source files, by extension. + * + * The walk complements {@link discoverBoundedGraphPaths}: same glob options and + * ignore list (so a repository's own `graph.ignore` config shapes the answer), + * but globbing every file and keeping only known-source extensions + * {@link isSupportedSourceFile} rejects. Dot-directories stay invisible, as + * they are to source discovery itself. + * + * Best-effort by contract: an unreadable tree yields an empty histogram rather + * than an error, because this reporting must never fail the command that + * carries it. + */ +export function unindexedExtensionHistogram( + root: string, + limits: GraphCoverageLimits = GRAPH_COVERAGE_LIMITS, +): GraphCoverageHistogram { + const counts = new Map(); + let total = 0; + let scanned = 0; + let truncated = false; + try { + for (const match of globIterateSync("**/*", { + ...GRAPH_CORPUS_GLOB_OPTIONS, + cwd: root, + ignore: graphCorpusIgnoreGlobs(root), + })) { + // The cap bounds the whole walk, not only counted files: a repository of + // a hundred thousand uninteresting files must not scan forever either. + scanned++; + if (scanned > limits.maxUnindexedFiles) { + truncated = true; + break; + } + const relPath = String(match).split("\\").join("/"); + if (isSupportedSourceFile(relPath)) continue; + const segment = relPath.slice(relPath.lastIndexOf("/") + 1); + const dot = segment.lastIndexOf("."); + // No extension, a dotfile, or a trailing dot is not a language signal. + if (dot <= 0 || dot === segment.length - 1) continue; + const extension = segment.slice(dot).toLowerCase(); + if (!OTHER_KNOWN_SOURCE_EXTENSIONS.has(extension)) continue; + total++; + counts.set(extension, (counts.get(extension) ?? 0) + 1); + } + } catch { + // Coverage reporting must never fail a build; a partial or empty report + // reads as "nothing observed", which is what an unreadable tree tells. + } + const entries = [...counts.entries()] + .sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : 1)) + .slice(0, limits.maxUnindexedEntries) + .map(([extension, files]) => ({ extension, files })); + return { total, entries, truncated }; +} diff --git a/test/graph-cli-agent.test.ts b/test/graph-cli-agent.test.ts index 9f8a2246..f48e31e3 100644 --- a/test/graph-cli-agent.test.ts +++ b/test/graph-cli-agent.test.ts @@ -95,11 +95,18 @@ describe("agent graph commands", () => { }); it("abstains when a targeted symbol lookup has only fuzzy matches", () => { - const fixture = deps(); - runGraphQuery("where-defined", "lea", "/repo", fixture.deps); - expect(fixture.output.map((line) => JSON.parse(line))).toEqual([ - { type: "error", code: "TARGET_NOT_FOUND", target: "lea" }, - ]); + // A real empty root: an empty store legitimately adds `filesIndexed: 0` + // coverage context to the abstention, and the walk needs a readable cwd. + const root = mkdtempSync(join(tmpdir(), "mex-query-abstain-")); + try { + const fixture = deps(); + runGraphQuery("where-defined", "lea", root, fixture.deps); + expect(fixture.output.map((line) => JSON.parse(line))).toEqual([ + { type: "error", code: "TARGET_NOT_FOUND", target: "lea", filesIndexed: 0 }, + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("minimal Scope pins a named seed and hydrates its reliable typed neighborhood", () => {