fix(cli): make --no-stats actually omit volatile counts (#1477)

Closes #1477.

The `--no-stats` flag on `gitnexus analyze` was advertised as
"Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md"
but had no effect: every reindex still rewrote the markdown with
fresh count phrases, producing chore-commit churn on every run —
the exact problem the flag was added to solve in #704.

Root cause is commander.js negation-flag semantics. `.option(
'--no-stats', ...)` registers the option under the accessor
`stats` (boolean, default `true`; `false` when the flag is passed),
NOT `noStats`. The two action-handler reads in `analyze.ts`
(lines 414 and 500 pre-fix) read `options?.noStats`, which is
always `undefined`, so the `noStats` payload always reached
`runFullAnalysis` / `generateAIContextFiles` as `undefined`/falsy
and the count branch in the template always fired.

Fixed by replacing `options?.noStats` with `options?.stats === false`
at both reads. The strict `=== false` check (rather than
`!options?.stats`) means absent options or absent `.stats` field
fall through as no-stats=false, preserving the documented default-on
behaviour. Also updated the `AnalyzeOptions` interface to declare
`stats?: boolean` (matching commander's actual output) with a
JSDoc explaining the negation, since the prior `noStats?: boolean`
shape was a static-type misrepresentation of what commander
provides at runtime.

Internal call sites that re-pack `{ noStats: ... }` for
downstream consumers (`run-analyze.ts`, `ai-context.ts`) keep
their existing field name — those interfaces are not commander-
shaped, so `noStats` is the correct name there.

## Regression tests

Two new unit tests in `test/unit/ai-context.test.ts`:

* `omits volatile counts when noStats option is set (#1477)` —
  asserts the count parenthetical is absent from both CLAUDE.md
  and AGENTS.md when `noStats: true` is passed.
* `preserves volatile counts when noStats is not set (default)` —
  documents the default-on path so a future refactor can't
  silently flip the default.

Both call `generateAIContextFiles` directly with distinctive numbers
that would unmistakably leak through if the omit branch is broken.

## Manual verification

* `vitest run test/unit/ai-context.test.ts` → 13/13 pass
  (11 prior + 2 new).
* Verified before-fix behaviour by checking out main, running
  `npx gitnexus analyze --no-stats` against an indexed repo, and
  observing the count phrase still present. Re-running on the fix
  branch with the same flag strips the phrase as documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Reza Bahramrad 2026-05-10 13:54:16 +03:00
parent 5d670a530d
commit 13998b5407
2 changed files with 72 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;
/** 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 {

View file

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