Skip to content
Merged
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
28 changes: 27 additions & 1 deletion .mex/patterns/safe-graph-snapshot-evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ edges:
condition: "when changing the graph data plane or its consumers"
- target: "context/conventions.md"
condition: "when verifying a graph implementation change"
last_updated: 2026-09-03
last_updated: 2026-09-07
mex:
id: mx_01M1M0CJP81C590FCKTSN5HA3Q
type: pattern
Expand Down Expand Up @@ -102,6 +102,32 @@ only to explicit maintenance workflows.
cannot detect facts extracted from B; extraction must be bound to A.
- Graph diagnostics and remediation commands must be truthful. Do not recommend
a command for a state it cannot safely repair.
- A bounded limit is not one kind of thing. A **per-file** ceiling means one
file cannot be parsed and every other file still can, so it must skip and
report; a **corpus-wide** ceiling means the run has no honest partial answer
and must abort. Conflating them lets one pathological file take a whole
repository's graph with it. Classify the breach, never the fact of one.
- Do the skipping at the single discovery seam. Indexing, publication
verification, sync's corpus comparison and freshness inspection must all agree
about which files exist, or a file skipped by one and expected by another
makes the index permanently unable to read `fresh`.
- Never infer which limit was breached by comparing byte values. Two limits can
hold the same number, and then the comparison silently reports the wrong one
forever. Pass the limit's name.
- A containment guard has two separable decisions: whether to decline, and what
declining does. Read paths already treated an out-of-root source as a soft
miss while config paths threw on the identical condition — an inconsistency
inside one class that cost whole repositories their graph. An existence probe
is not a read request: the honest answer for a path we will not read is
"absent", recorded as a diagnostic so the degradation is visible.
- Report a declined out-of-root path by dependency specifier, not absolutely.
The compiler resolves a bare specifier by walking every ancestor directory, so
one unresolvable name yields a dozen near-identical entries carrying absolute
paths from outside the repository into product output.
- Any per-repository discovery input — a configured ignore list included — must
enter the corpus policy hash, or changing it leaves a stale index silently
describing files that are no longer in the corpus. Hash to the existing
constant when nothing is configured, so existing indexes stay valid.
- Wall-clock status timings vary by machine and process-start overhead. Keep
the benchmark non-gating, record its environment, and protect correctness
with deterministic race, non-mutation, and bounded-work tests.
Expand Down
1 change: 1 addition & 0 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,5 +424,6 @@ MEX mantiene la memoria del equipo en archivos del repositorio y proporciona flu
- Consulta la [guía de entorno de ejecución y compatibilidad](https://github.com/mex-memory/mex/blob/v0.8.0/COMPATIBILITY.md) y la [política de seguridad](https://github.com/mex-memory/mex/blob/v0.8.0/SECURITY.md).
- Revisa la [matriz de compatibilidad del Code Graph](https://github.com/mex-memory/mex/blob/v0.8.0/docs/code-graph-support.md).
- Consulta el [modelo de extractores y las relaciones compatibles](https://github.com/mex-memory/mex/blob/v0.8.0/docs/extractors.md).
- Lee los [resultados del benchmark de recuperación del Code Graph](https://github.com/mex-memory/mex/blob/v0.8.0/evaluate/RESULTS.md), incluida la comparación evaluada a ciegas frente a una búsqueda de archivos convencional.
- Inspecciona la CLI localmente con `mex capabilities --json` y `mex commands`.
- Únete a la [comunidad de MEX en Discord](https://discord.gg/FEdNsQ4Qt4) o visita [mexmemory.com](https://mexmemory.com).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,5 +385,6 @@ MEX keeps team memory in repository files and provides local retrieval and revie
- Check the [runtime and compatibility guide](https://github.com/mex-memory/mex/blob/v0.8.0/COMPATIBILITY.md) and [security policy](https://github.com/mex-memory/mex/blob/v0.8.0/SECURITY.md).
- Review the [Code Graph support matrix](https://github.com/mex-memory/mex/blob/v0.8.0/docs/code-graph-support.md).
- See the [extractor model and supported relationships](https://github.com/mex-memory/mex/blob/v0.8.0/docs/extractors.md).
- Read the [graph retrieval benchmark results](https://github.com/mex-memory/mex/blob/v0.8.0/evaluate/RESULTS.md), including the blind-graded comparison against an ordinary file-search baseline.
- Inspect the CLI locally with `mex capabilities --json` and `mex commands`.
- Join the [MEX community on Discord](https://discord.gg/FEdNsQ4Qt4) or visit [mexmemory.com](https://mexmemory.com).
1 change: 1 addition & 0 deletions README.pt-BR.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,5 +424,6 @@ O MEX mantém a memória da equipe em arquivos do repositório e oferece fluxos
- Consulte o [guia de ambiente de execução e compatibilidade](https://github.com/mex-memory/mex/blob/v0.8.0/COMPATIBILITY.md) e a [política de segurança](https://github.com/mex-memory/mex/blob/v0.8.0/SECURITY.md).
- Veja a [matriz de suporte do Code Graph](https://github.com/mex-memory/mex/blob/v0.8.0/docs/code-graph-support.md).
- Consulte o [modelo dos extratores e as relações suportadas](https://github.com/mex-memory/mex/blob/v0.8.0/docs/extractors.md).
- Leia os [resultados do benchmark de recuperação do Code Graph](https://github.com/mex-memory/mex/blob/v0.8.0/evaluate/RESULTS.md), incluindo a comparação avaliada às cegas com uma busca de arquivos comum.
- Examine a CLI localmente com `mex capabilities --json` e `mex commands`.
- Entre na [comunidade do MEX no Discord](https://discord.gg/FEdNsQ4Qt4) ou visite [mexmemory.com](https://mexmemory.com).
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,5 +424,6 @@ MEX 将团队记忆保存在仓库文件中,并提供本地检索和审阅工
- 查看[运行时与兼容性指南](https://github.com/mex-memory/mex/blob/v0.8.0/COMPATIBILITY.md)和[安全政策](https://github.com/mex-memory/mex/blob/v0.8.0/SECURITY.md)。
- 查阅 [Code Graph 支持矩阵](https://github.com/mex-memory/mex/blob/v0.8.0/docs/code-graph-support.md)。
- 了解[提取器模型和支持的关系](https://github.com/mex-memory/mex/blob/v0.8.0/docs/extractors.md)。
- 阅读 [Code Graph 检索基准结果](https://github.com/mex-memory/mex/blob/v0.8.0/evaluate/RESULTS.md),其中包含与普通文件搜索基线的盲评对比。
- 在本地使用 `mex capabilities --json` 和 `mex commands` 检查 CLI。
- 加入 [Discord 上的 MEX 社区](https://discord.gg/FEdNsQ4Qt4),或访问 [mexmemory.com](https://mexmemory.com)。
101 changes: 97 additions & 4 deletions docs/code-graph-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,100 @@ legacy checks running” case in
Unsupported source-language files are also skipped. A missing extractor does
not make the rest of setup or drift checking fail.

### Files the corpus policy will not index

The graph applies a bounded per-file size ceiling (2 MB) so one pathological
file cannot exhaust memory. A file over that ceiling is **skipped, not fatal**:
the rest of the repository is indexed normally, and the skipped files are
reported by name, size and limit in `mex graph` output and in the `skipped`
array of its `--json` result.

Corpus-*wide* ceilings still abort the run. They describe the whole build and
there is no honest partial answer to "this repository is too large to index
within the bounded policy".

### Config inputs outside the project

mex never reads a file outside the repository root, and a TypeScript config
routinely points at one: `"extends": "some-package/tsconfig"` resolves through
`node_modules`, which any hoisted pnpm/yarn layout — or a monorepo sub-package
indexed on its own — places above the indexed root.

Such an input is **declined, not fatal**. The build finishes, the affected
project's type resolution is less complete than its config asks for, and the
declined inputs are reported by dependency specifier (never by absolute path)
in `mex graph` output and in the `declinedInputs` array of its `--json` result.
The same applies to a `tsconfig` `include` or project `reference` that points
above the root.

The containment guard itself is unchanged: nothing outside the root is read,
and nothing outside the root enters the graph's provenance.

### Excluding paths from the graph

`node_modules`, `.git`, `dist`, `build`, `.mex`, `coverage`, `.next` and `out`
are always excluded. A repository can exclude more by listing globs under
`graph.ignore` in `.mex/config.json`:

```json
{
"graph": {
"ignore": ["vendor/**", "**/*.generated.ts"]
}
}
```

The list is **additive**: configured globs are appended to the built-in ones
and cannot un-ignore them, so `node_modules` and `.mex` stay excluded whatever
the configuration says. Globs are repository-relative; absolute paths and
upward traversal are ignored, and the list is bounded. A missing or malformed
config simply contributes no extra globs rather than failing a build.

Changing this list changes which files the graph describes, so it changes the
build manifest and the next `mex graph status` will report the index as stale
until it is rebuilt.

## Unresolved references

Extraction records every reference it sees. The resolver then binds what it
can to a declaration and emits an edge; what it cannot bind stays recorded as
an unresolved reference. Those records are the graph being honest about its own
blind spots: a name a file referenced, that the resolver could not decide the
meaning of.

They matter for `who-calls`. A dynamically generated method has real call sites
and no literal declaration, so no node resolves and the structural answer is
"not found" — accurate, and useless as a next step. When `who-calls` cannot
resolve its target, it now looks the name up among the recorded unresolved
references and reports those call sites:

```bash
mex graph query who-calls mark_failed
```

```json
{"type":"unresolved-reference","relation":"who-calls","target":"mark_failed",
"name":"mark_failed","referenceKind":"calls","resolution":"unresolved",
"file":"app/models/job.rb","line":42,"col":8,"fromNode":"function:…",
"receiver":"job"}
```

Three properties of that output are deliberate:

- **It is not a `result` record.** An unresolved reference is not a resolved
graph fact and an agent must not be able to confuse the two, so it carries
its own record type.
- **It is capped and charged to the same output budget** as every other
response. Common names accumulate hundreds of unresolved references, and an
uncapped fallback on a hot name would flood the caller. The `summary` reports
the total that matched alongside what was returned.
- **The response is a normal one**, with `meta` and `summary`, and a `summary`
whose `status` is `partial` and `evidenceStrength` is `weak`.

A name with no declaration *and* no recorded reference still abstains with
`TARGET_NOT_FOUND`. `where-defined` and `what-calls` are unchanged: they
either resolve the requested declaration exactly or abstain.

## Known limitations

- **Ambiguous references stay unresolved.** The base resolver prefers a
Expand All @@ -139,10 +233,9 @@ not make the rest of setup or drift checking fail.
reflection, dependency injection, monkey-patching, or computed calls.
- **Generated code is path-filtered, not identified semantically.** Common
output trees such as `node_modules`, `dist`, `build`, `.next`, `out`,
`coverage`, and `.mex` are excluded by the source globs in
[`engine-impl.ts`](../src/graph/engine-impl.ts) and
[`runtime.ts`](../src/graph/runtime.ts). Generated files outside those paths
may still be indexed.
`coverage`, and `.mex` are excluded by the corpus policy in
[`corpus-policy.ts`](../src/graph/corpus-policy.ts). Generated files outside
those paths may still be indexed; add a `graph.ignore` glob to exclude them.
- **Framework behavior is opt-in and narrow.** Express route-to-handler binding
is the only framework fixture in v0.7.0. Other frameworks remain unsupported
until their language extractor and resolver work merges.
Expand Down
4 changes: 4 additions & 0 deletions evaluate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ The older compactness and scripted-agent scripts remain available as historical
`npm run eval:legacy` and `npm run eval:e2e`. They are not evidence that natural-language graph
retrieval works.

Measured results from these harnesses are written up in
[`RESULTS.md`](RESULTS.md), including the blind-graded headless comparison
against an ordinary file-search baseline.

## Deterministic graph evaluation

Build the CLI, then run the native MEX suite:
Expand Down
102 changes: 102 additions & 0 deletions src/graph/__tests__/compiler-containment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { buildTypeScriptExtraction } from "../extraction/compiler.js";
import { createGraphEngine } from "../engine-impl.js";

const roots: string[] = [];

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

/** A project nested one level down, so `..` is a real directory outside it. */
function nestedProject(): { workspace: string; root: string } {
const workspace = mkdtempSync(join(tmpdir(), "mex-graph-containment-"));
roots.push(workspace);
const root = join(workspace, "package");
mkdirSync(root, { recursive: true });
return { workspace, root };
}

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

describe("compiler input containment declines rather than aborting", () => {
it("finishes a build whose tsconfig extends a package hoisted above the root", async () => {
const { workspace, root } = nestedProject();
// The hoisted layout every pnpm/yarn workspace produces: the package the
// config extends resolves above the indexed root.
write(workspace, "node_modules/shared-config/tsconfig.json",
JSON.stringify({ compilerOptions: { strict: true } }));
write(workspace, "node_modules/shared-config/package.json",
JSON.stringify({ name: "shared-config", version: "1.0.0" }));
write(root, "tsconfig.json", JSON.stringify({
extends: "shared-config/tsconfig.json",
compilerOptions: { target: "ES2022" },
}));
write(root, "package.json", JSON.stringify({ name: "package", version: "1.0.0" }));
write(root, "src/service.ts", "export function containedPrimary(): number { return 1; }\n");
const engine = createGraphEngine({ rootDir: root });

try {
const result = await engine.build();

expect(result.filesIndexed).toBe(1);
// The config was declined, not read — and reported by dependency
// specifier rather than by absolute path.
expect(result.declinedInputs).toContainEqual(expect.objectContaining({
filePath: "node_modules/shared-config/tsconfig.json",
reason: "outside-project-corpus",
}));
for (const input of result.declinedInputs ?? []) {
expect(input.filePath.startsWith("node_modules/")).toBe(true);
}
expect(engine.searchNodes("containedPrimary").length).toBeGreaterThan(0);
} finally {
engine.close();
}
}, 60_000);

it("declines an include and a project reference above the root without throwing", () => {
const { workspace, root } = nestedProject();
write(workspace, "sibling/tsconfig.json", JSON.stringify({ compilerOptions: {} }));
write(workspace, "sibling/src/other.ts", "export const other = 1;\n");
write(root, "tsconfig.json", JSON.stringify({
compilerOptions: { composite: true },
include: ["src/**/*", "../sibling/src/**/*"],
references: [{ path: "../sibling" }],
}));
write(root, "src/service.ts", "export function referencedPrimary(): number { return 1; }\n");

const result = buildTypeScriptExtraction(root, ["src/service.ts"], {
stagedInputs: [
{ filePath: "tsconfig.json", source: JSON.stringify({
compilerOptions: { composite: true },
include: ["src/**/*", "../sibling/src/**/*"],
references: [{ path: "../sibling" }],
}) },
{ filePath: "src/service.ts",
source: "export function referencedPrimary(): number { return 1; }\n" },
],
});

expect(result.files.map((file) => file.filePath)).toEqual(["src/service.ts"]);
// The referenced project above the root is declined by path rather than
// traversed. (The `..` include is not declined here: TypeScript invokes
// readDirectory with the config's own directory and applies the include
// patterns inside its matcher, so that guard is reached only when a config
// names an out-of-root directory as the matcher root.)
expect(result.declinedInputs.map((input) => input.filePath))
.toEqual(["../sibling/tsconfig.json"]);
expect(result.declinedInputs[0]?.reason).toBe("outside-project-corpus");
// Nothing outside the root reached the graph's provenance.
for (const input of result.semanticInputs) {
expect(input.filePath.startsWith("..")).toBe(false);
}
});
});
Loading
Loading