Skip to content

Commit 26c0190

Browse files
committed
feat(explore): markdown index (#361) with a section-first doc tier
Ports QingNagi/codegraph#361 (markdown extractor, heading nodes, name-matcher and resolution hooks) onto experimental and adds the section-first doc tier from feature/md-section-first: a doc-shaped query renders the best headed sections of the markdown file it names, ranked by idf-weighted line hits with path tokens weighted zero, capped at DOC_FILE_CAP per file. Markdown reaches an answer only through that tier, generated-file detection ignores markdown bodies, and the budget tiers count code files only so a README-heavy repo keeps its code answers.
1 parent 67e71bc commit 26c0190

14 files changed

Lines changed: 2133 additions & 33 deletions

‎__tests__/extraction.test.ts‎

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ describe('Language Detection', () => {
102102
expect(detectLanguage('stdio.h', '#ifndef STDIO_H\nvoid printf();\n#endif\n')).toBe('c');
103103
});
104104

105+
it('should detect Markdown files', () => {
106+
expect(detectLanguage('README.md')).toBe('markdown');
107+
expect(detectLanguage('docs/guide.markdown')).toBe('markdown');
108+
expect(detectLanguage('docs/page.mdx')).toBe('markdown');
109+
});
110+
105111
it('should detect Metal shader files as C++ (#1121)', () => {
106112
expect(detectLanguage('Shaders.metal')).toBe('cpp');
107113
expect(isSourceFile('Renderer/Shaders.metal')).toBe(true);
@@ -250,11 +256,218 @@ describe('Language Support', () => {
250256
expect(languages).toContain('swift');
251257
expect(languages).toContain('kotlin');
252258
expect(languages).toContain('dart');
259+
expect(languages).toContain('markdown');
253260
expect(languages).toContain('solidity');
254261
expect(languages).toContain('nix');
255262
});
256263
});
257264

265+
describe('Markdown Extraction', () => {
266+
it('should extract headings, links, and shell script references', () => {
267+
const markdown = `# Project Guide
268+
269+
See [Setup](docs/setup.md#install) and scripts/release.mjs.
270+
271+
## Release
272+
273+
\`\`\`bash
274+
npm run build
275+
node scripts/release.mjs
276+
\`\`\`
277+
`;
278+
279+
const result = extractFromSource('README.md', markdown);
280+
281+
const fileNode = result.nodes.find((n) => n.kind === 'file');
282+
expect(fileNode).toMatchObject({
283+
name: 'README.md',
284+
language: 'markdown',
285+
});
286+
287+
const headings = result.nodes.filter((n) => n.kind === 'module');
288+
expect(headings.map((n) => n.name)).toContain('Project Guide');
289+
expect(headings.map((n) => n.name)).toContain('Release');
290+
291+
const commandNode = result.nodes.find((n) => n.kind === 'function' && n.signature === 'node scripts/release.mjs');
292+
expect(commandNode).toBeDefined();
293+
294+
expect(result.unresolvedReferences).toEqual(
295+
expect.arrayContaining([
296+
expect.objectContaining({
297+
referenceName: 'docs/setup.md#install',
298+
referenceKind: 'imports',
299+
language: 'markdown',
300+
}),
301+
expect.objectContaining({
302+
referenceName: 'scripts/release.mjs',
303+
referenceKind: 'calls',
304+
language: 'markdown',
305+
}),
306+
])
307+
);
308+
});
309+
310+
it('should extract structured table rows and file-symbol references from Markdown', () => {
311+
const markdown = `# Maintenance Guide
312+
313+
## Phase 4
314+
315+
| Template | CLI Entry | Dispatcher | Implementation |
316+
| --- | --- | --- | --- |
317+
| P4-S1 | \`python "{script_path}" p4 "{csv_file}" s1 "{conditions_or_-}" "{probe_cols}"\` | \`scripts/csv_search.py::run_p4\` | \`scripts/csv_search.py::_p4_stage1\` |
318+
| P4-S2 | \`python "{script_path}" p4 "{csv_file}" s2 "{stage1_rows}" "{condition_or_-}" "{detail_cols}"\` | \`scripts/csv_search.py::run_p4\` | \`scripts/csv_search.py::_p4_stage2\` |
319+
320+
- P4-FLOW changes must inspect \`scripts/csv_search.py::run_p4\`.
321+
`;
322+
323+
const result = extractFromSource('phases/phase4.md', markdown);
324+
325+
const tableRows = result.nodes.filter((n) => n.kind === 'constant' && n.qualifiedName.includes('table-row'));
326+
expect(tableRows.map((n) => n.name)).toEqual(expect.arrayContaining(['P4-S1', 'P4-S2']));
327+
328+
const p4s1 = tableRows.find((n) => n.name === 'P4-S1');
329+
expect(p4s1?.signature).toContain('Template: P4-S1');
330+
expect(p4s1?.signature).toContain('Dispatcher: scripts/csv_search.py::run_p4');
331+
332+
const commandNode = result.nodes.find((n) =>
333+
n.kind === 'function' &&
334+
n.language === 'markdown' &&
335+
n.signature?.includes('python "{script_path}" p4')
336+
);
337+
expect(commandNode).toBeDefined();
338+
339+
expect(result.unresolvedReferences).toEqual(
340+
expect.arrayContaining([
341+
expect.objectContaining({
342+
referenceName: 'phases/scripts/csv_search.py::run_p4',
343+
referenceKind: 'references',
344+
language: 'markdown',
345+
}),
346+
expect.objectContaining({
347+
referenceName: 'phases/scripts/csv_search.py::_p4_stage1',
348+
referenceKind: 'references',
349+
language: 'markdown',
350+
}),
351+
])
352+
);
353+
});
354+
355+
it('should keep structured blocks after fences containing a different fence marker', () => {
356+
const markdown = `# Runbook
357+
358+
\`\`\`text
359+
~~~~
360+
\`\`\`
361+
362+
- POST-FENCE references \`src/auth.ts::login\`.
363+
364+
| Key | Target |
365+
| --- | --- |
366+
| POST-TABLE | \`src/auth.ts::login\` |
367+
`;
368+
369+
const result = extractFromSource('docs/runbook.md', markdown);
370+
const constants = result.nodes.filter((n) => n.kind === 'constant');
371+
372+
expect(constants).toEqual(expect.arrayContaining([
373+
expect.objectContaining({ docstring: 'POST-FENCE references src/auth.ts::login.' }),
374+
expect.objectContaining({ name: 'POST-TABLE' }),
375+
]));
376+
});
377+
378+
it('indexes Setext (underline) headings and skips frontmatter / code fences', () => {
379+
const markdown = `---
380+
title: Config Doc
381+
---
382+
383+
Architecture Overview
384+
=====================
385+
386+
Intro paragraph for the overview.
387+
388+
Routing Layer
389+
-------------
390+
391+
\`\`\`md
392+
Not A Heading
393+
=============
394+
\`\`\`
395+
`;
396+
397+
const result = extractFromSource('docs/arch.md', markdown);
398+
const headings = result.nodes.filter((n) => n.kind === 'module');
399+
const byName = new Map(headings.map((h) => [h.name, h]));
400+
401+
// Setext H1 (===) and H2 (---) become module nodes.
402+
expect(byName.get('Architecture Overview')?.signature).toBe('# Architecture Overview');
403+
expect(byName.get('Routing Layer')?.signature).toBe('## Routing Layer');
404+
// Frontmatter `title:` (above the closing `---`) is NOT a heading, and a
405+
// setext-looking line inside a code fence is ignored.
406+
expect(byName.has('title: Config Doc')).toBe(false);
407+
expect(byName.has('Not A Heading')).toBe(false);
408+
});
409+
410+
it('builds a deterministic, compact file digest (intro + key references)', () => {
411+
const markdown = `# Release Runbook
412+
413+
This runbook explains how to cut a release.
414+
415+
See [setup](docs/setup.md#install) and run \`scripts/release.mjs\`.
416+
It dispatches \`scripts/csv_search.py::run_p4\`.
417+
`;
418+
419+
const result = extractFromSource('RUNBOOK.md', markdown);
420+
const fileNode = result.nodes.find((n) => n.kind === 'file');
421+
422+
expect(fileNode?.docstring).toBeDefined();
423+
const digest = fileNode!.docstring!;
424+
// Intro is the first prose line, not the heading or a link blob.
425+
expect(digest).toContain('This runbook explains how to cut a release.');
426+
// Key referenced files/symbols are surfaced, compacted to basenames.
427+
expect(digest).toContain('refs:');
428+
expect(digest).toContain('setup.md#install');
429+
expect(digest).toContain('release.mjs');
430+
expect(digest).toContain('csv_search.py::run_p4');
431+
// Short enough to show in node details (the < 200 char detail gate).
432+
expect(digest.length).toBeLessThan(200);
433+
});
434+
});
435+
436+
describe('Code to Markdown Reference Extraction', () => {
437+
it('should extract Markdown path references from code string literals', () => {
438+
const code = `
439+
export const GUIDE = '../docs/guide.md';
440+
441+
export function loadDocs() {
442+
return fs.readFileSync('../docs/guide.md#install', 'utf8');
443+
}
444+
`;
445+
446+
const result = extractFromSource('src/load-docs.ts', code);
447+
const loadDocs = result.nodes.find((n) => n.kind === 'function' && n.name === 'loadDocs');
448+
const guideConstant = result.nodes.find((n) => n.kind === 'constant' && n.name === 'GUIDE');
449+
450+
expect(loadDocs).toBeDefined();
451+
expect(guideConstant).toBeDefined();
452+
expect(result.unresolvedReferences).toEqual(
453+
expect.arrayContaining([
454+
expect.objectContaining({
455+
fromNodeId: loadDocs!.id,
456+
referenceName: 'docs/guide.md#install',
457+
referenceKind: 'references',
458+
language: 'typescript',
459+
}),
460+
expect.objectContaining({
461+
fromNodeId: guideConstant!.id,
462+
referenceName: 'docs/guide.md',
463+
referenceKind: 'references',
464+
language: 'typescript',
465+
}),
466+
])
467+
);
468+
});
469+
});
470+
258471
describe('Nix Extraction', () => {
259472
it('should distinguish Nix variable and function bindings', () => {
260473
const code = `

‎__tests__/integration/full-pipeline.test.ts‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,97 @@ describe('Integration: full pipeline', () => {
8282
cleanupTempDir(tempDir);
8383
});
8484

85+
it('indexes Markdown headings and resolves Markdown links to script files', async () => {
86+
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
87+
fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true });
88+
fs.writeFileSync(
89+
path.join(tempDir, 'README.md'),
90+
`# Project Guide
91+
92+
See [Setup](docs/setup.md#install).
93+
94+
## Release
95+
96+
\`\`\`bash
97+
node scripts/release.mjs
98+
\`\`\`
99+
`
100+
);
101+
fs.writeFileSync(path.join(tempDir, 'docs', 'setup.md'), '# Install\n');
102+
fs.writeFileSync(path.join(tempDir, 'scripts', 'release.mjs'), 'export function release() { return true; }\n');
103+
104+
const cg = await CodeGraph.init(tempDir);
105+
try {
106+
await cg.indexAll();
107+
108+
const guide = cg.searchNodes('Project Guide').find((r) => r.node.language === 'markdown');
109+
expect(guide).toBeDefined();
110+
111+
const releaseCommand = cg
112+
.searchNodes('release.mjs')
113+
.find((r) => r.node.language === 'markdown' && r.node.kind === 'function');
114+
expect(releaseCommand).toBeDefined();
115+
116+
const guideEdges = cg.getOutgoingEdges(guide!.node.id).filter((e) => e.kind === 'imports');
117+
const guideTargets = guideEdges.map((e) => cg.getNode(e.target));
118+
const setupHeading = guideTargets.find((n) => n?.qualifiedName === 'docs/setup.md#install');
119+
expect(setupHeading).toMatchObject({
120+
kind: 'module',
121+
name: 'Install',
122+
filePath: 'docs/setup.md',
123+
startLine: 1,
124+
});
125+
126+
const commandEdges = cg.getOutgoingEdges(releaseCommand!.node.id).filter((e) => e.kind === 'calls');
127+
const commandTargets = commandEdges.map((e) => cg.getNode(e.target)?.filePath);
128+
expect(commandTargets).toContain('scripts/release.mjs');
129+
} finally {
130+
cg.destroy();
131+
}
132+
});
133+
134+
it('indexes Markdown template tables and resolves file-symbol references to implementation functions', async () => {
135+
fs.mkdirSync(path.join(tempDir, 'phases'), { recursive: true });
136+
fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true });
137+
fs.writeFileSync(
138+
path.join(tempDir, 'phases', 'phase4.md'),
139+
`# Phase 4
140+
141+
## Fixed Script Templates
142+
143+
| Template | CLI Entry | Dispatcher | Implementation |
144+
| --- | --- | --- | --- |
145+
| P4-S1 | \`python "{script_path}" p4 "{csv_file}" s1 "{conditions_or_-}" "{probe_cols}"\` | \`scripts/csv_search.py::run_p4\` | \`scripts/csv_search.py::_p4_stage1\` |
146+
| P4-S2 | \`python "{script_path}" p4 "{csv_file}" s2 "{stage1_rows}" "{condition_or_-}" "{detail_cols}"\` | \`scripts/csv_search.py::run_p4\` | \`scripts/csv_search.py::_p4_stage2\` |
147+
`
148+
);
149+
fs.writeFileSync(
150+
path.join(tempDir, 'scripts', 'csv_search.py'),
151+
`def _p4_stage1(filepath, condition_spec, probe_cols_spec):\n return 's1'\n\n` +
152+
`def _p4_stage2(filepath, stage1_rows, condition_spec, detail_cols_spec):\n return 's2'\n\n` +
153+
`def run_p4(filepath, args):\n return _p4_stage1(filepath, '-', 'MPN')\n`
154+
);
155+
156+
const cg = await CodeGraph.init(tempDir);
157+
try {
158+
await cg.indexAll();
159+
160+
const p4s1Row = cg.searchNodes('P4-S1').find((r) => r.node.language === 'markdown');
161+
expect(p4s1Row?.node.kind).toBe('constant');
162+
163+
const edges = cg.getOutgoingEdges(p4s1Row!.node.id).filter((e) => e.kind === 'references');
164+
const targets = edges.map((e) => cg.getNode(e.target));
165+
expect(targets).toEqual(
166+
expect.arrayContaining([
167+
expect.objectContaining({ name: 'run_p4', filePath: 'scripts/csv_search.py' }),
168+
expect.objectContaining({ name: '_p4_stage1', filePath: 'scripts/csv_search.py' }),
169+
])
170+
);
171+
} finally {
172+
cg.destroy();
173+
}
174+
});
175+
85176
it('runs init → index → resolve → search → callers → context → sync', async () => {
86177
const MODULE_COUNT = 120;
87178
generateSyntheticProject(tempDir, MODULE_COUNT);
@@ -269,4 +360,46 @@ describe('Integration: full pipeline', () => {
269360
cg.destroy();
270361
}
271362
}, 30_000);
363+
364+
it('resolves code string references to Markdown headings', async () => {
365+
fs.mkdirSync(path.join(tempDir, 'docs'), { recursive: true });
366+
fs.mkdirSync(path.join(tempDir, 'scripts'), { recursive: true });
367+
fs.writeFileSync(
368+
path.join(tempDir, 'docs', 'guide.md'),
369+
`# Guide
370+
371+
## Install
372+
373+
Run the setup command.
374+
`
375+
);
376+
fs.writeFileSync(
377+
path.join(tempDir, 'scripts', 'load_docs.py'),
378+
`GUIDE = "docs/guide.md"\n\n` +
379+
`def load_docs():\n` +
380+
` return open("docs/guide.md#install", encoding="utf-8").read()\n`
381+
);
382+
383+
const cg = await CodeGraph.init(tempDir);
384+
try {
385+
await cg.indexAll();
386+
387+
const loadDocs = cg.searchNodes('load_docs').find((r) => r.node.language === 'python');
388+
expect(loadDocs).toBeDefined();
389+
390+
const edges = cg.getOutgoingEdges(loadDocs!.node.id).filter((e) => e.kind === 'references');
391+
const targets = edges.map((e) => cg.getNode(e.target));
392+
expect(targets).toEqual(
393+
expect.arrayContaining([
394+
expect.objectContaining({
395+
kind: 'module',
396+
name: 'Install',
397+
qualifiedName: 'docs/guide.md#install',
398+
}),
399+
])
400+
);
401+
} finally {
402+
cg.destroy();
403+
}
404+
});
272405
});

0 commit comments

Comments
 (0)