diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 47745c575..f4501838f 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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; /** Index the folder even when no .git directory is present. */ skipGit?: boolean; /** @@ -411,7 +421,12 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption dropEmbeddings: options?.dropEmbeddings, skipGit: options?.skipGit, skipAgentsMd: options?.skipAgentsMd, - 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 @@ -497,7 +512,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption processes: s.processes, }, skillResult.skills, - { skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats }, + // See note above (#1477): commander stores --no-stats as + // `options.stats === false`, not as `options.noStats`. + { skipAgentsMd: options?.skipAgentsMd, noStats: options?.stats === false }, ); } } catch { diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index bbaecb932..cd9fb0a7c 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -45,6 +45,57 @@ 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); + const content = await fs.readFile(path.join(subDir, 'CLAUDE.md'), '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,