Merge branch 'main' into fix/wiki-graph-data-token-budget

This commit is contained in:
Gergő Magyar 2026-05-14 08:30:04 +01:00 • committed by GitHub
commit 4758d17375
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 4 deletions

View file

@ -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 {

View file

@ -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,

View 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();
}
});
});