mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-26 01:11:16 +00:00
Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/npm_and_yarn-a5d3e3ad53
This commit is contained in:
commit
6baaa779a2
6 changed files with 735 additions and 10 deletions
|
|
@ -199,7 +199,9 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||
async function upsertGitNexusSection(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<'created' | 'updated' | 'appended'> {
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
): Promise<'created' | 'updated' | 'appended' | 'preserved'> {
|
||||
const exists = await fileExists(filePath);
|
||||
|
||||
if (!exists) {
|
||||
|
|
@ -223,7 +225,50 @@ async function upsertGitNexusSection(
|
|||
);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
// Replace existing section
|
||||
const existingSection = existingContent.substring(
|
||||
startIdx,
|
||||
endIdx + GITNEXUS_END_MARKER.length,
|
||||
);
|
||||
|
||||
// If the existing section contains <!-- gitnexus:keep -->, preserve the user's
|
||||
// custom layout and only update the stats line (node/edge/flow counts).
|
||||
// This lets teams trim the verbose default template to a lean format without
|
||||
// having it overwritten on every `gitnexus analyze`.
|
||||
//
|
||||
// Note: the keep-marker check operates on `existingSection` (the substring
|
||||
// between valid section markers identified by findSectionMarkerIndex), so
|
||||
// a keep marker in user prose OUTSIDE the GitNexus block has no effect.
|
||||
if (existingSection.includes('<!-- gitnexus:keep -->')) {
|
||||
// Build the new stats line from the caller-provided values directly.
|
||||
// We do NOT re-extract from `content` because:
|
||||
// (a) first-bold extraction is fragile if the template evolves
|
||||
// (b) the parenthesized-text fallback can match unrelated tuples
|
||||
// like `({target: "symbolName", direction: "upstream"})`
|
||||
// when noStats is set
|
||||
// Passing projectName + stats explicitly makes the contract obvious.
|
||||
// noStats controls template generation, not keep-section stat updates — the user opted into a stats line by keeping it.
|
||||
const newStatsInner = `${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows`;
|
||||
const statsLine = `Indexed as **${projectName}** (${newStatsInner})`;
|
||||
|
||||
// Match either canonical phrasing at line start (`^` with `m` flag) so we
|
||||
// cannot replace prose embedded mid-paragraph. Deliberately no `$`: text
|
||||
// after the closing `)` on the same line (e.g. ". MCP tools.") stays intact.
|
||||
const statsPattern = /^(?:Indexed as|indexed by GitNexus as) \*\*[^*]+\*\* \([^)]+\)/m;
|
||||
|
||||
if (statsPattern.test(existingSection)) {
|
||||
const updatedSection = existingSection.replace(statsPattern, statsLine);
|
||||
const before = existingContent.substring(0, startIdx);
|
||||
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
|
||||
await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8');
|
||||
return 'updated';
|
||||
}
|
||||
// Keep marker present but no stats line matched. Section is preserved
|
||||
// unchanged on disk; return a distinct status so callers/CLI output
|
||||
// don't mis-report this as 'updated' (which would imply a write).
|
||||
return 'preserved';
|
||||
}
|
||||
|
||||
// No keep marker — replace existing section with full verbose content
|
||||
const before = existingContent.substring(0, startIdx);
|
||||
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
|
||||
const newContent = before + content + after;
|
||||
|
|
@ -344,12 +389,12 @@ export async function generateAIContextFiles(
|
|||
if (!options?.skipAgentsMd) {
|
||||
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
||||
const agentsPath = path.join(repoPath, 'AGENTS.md');
|
||||
const agentsResult = await upsertGitNexusSection(agentsPath, content);
|
||||
const agentsResult = await upsertGitNexusSection(agentsPath, content, projectName, stats);
|
||||
createdFiles.push(`AGENTS.md (${agentsResult})`);
|
||||
|
||||
// Create CLAUDE.md (for Claude Code)
|
||||
const claudePath = path.join(repoPath, 'CLAUDE.md');
|
||||
const claudeResult = await upsertGitNexusSection(claudePath, content);
|
||||
const claudeResult = await upsertGitNexusSection(claudePath, content, projectName, stats);
|
||||
createdFiles.push(`CLAUDE.md (${claudeResult})`);
|
||||
} else {
|
||||
createdFiles.push('AGENTS.md (skipped via --skip-agents-md)');
|
||||
|
|
|
|||
|
|
@ -117,8 +117,18 @@ export interface AnalyzeOptions {
|
|||
verbose?: boolean;
|
||||
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
|
||||
skipAgentsMd?: boolean;
|
||||
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
|
||||
noStats?: boolean;
|
||||
/**
|
||||
* Stats inclusion in AGENTS.md and CLAUDE.md.
|
||||
*
|
||||
* Commander.js represents `--no-stats` as `stats: boolean` (default
|
||||
* `true`; `false` when the user passes `--no-stats`), NOT as
|
||||
* `noStats: boolean`. Reading the negated form would always be
|
||||
* `undefined` and the flag would silently no-op (#1477). Consumers
|
||||
* that want "did the user request --no-stats?" should compare with
|
||||
* `=== false` to distinguish the explicit-off case from the
|
||||
* default-on case.
|
||||
*/
|
||||
stats?: boolean;
|
||||
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
|
||||
skipSkills?: boolean;
|
||||
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
||||
|
|
@ -449,7 +459,12 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
skipGit: options?.skipGit,
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
noStats: options?.noStats,
|
||||
// commander.js `.option('--no-stats', …)` registers the flag as
|
||||
// `options.stats` (boolean, default true; `false` when the user
|
||||
// passed --no-stats). Reading `options?.noStats` here returns
|
||||
// undefined every time, so the flag was a no-op on the markdown
|
||||
// rewrite path before this fix. See #1477.
|
||||
noStats: options?.stats === false,
|
||||
registryName: options?.name,
|
||||
// Registry-collision bypass — its own CLI flag, intentionally NOT
|
||||
// overloading --force. A user who hits the collision guard should
|
||||
|
|
@ -537,7 +552,13 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
|||
processes: s.processes,
|
||||
},
|
||||
skillResult.skills,
|
||||
{ skipAgentsMd, skipSkills, noStats: options?.noStats },
|
||||
{
|
||||
skipAgentsMd,
|
||||
skipSkills,
|
||||
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
|
||||
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
|
||||
noStats: options?.stats === false,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,12 @@ export const processMarkdown = (
|
|||
// Skip if file node doesn't exist (shouldn't happen, structure-processor creates it)
|
||||
if (!graph.getNode(fileNodeId)) continue;
|
||||
|
||||
const lines = file.content.split('\n');
|
||||
// Normalize CRLF/CR to LF before splitting so that line-end agnostic
|
||||
// markdown files (Windows-authored, mixed) yield correct headings.
|
||||
// Without this, splitting on `\n` alone leaves `## Heading\r` on each line;
|
||||
// `$` in HEADING_RE only matches at end-of-string, while `.+` stops before
|
||||
// the trailing `\r`, so the line never matches as a heading.
|
||||
const lines = file.content.split(/\r\n|\r|\n/);
|
||||
|
||||
// --- Extract headings and build hierarchy ---
|
||||
// First pass: collect all heading positions so we can compute endLine spans
|
||||
|
|
|
|||
158
gitnexus/test/integration/markdown-processor-crlf.test.ts
Normal file
158
gitnexus/test/integration/markdown-processor-crlf.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Regression test for CRLF-encoded markdown heading extraction.
|
||||
*
|
||||
* Files with CRLF line endings (Windows-authored markdown) previously
|
||||
* produced zero Section nodes because `split('\n')` left a trailing `\r`
|
||||
* on each line, and the heading regex `/^(#{1,6})\s+(.+)$/` (anchored
|
||||
* with `$`) failed to match `## Heading\r` because `$` only matches at
|
||||
* end-of-string while `.+` does not consume the trailing `\r`.
|
||||
*
|
||||
* Fix: split on `/\r\n|\r|\n/` so all line-ending conventions are
|
||||
* normalized at split time. See markdown-processor.ts line 39.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { processMarkdown } from '../../src/core/ingestion/markdown-processor.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
||||
|
||||
function getMarkdownSections(graph: KnowledgeGraph, filePath: string): GraphNode[] {
|
||||
return [...graph.iterNodes()]
|
||||
.filter((n) => n.label === 'Section' && n.properties.filePath === filePath)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
((a.properties.startLine as number | undefined) ?? 0) -
|
||||
((b.properties.startLine as number | undefined) ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
function expectContainsEdge(graph: KnowledgeGraph, sourceId: string, targetId: string) {
|
||||
const found = [...graph.iterRelationshipsByType('CONTAINS')].some(
|
||||
(r) => r.sourceId === sourceId && r.targetId === targetId,
|
||||
);
|
||||
expect(found).toBe(true);
|
||||
}
|
||||
|
||||
function setupGraphWithFile(filePath: string) {
|
||||
const graph = createKnowledgeGraph();
|
||||
const fileNode: GraphNode = {
|
||||
id: generateId('File', filePath),
|
||||
label: 'File',
|
||||
properties: { name: filePath, filePath },
|
||||
};
|
||||
graph.addNode(fileNode);
|
||||
return graph;
|
||||
}
|
||||
|
||||
describe('markdown-processor CRLF tolerance', () => {
|
||||
it('extracts headings from LF-encoded markdown (baseline)', () => {
|
||||
const filePath = 'lf.md';
|
||||
const graph = setupGraphWithFile(filePath);
|
||||
const content = '# Title\nbody line 1\n## Sub\nbody line 2\n### SubSub\nmore\n';
|
||||
|
||||
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
||||
|
||||
expect(stats.sections).toBe(3);
|
||||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['Title', 'Sub', 'SubSub']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2, 3]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3, 5]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([7, 7, 7]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
const fileId = generateId('File', filePath);
|
||||
expectContainsEdge(graph, fileId, sections[0]!.id);
|
||||
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
||||
expectContainsEdge(graph, sections[1]!.id, sections[2]!.id);
|
||||
});
|
||||
|
||||
it('extracts headings from CRLF-encoded markdown (the regression)', () => {
|
||||
const filePath = 'crlf.md';
|
||||
const graph = setupGraphWithFile(filePath);
|
||||
const content = '# Title\r\nbody line 1\r\n## Sub\r\nbody line 2\r\n### SubSub\r\nmore\r\n';
|
||||
|
||||
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
||||
|
||||
// Pre-fix: this returned 0 because `## Sub\r` failed the heading regex.
|
||||
expect(stats.sections).toBe(3);
|
||||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['Title', 'Sub', 'SubSub']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2, 3]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3, 5]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([7, 7, 7]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
const fileId = generateId('File', filePath);
|
||||
expectContainsEdge(graph, fileId, sections[0]!.id);
|
||||
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
||||
expectContainsEdge(graph, sections[1]!.id, sections[2]!.id);
|
||||
});
|
||||
|
||||
it('extracts headings from CR-only-encoded markdown (old Mac OS Classic)', () => {
|
||||
const filePath = 'cr.md';
|
||||
const graph = setupGraphWithFile(filePath);
|
||||
const content = '# Title\rbody line 1\r## Sub\rbody line 2\r';
|
||||
|
||||
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
||||
|
||||
expect(stats.sections).toBe(2);
|
||||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['Title', 'Sub']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([5, 5]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
const fileId = generateId('File', filePath);
|
||||
expectContainsEdge(graph, fileId, sections[0]!.id);
|
||||
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
||||
});
|
||||
|
||||
it('extracts headings from mixed CRLF + LF markdown', () => {
|
||||
const filePath = 'mixed.md';
|
||||
const graph = setupGraphWithFile(filePath);
|
||||
const content = '# LF Title\nbody\r\n## CRLF Sub\r\nmore\n### Trailing LF\nend\n';
|
||||
|
||||
const stats = processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
||||
|
||||
expect(stats.sections).toBe(3);
|
||||
const sections = getMarkdownSections(graph, filePath);
|
||||
expect(sections.map((s) => s.properties.name)).toEqual(['LF Title', 'CRLF Sub', 'Trailing LF']);
|
||||
expect(sections.map((s) => s.properties.level)).toEqual([1, 2, 3]);
|
||||
expect(sections.map((s) => s.properties.startLine)).toEqual([1, 3, 5]);
|
||||
expect(sections.map((s) => s.properties.endLine)).toEqual([7, 7, 7]);
|
||||
for (const s of sections) {
|
||||
expect(String(s.properties.name)).not.toMatch(/\r/);
|
||||
}
|
||||
const fileId = generateId('File', filePath);
|
||||
expectContainsEdge(graph, fileId, sections[0]!.id);
|
||||
expectContainsEdge(graph, sections[0]!.id, sections[1]!.id);
|
||||
expectContainsEdge(graph, sections[1]!.id, sections[2]!.id);
|
||||
});
|
||||
|
||||
it('reports correct startLine and endLine for CRLF content', () => {
|
||||
const filePath = 'crlf-lines.md';
|
||||
const graph = setupGraphWithFile(filePath);
|
||||
// Lines 1, 3, 5 are headings (1-indexed)
|
||||
const content = '# T\r\nbody\r\n## Sub\r\nmore\r\n### SubSub\r\ntail\r\n';
|
||||
|
||||
processMarkdown(graph, [{ path: filePath, content }], new Set([filePath]));
|
||||
|
||||
const sections = getMarkdownSections(graph, filePath);
|
||||
const titleSection = sections.find((s) => s.properties.name === 'T');
|
||||
const subSection = sections.find((s) => s.properties.name === 'Sub');
|
||||
const subSubSection = sections.find((s) => s.properties.name === 'SubSub');
|
||||
|
||||
expect(titleSection?.properties.startLine).toBe(1);
|
||||
expect(titleSection?.properties.endLine).toBe(7);
|
||||
expect(subSection?.properties.startLine).toBe(3);
|
||||
expect(subSection?.properties.endLine).toBe(7);
|
||||
expect(subSubSection?.properties.startLine).toBe(5);
|
||||
expect(subSubSection?.properties.endLine).toBe(7);
|
||||
});
|
||||
});
|
||||
|
|
@ -45,6 +45,54 @@ describe('generateAIContextFiles', () => {
|
|||
expect(content).toContain('TestProject');
|
||||
});
|
||||
|
||||
it('omits volatile counts when noStats option is set (#1477)', async () => {
|
||||
// Distinct subdir per case so we can assert on a clean slate.
|
||||
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-no-stats-test-'));
|
||||
const subStorage = path.join(subDir, '.gitnexus');
|
||||
await fs.mkdir(subStorage, { recursive: true });
|
||||
try {
|
||||
// Stats values picked to be unmistakable if they leak through.
|
||||
const stats = { nodes: 12345, edges: 67890, processes: 99 };
|
||||
await generateAIContextFiles(subDir, subStorage, 'NoStatsProject', stats, undefined, {
|
||||
noStats: true,
|
||||
});
|
||||
|
||||
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
|
||||
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
|
||||
expect(content).toContain('NoStatsProject');
|
||||
// The "(N symbols, N relationships, N execution flows)"
|
||||
// phrase MUST NOT appear when noStats=true.
|
||||
expect(content).not.toMatch(
|
||||
/\(\d+\s+symbols,\s+\d+\s+relationships,\s+\d+\s+execution flows\)/,
|
||||
);
|
||||
// And the distinctive numbers must not leak via any other path.
|
||||
expect(content).not.toContain('12345');
|
||||
expect(content).not.toContain('67890');
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(subDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves volatile counts when noStats is not set (default)', async () => {
|
||||
const subDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-with-stats-test-'));
|
||||
const subStorage = path.join(subDir, '.gitnexus');
|
||||
await fs.mkdir(subStorage, { recursive: true });
|
||||
try {
|
||||
const stats = { nodes: 12345, edges: 67890, processes: 99 };
|
||||
await generateAIContextFiles(subDir, subStorage, 'WithStatsProject', stats);
|
||||
for (const f of ['CLAUDE.md', 'AGENTS.md']) {
|
||||
const content = await fs.readFile(path.join(subDir, f), 'utf-8');
|
||||
expect(content).toContain('WithStatsProject');
|
||||
expect(content).toMatch(
|
||||
/\(12345\s+symbols,\s+67890\s+relationships,\s+99\s+execution flows\)/,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(subDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the load-bearing repo-specific sections in the CLAUDE.md block (#856)', async () => {
|
||||
// The trimmed block must still contain everything that is genuinely
|
||||
// unique per repo or load-bearing for the agent: the freshness warning,
|
||||
|
|
@ -123,9 +171,81 @@ describe('generateAIContextFiles', () => {
|
|||
expect(starts).toBe(1);
|
||||
});
|
||||
|
||||
it('preserves custom section when gitnexus:keep is present', async () => {
|
||||
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
|
||||
|
||||
// Write a custom lean section with keep marker
|
||||
const customContent = `# My Project
|
||||
|
||||
Some project docs here.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
# GitNexus — Code Knowledge Graph
|
||||
|
||||
Indexed as **TestProject** (50 symbols, 100 relationships, 5 execution flows). MCP tools.
|
||||
|
||||
| Tool | Use for |
|
||||
|------|---------|
|
||||
| query | Find flows |
|
||||
|
||||
Resources: gitnexus://repo/TestProject/context
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudeMdPath, customContent, 'utf-8');
|
||||
|
||||
// Run analyze with new stats — should only update the stats line
|
||||
const stats = { nodes: 999, edges: 1234, processes: 42 };
|
||||
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
|
||||
const result = await fs.readFile(claudeMdPath, 'utf-8');
|
||||
|
||||
// Stats should be updated
|
||||
expect(result).toContain('999 symbols');
|
||||
expect(result).toContain('1234 relationships');
|
||||
expect(result).toContain('42 execution flows');
|
||||
expect(result).toContain('. MCP tools.');
|
||||
|
||||
// Custom layout should be preserved (not replaced with verbose template)
|
||||
expect(result).toContain('<!-- gitnexus:keep -->');
|
||||
expect(result).toContain('Code Knowledge Graph');
|
||||
expect(result).toContain('| query | Find flows |');
|
||||
|
||||
// Verbose template sections should NOT be present
|
||||
expect(result).not.toContain('## Always Do');
|
||||
expect(result).not.toContain('## Never Do');
|
||||
expect(result).not.toContain('## When Debugging');
|
||||
|
||||
// Non-GitNexus content should be preserved
|
||||
expect(result).toContain('# My Project');
|
||||
expect(result).toContain('Some project docs here.');
|
||||
});
|
||||
|
||||
it('replaces section when no keep marker is present', async () => {
|
||||
const agentsPath = path.join(tmpDir, 'AGENTS.md');
|
||||
|
||||
// Write a section WITHOUT keep marker
|
||||
const content = `<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
Old content here.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(agentsPath, content, 'utf-8');
|
||||
|
||||
const stats = { nodes: 100, edges: 200, processes: 10 };
|
||||
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
|
||||
const result = await fs.readFile(agentsPath, 'utf-8');
|
||||
|
||||
// Should have the full verbose template
|
||||
expect(result).toContain('## Always Do');
|
||||
expect(result).not.toContain('Old content here');
|
||||
});
|
||||
|
||||
it('installs skills files', async () => {
|
||||
const stats = { nodes: 10 };
|
||||
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
|
||||
|
||||
// Should have installed skill files
|
||||
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
|
||||
|
|
@ -371,4 +491,240 @@ describe('generateAIContextFiles', () => {
|
|||
await fs.rm(crlfDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Keep-marker edge cases (added to address PR #1508 review findings)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it('keep marker OUTSIDE the GitNexus section has no effect (#1508 review F5)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-scope-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
// Keep marker appears in user prose BEFORE the GitNexus section.
|
||||
// The keep-path must NOT be triggered — full template replacement
|
||||
// is the correct behavior here, because the marker is not inside
|
||||
// the generated block.
|
||||
const fileWithOutOfBandMarker = `# My Project
|
||||
|
||||
A note about <!-- gitnexus:keep --> markers: they only apply inside the
|
||||
GitNexus block below, not in prose like this.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
Old verbose stub here.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, fileWithOutOfBandMarker, 'utf-8');
|
||||
|
||||
const stats = { nodes: 50, edges: 100, processes: 5 };
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'TestProject', stats);
|
||||
|
||||
const result = await fs.readFile(claudePath, 'utf-8');
|
||||
// Section MUST have been fully replaced — keep marker outside section ignored
|
||||
expect(result).toContain('## Always Do');
|
||||
expect(result).not.toContain('Old verbose stub here.');
|
||||
// User's prose with the marker reference is preserved untouched
|
||||
expect(result).toContain('A note about <!-- gitnexus:keep --> markers');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('AGENTS.md keep path preserves custom layout (#1508 review F5)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-agents-'));
|
||||
try {
|
||||
const agentsPath = path.join(dir, 'AGENTS.md');
|
||||
const customAgents = `# AGENTS instructions
|
||||
|
||||
Project-specific agent guidance.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
# GitNexus context for AGENTS
|
||||
|
||||
Indexed as **AgentsTest** (10 symbols, 20 relationships, 1 execution flows).
|
||||
|
||||
Use 'query' for finding flows, 'context' for symbol details.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(agentsPath, customAgents, 'utf-8');
|
||||
|
||||
const stats = { nodes: 777, edges: 888, processes: 9 };
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'AgentsTest', stats);
|
||||
|
||||
const result = await fs.readFile(agentsPath, 'utf-8');
|
||||
// Stats updated
|
||||
expect(result).toContain('777 symbols');
|
||||
expect(result).toContain('888 relationships');
|
||||
expect(result).toContain('9 execution flows');
|
||||
// Custom layout preserved
|
||||
expect(result).toContain('# GitNexus context for AGENTS');
|
||||
expect(result).toContain("Use 'query' for finding flows");
|
||||
// Verbose template NOT injected
|
||||
expect(result).not.toContain('## Always Do');
|
||||
// Non-GitNexus content preserved
|
||||
expect(result).toContain('# AGENTS instructions');
|
||||
expect(result).toContain('Project-specific agent guidance.');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('idempotent: second run with keep marker produces byte-identical output (#1508 review F5)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-idem-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
const seed = `# Project
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
|
||||
const stats = { nodes: 99, edges: 100, processes: 7 };
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'Idem', stats);
|
||||
const afterFirst = await fs.readFile(claudePath, 'utf-8');
|
||||
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'Idem', stats);
|
||||
const afterSecond = await fs.readFile(claudePath, 'utf-8');
|
||||
|
||||
expect(afterSecond).toBe(afterFirst);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('CRLF file with keep marker: stats line updates without corrupting content (#1508 review F5)', async () => {
|
||||
// upsertGitNexusSection writes with .trim() + '\n', so the saved file uses LF
|
||||
// line endings throughout — CRLF in the seed input is not preserved.
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-crlf-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
const crlfContent =
|
||||
'# Project\r\n' +
|
||||
'\r\n' +
|
||||
'<!-- gitnexus:start -->\r\n' +
|
||||
'<!-- gitnexus:keep -->\r\n' +
|
||||
'Indexed as **CRLFTest** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' +
|
||||
'<!-- gitnexus:end -->\r\n';
|
||||
await fs.writeFile(claudePath, crlfContent, 'utf-8');
|
||||
|
||||
const stats = { nodes: 50, edges: 60, processes: 7 };
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), 'CRLFTest', stats);
|
||||
|
||||
const result = await fs.readFile(claudePath, 'utf-8');
|
||||
// Stats updated correctly
|
||||
expect(result).toContain('50 symbols');
|
||||
expect(result).toContain('60 relationships');
|
||||
// Custom prose preserved
|
||||
expect(result).toContain('Custom CRLF');
|
||||
// No verbose template injected
|
||||
expect(result).not.toContain('## Always Do');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('noStats + keep marker: stats line update is NOT corrupted by Always-Do tuple text (#1508 review F3)', async () => {
|
||||
// Regression guard: with the old fallback regex `\(([^)]+)\)`, when
|
||||
// noStats=true suppressed the canonical stats line from generated
|
||||
// content, the fallback matched the FIRST parenthesized text in the
|
||||
// template, which was `({target: "symbolName", direction: "upstream"})`
|
||||
// from the Always Do bullet — silently writing that as the stats line.
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-nostats-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
const seed = `<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
Indexed as **NoStatsTest** (1 symbols, 1 relationships, 1 execution flows). Custom.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
|
||||
const stats = { nodes: 42, edges: 84, processes: 3 };
|
||||
await generateAIContextFiles(
|
||||
dir,
|
||||
path.join(dir, '.gitnexus'),
|
||||
'NoStatsTest',
|
||||
stats,
|
||||
undefined,
|
||||
{ noStats: true },
|
||||
);
|
||||
|
||||
const result = await fs.readFile(claudePath, 'utf-8');
|
||||
// Stats line MUST NOT have been corrupted with the Always-Do tuple text
|
||||
expect(result).not.toMatch(/\(\{target:/);
|
||||
expect(result).not.toMatch(/direction:\s*"upstream"/);
|
||||
// Stats line should reflect a sensible numeric update (passed stats)
|
||||
expect(result).toContain('42 symbols');
|
||||
// Custom prose still preserved
|
||||
expect(result).toContain('Custom.');
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 'preserved' (not 'updated') when keep marker is present but no stats line matches (#1508 review F1)", async () => {
|
||||
// Regression guard for the misleading-return-value bug: previously the
|
||||
// function returned 'updated' without writing when the keep-section had
|
||||
// no recognizable stats line, causing CLI output to claim files were
|
||||
// updated when they were not.
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-noline-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
// Custom keep-section with NO "Indexed as ..." or "indexed by GitNexus as ..." line
|
||||
const seed = `# Project
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
# GitNexus block (custom, no stats line)
|
||||
|
||||
This block intentionally omits the standard stats line.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
|
||||
const stats = { nodes: 100, edges: 200, processes: 10 };
|
||||
const result = await generateAIContextFiles(
|
||||
dir,
|
||||
path.join(dir, '.gitnexus'),
|
||||
'NoLineTest',
|
||||
stats,
|
||||
);
|
||||
|
||||
// The result manifest should reflect 'preserved', not 'updated'
|
||||
expect(result.files).toContain('CLAUDE.md (preserved)');
|
||||
// File on disk is unchanged
|
||||
const onDisk = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(onDisk).toBe(seed);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('project name with markdown-sensitive punctuation lands intact in stats line (#1508 review F5)', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-keep-punct-'));
|
||||
try {
|
||||
const claudePath = path.join(dir, 'CLAUDE.md');
|
||||
const seed = `<!-- gitnexus:start -->
|
||||
<!-- gitnexus:keep -->
|
||||
Indexed as **placeholder** (1 symbols, 1 relationships, 1 execution flows). Custom.
|
||||
<!-- gitnexus:end -->
|
||||
`;
|
||||
await fs.writeFile(claudePath, seed, 'utf-8');
|
||||
|
||||
// Name with hyphens, dot, and slash — exactly what dp-web4/some-repo
|
||||
// style names look like
|
||||
const trickyName = 'dp-web4/some-repo.v2';
|
||||
const stats = { nodes: 5, edges: 10, processes: 1 };
|
||||
await generateAIContextFiles(dir, path.join(dir, '.gitnexus'), trickyName, stats);
|
||||
|
||||
const result = await fs.readFile(claudePath, 'utf-8');
|
||||
// The full name appears in the bold of the stats line, intact
|
||||
expect(result).toContain(`Indexed as **${trickyName}** (5 symbols`);
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
140
gitnexus/test/unit/analyze-no-stats-bridge.test.ts
Normal file
140
gitnexus/test/unit/analyze-no-stats-bridge.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock } = vi.hoisted(
|
||||
() => {
|
||||
const runFullAnalysisMock = vi.fn();
|
||||
const generateAIContextFilesMock = vi.fn(async () => ({ files: [] as string[] }));
|
||||
const generateSkillFilesMock = vi.fn(async () => ({
|
||||
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
||||
outputPath: '/repo/.claude/skills/generated',
|
||||
}));
|
||||
return { runFullAnalysisMock, generateAIContextFilesMock, generateSkillFilesMock };
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock('../../src/core/run-analyze.js', () => ({
|
||||
runFullAnalysis: runFullAnalysisMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/cli/ai-context.js', () => ({
|
||||
generateAIContextFiles: generateAIContextFilesMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/cli/skill-gen.js', () => ({
|
||||
generateSkillFiles: generateSkillFilesMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
|
||||
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
|
||||
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
|
||||
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
|
||||
assertAnalysisFinalized: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(() => '/repo'),
|
||||
hasGitDir: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
|
||||
getMaxFileSizeBannerMessage: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFullAnalysisMock.mockReset();
|
||||
runFullAnalysisMock.mockResolvedValue({
|
||||
repoName: 'repo',
|
||||
repoPath: '/repo',
|
||||
stats: {},
|
||||
alreadyUpToDate: true,
|
||||
});
|
||||
generateAIContextFilesMock.mockReset();
|
||||
generateAIContextFilesMock.mockResolvedValue({ files: [] });
|
||||
generateSkillFilesMock.mockReset();
|
||||
generateSkillFilesMock.mockResolvedValue({
|
||||
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
||||
outputPath: '/repo/.claude/skills/generated',
|
||||
});
|
||||
process.exitCode = undefined;
|
||||
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
||||
});
|
||||
|
||||
it('maps commander-shaped stats:false to noStats:true (equivalent to --no-stats)', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { stats: false });
|
||||
|
||||
expect(runFullAnalysisMock).toHaveBeenCalledTimes(1);
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.noStats).toBe(true);
|
||||
});
|
||||
|
||||
it('maps omitted stats to noStats:false (default-on preserved)', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.noStats).toBe(false);
|
||||
});
|
||||
|
||||
it('maps explicit stats:true to noStats:false', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { stats: true });
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.noStats).toBe(false);
|
||||
});
|
||||
|
||||
it('still maps stats:false to noStats:true when skipAgentsMd is set', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { stats: false, skipAgentsMd: true });
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.noStats).toBe(true);
|
||||
expect(opts.skipAgentsMd).toBe(true);
|
||||
});
|
||||
|
||||
it('passes stats:false as noStats to generateAIContextFiles on the --skills regeneration path (#1477)', async () => {
|
||||
runFullAnalysisMock.mockResolvedValueOnce({
|
||||
repoName: 'repo',
|
||||
repoPath: '/repo',
|
||||
stats: {
|
||||
files: 1,
|
||||
nodes: 10,
|
||||
edges: 20,
|
||||
communities: 0,
|
||||
processes: 5,
|
||||
},
|
||||
alreadyUpToDate: false,
|
||||
pipelineResult: { communityResult: undefined },
|
||||
});
|
||||
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
try {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { skills: true, stats: false });
|
||||
|
||||
expect(generateSkillFilesMock).toHaveBeenCalledTimes(1);
|
||||
expect(generateAIContextFilesMock).toHaveBeenCalledTimes(1);
|
||||
const aiCtxOpts = generateAIContextFilesMock.mock.calls[0]![5];
|
||||
expect(aiCtxOpts).toEqual({
|
||||
skipAgentsMd: undefined,
|
||||
skipSkills: undefined,
|
||||
noStats: true,
|
||||
});
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue