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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- **A documentation link to a file that is not there no longer gets its own graph node.** A Markdown link or path mention whose target basename no indexed file has minted an import node and a `contains` edge, even though the resolver's file-path arm could not match it. On supabase a full index now skips 10,394 of 14,038 Markdown import nodes and their `contains` edges. The reference is always kept, so the resolver's route, module and table arms still match one (a relative `api/users.json` link included), `codegraph sync` can resolve a target added later from the parked failed reference, and a skipped target stays in a separate `links:` part of the file digest so it remains searchable past the 12-item display digest. Resolved Markdown edges are byte-identical on supabase (3,058), n8n, pretix and discourse, and the failed Markdown reference count is unchanged (11,368 on supabase). A full supabase index is a median 16.176 s to 15.953 s (Hyperfine 2.0.0, 8 runs per build, two ABBA rounds, 0.223 s/1.4%, interquartile ranges not overlapping). Re-index Markdown-heavy projects after upgrading.

- **`codegraph sessions` and `codegraph_sessions` can search history before a date.** A search could only look back a number of days (`--since`, `sinceDays`), so its best 100 hits always came from the newest matching sessions and older context could not be reached. `--until <date>` and `until` keep only docs before a date (read as UTC) or a timestamp that names its zone; together with `--since` they bound a window.

- A background sync after editing a widely imported file no longer leaves the MCP daemon holding about 19 GB (n8n, one new export in a barrel imported from 335 files). Re-checking the importers built a native resolver per binding and the process kept about 2.5 MB of each; they now share one. That check went from 38.7 s to 0.5 s, and idle memory after the edit from 19.4 GB to 1.4 GB.
Expand Down
16 changes: 4 additions & 12 deletions __tests__/fixtures/golden/payroll-go.dump

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions __tests__/integration/full-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,45 @@ node scripts/release.mjs
}
});

it('resolves a full-index Markdown link after sync adds its target', async () => {
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'README.md'),
`# Guide

## Links

See [Setup](docs/setup.md#install).
`
);

const cg = await CodeGraph.init(tempDir);
try {
await cg.indexAll();
// The target is absent: the reference is kept (parked as failed), but no
// edge exists yet.
expect(cg.getNodesInFile('docs/setup.md')).toEqual([]);
const linksHeading = cg
.getNodesInFile('README.md')
.find((n) => n.kind === 'module' && n.name === 'Links')!;
expect(
cg.getOutgoingEdges(linksHeading.id)
.filter((e) => e.kind === 'imports')
.map((e) => cg.getNode(e.target)?.qualifiedName)
).not.toContain('docs/setup.md#install');

fs.writeFileSync(path.join(tempDir, 'docs', 'setup.md'), '# Install\n\nInstall it.\n');
await cg.sync();

const install = cg
.getNodesInFile('docs/setup.md')
.find((n) => n.kind === 'module' && n.name === 'Install')!;
expect(cg.getIncomingEdges(install.id, ['imports']).some((e) => e.source === linksHeading.id)).toBe(true);
} finally {
cg.destroy();
}
});

it('indexes Markdown template tables and resolves file-symbol references to implementation functions', async () => {
fs.mkdirSync(path.join(tempDir, 'phases'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true });
Expand Down
121 changes: 121 additions & 0 deletions __tests__/markdown-path-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* A Markdown path target whose basename no indexed file has cannot resolve to
* a file through the resolver's file-path arm. Its reference is still kept —
* the resolver's route/module/symbol arms may match it, and sync can retry it
* when a file appears later (#1240) — but its own import node only added a
* search child and a `contains` edge. A full index skips that node; sync keeps
* every node. Pruned targets stay in a separate, larger `links:` part of the
* file digest so a link beyond the 12-item display digest is still searchable.
*/
import { describe, it, expect } from 'vitest';
import { extractFromSource } from '../src/extraction/tree-sitter';
import type { MarkdownExtractorContext } from '../src/extraction/markdown-extractor';

function context(prune: boolean, ...files: string[]): MarkdownExtractorContext {
return {
knownFileNames: new Set(files.map((file) => file.split('/').pop()!)),
pruneMissingTargets: prune,
};
}

const FULL_INDEX = context(true, 'docs/guide.md', 'src/widget.ts', 'scripts/exists.sh');
const SYNC = context(false, 'docs/guide.md', 'src/widget.ts', 'scripts/exists.sh');

const SOURCE = [
'# Guide',
'',
'Preamble about `createWidget`.',
'',
'## Links',
'',
'- [widget](../src/widget.ts)',
'- [missing](../src/missing.ts)',
'- [same name, other path](../apps/widget.ts#usage)',
'- [pricing](/pricing)',
'- [users route](/api/users/)',
'- [guide route](../guides/auth)',
'- [dotted route](api/users.json)',
'- [one](../src/one.ts)',
'- [two](../src/two.ts)',
'- [three](../src/three.ts)',
'- [four](../src/four.ts)',
'- [five](../src/five.ts)',
'- [six](../src/six.ts)',
'- [seven](../src/seven.ts)',
'- [eight](../src/eight.ts)',
'- [nine](../src/nine.ts)',
'- [ten](../src/ten.ts)',
'- [eleven](../src/eleven.ts)',
'- [twelve](../src/twelve.ts)',
'- [diagram](../assets/releaseblueprint.svg)',
'- [this section](#links)',
'',
'Mention `scripts/run.sh`, `scripts/widget.ts` and `src/widget.ts::createWidget`.',
'A missing symbol target: `src/missing.ts::createWidget`.',
'',
'```sh',
'bash scripts/missing.sh',
'bash scripts/exists.sh',
'```',
].join('\n');

function extract(withContext?: MarkdownExtractorContext) {
return extractFromSource('docs/guide.md', SOURCE, 'markdown', [], withContext);
}

function refs(withContext?: MarkdownExtractorContext): string[] {
return extract(withContext).unresolvedReferences.map((ref) => ref.referenceName);
}

function importTargets(withContext?: MarkdownExtractorContext): string[] {
return extract(withContext)
.nodes.filter((node) => node.kind === 'import')
.map((node) => node.qualifiedName);
}

describe('Markdown link-node pruning', () => {
it('keeps every reference and link node without a context', () => {
const names = refs();
expect(names).toContain('src/widget.ts');
expect(names).toContain('src/missing.ts');
expect(names).toContain('docs/scripts/run.sh');
expect(importTargets().some((qn) => qn.includes('missing.ts'))).toBe(true);
});

it('keeps the reference but skips the link node on a full index', () => {
const names = refs(FULL_INDEX);
// The reference survives so routes, modules and later syncs can match it.
expect(names).toContain('src/missing.ts');
expect(names).toContain('docs/scripts/run.sh');
expect(names).toContain('docs/scripts/missing.sh');
// The dead target's import node does not exist.
expect(importTargets(FULL_INDEX).some((qn) => qn.includes('missing.ts'))).toBe(false);
// A target whose basename is known keeps its node.
expect(importTargets(FULL_INDEX).some((qn) => qn.includes('src/widget.ts'))).toBe(true);
});

it('keeps every link node on an incremental sync', () => {
expect(importTargets(SYNC).some((qn) => qn.includes('missing.ts'))).toBe(true);
});

it('keeps routes and targets the resolver reaches by name', () => {
const names = refs(FULL_INDEX);
expect(names).toContain('apps/widget.ts#usage');
expect(names).toContain('docs/scripts/widget.ts');
expect(names).toContain('pricing');
expect(names).toContain('api/users/');
expect(names).toContain('guides/auth');
// A relative route with a dotted final segment is kept for name matching.
expect(names).toContain('docs/api/users.json');
expect(names).toContain('docs/src/widget.ts::createWidget');
expect(names).toContain('docs/guide.md#links');
expect(names).toContain('docs/scripts/exists.sh');
});

it('keeps a pruned target searchable beyond the display digest budget', () => {
const fileNode = extract(FULL_INDEX).nodes.find((node) => node.kind === 'file');
// The target is the 13th link, past the 12-item `refs:` budget.
expect(fileNode?.docstring).toContain('links:');
expect(fileNode?.docstring).toContain('releaseblueprint.svg');
});
});
21 changes: 20 additions & 1 deletion src/extraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2402,6 +2402,24 @@ export class ExtractionOrchestrator {
}, skipStats);
this.prepareLiquidThemeRoots(files);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] scan: ${Date.now() - tScan}ms (${files.length} files)`);

// Markdown link/path targets resolve through the file-path arm, which
// collects candidates by basename. Hand extraction that set so a path
// mention whose target file is absent creates no import node and no
// failed reference. Existing DB rows are included on a re-index so a ref
// whose target is still tracked (but gone from disk) resolves as before.
const knownFileNames = new Set<string>();
const rememberBasename = (file: string): void => {
const cut = Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\'));
knownFileNames.add(cut >= 0 ? file.slice(cut + 1) : file);
};
for (const file of files) rememberBasename(file);
for (const file of this.queries.getAllFilePaths()) rememberBasename(file);
// A full index has the whole project in hand, so a relative file target
// with no matching basename is dead. An incremental sync re-extracts one
// file at a time and must keep the reference for a target added later
// (#1240), so indexFile() passes no context and keeps every target.
const extractionContext = { knownFileNames, pruneMissingTargets: true };
Comment thread
bompus marked this conversation as resolved.
Resolved
/** Only meaningful when nothing was indexable — see IndexResult (#1502). */
const skipSummary = (): Pick<IndexResult, 'filesSkippedUnsupported' | 'topUnsupportedExtensions'> => {
let total = 0;
Expand Down Expand Up @@ -2498,6 +2516,7 @@ export class ExtractionOrchestrator {
recycleInterval: WORKER_RECYCLE_INTERVAL,
parseTimeoutMs: PARSE_TIMEOUT_MS,
log,
sharedContext: extractionContext,
});
log(`Parse worker pool: ${poolSize} worker(s)`);
// Bulk index: every core will be needed — spawn the whole pool now so
Expand Down Expand Up @@ -2538,7 +2557,7 @@ export class ExtractionOrchestrator {
const parseFile = (filePath: string, content: string): Promise<ExtractionResult> => {
const language = detectLanguage(filePath, content, overrides, this.rootDir);
const names = this.frameworksForFile(filePath, frameworkNames);
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, names));
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, names, extractionContext));
return pool.requestParse({ filePath, content, language, frameworkNames: names });
};

Expand Down
Loading
Loading