Skip to content

Commit 2ae148e

Browse files
Brian Westphalclaude
authored andcommitted
test: close coverage gaps to 100% lines / ~97% branches (GG-44)
The previously-uncovered lines were untested, not unreachable. Add the missing tests and mark the genuinely-untestable spots explicitly: - cliArgs: --endpoint / --language parsing - prompt: cleanModelOutput('') early return - providers/index: export + unit-test unavailableMessage (all 3 branches), resolveProvider local branch + language-hint apple rebuild - providers/cli: available probe, spawn error, empty-output (hint/no-hint), bare exit code - providers/local: abort/timeout, non-OK status, empty response, /models non-OK, env-endpoint resolution, malformed-response guards - releaseNotes: default stderr warn sink, default cwd, non-Error formatting, singular/plural noun - template: non-ENOENT read error re-throw - git (integration): empty untracked file, all-empty categories, default cwd - apple: detectSystemLanguage catch (simulated small-ICU Intl throw) v8-ignore (with reasons) the genuine live-I/O sinks — anthropicApi defaultRun, local defaultFetch — and the defensive git untrackedDiff fallbacks that real git never reaches. Raise the vitest floors (80/75/72/80 → 98/95/97/99) to lock the gains, and sync the analyze-code-quality skill's quoted thresholds. Tests 159 → 188. Coverage: lines 94.18 → 100, statements 92.89 → 99.63, functions 89.47 → 98.91, branches 87.61 → 97.11. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 04df013 commit 2ae148e

13 files changed

Lines changed: 343 additions & 13 deletions

File tree

.claude/skills/analyze-code-quality/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ report.
1414
npm test
1515
```
1616
Report total tests, pass/fail, and coverage per file. The thresholds in
17-
`vitest.config.ts` are the floor — currently **statements 80 / branches 75 /
18-
functions 72 / lines 80**, with `src/cli.ts` excluded as the thin bin. Flag
17+
`vitest.config.ts` are the floor — currently **statements 98 / branches 95 /
18+
functions 97 / lines 99**, with `src/cli.ts` excluded as the thin bin. Flag
1919
any file that drags the suite toward those floors. The real-I/O provider paths
2020
(`anthropicApi.ts`, `apple.ts`, `local.ts`) are expected to show lower line
2121
coverage because their network/subprocess calls aren't exercised live; what

src/git.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ async function untrackedDiff(path: string, cwd: string): Promise<string> {
119119
['diff', '--no-color', '--no-index', '--', '/dev/null', path],
120120
{ cwd, maxBuffer: GIT_MAX_BUFFER },
121121
);
122+
// Defensive: `git diff --no-index /dev/null <file>` always reports a new
123+
// file (mode/index differ, even for an empty file) and so always exits
124+
// non-zero — the diff arrives via the catch below, never here.
125+
/* v8 ignore next */
122126
return stdout;
123127
} catch (err: unknown) {
124128
// `git diff --no-index` exits 1 whenever the files differ — which is always
@@ -127,6 +131,9 @@ async function untrackedDiff(path: string, cwd: string): Promise<string> {
127131
const { stdout } = err as { stdout?: unknown };
128132
if (typeof stdout === 'string') return stdout;
129133
}
134+
// Defensive: real `git diff --no-index` always attaches the diff to the
135+
// rejected call's `stdout`, so this fallback is effectively unreachable.
136+
/* v8 ignore next */
130137
return `new file: ${path}`;
131138
}
132139
}

src/providers/anthropicApi.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ export interface AnthropicApiProviderConfig {
5151
* for this model. The SDK is imported **lazily** so `--no-ai` and the CLI
5252
* provider don't pay the load cost.
5353
*/
54+
/* v8 ignore start -- thin real-SDK adapter: hits the live Anthropic API, so it
55+
is not exercised in unit tests; the provider's logic is covered via an
56+
injected `run` (see createAnthropicApiProvider tests). */
5457
const defaultRun: AnthropicRunFn = async ({ model, maxTokens, system, prompt }) => {
5558
const { default: Anthropic } = await import('@anthropic-ai/sdk');
5659
const client = new Anthropic();
@@ -71,6 +74,7 @@ const defaultRun: AnthropicRunFn = async ({ model, maxTokens, system, prompt })
7174
),
7275
};
7376
};
77+
/* v8 ignore stop */
7478

7579
/**
7680
* Build an {@link AIProvider} backed by the **Anthropic Messages API** (official

src/providers/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,16 @@ export interface ResolveProviderOptions {
7777
language?: string;
7878
}
7979

80-
function unavailableMessage(name: string, endpoint?: string): string {
80+
/**
81+
* Build the actionable "provider unavailable" message for a resolved provider,
82+
* naming the concrete fix (set the key, start a local server, or install the
83+
* CLI). Exported for unit testing.
84+
*
85+
* @param name - The provider id (e.g. `anthropic-api`, `local`, `claude-cli`).
86+
* @param endpoint - The local endpoint, included in the `local` message when set.
87+
* @returns The user-facing unavailability message.
88+
*/
89+
export function unavailableMessage(name: string, endpoint?: string): string {
8190
if (name === 'anthropic-api') {
8291
return 'The anthropic-api provider is unavailable: set ANTHROPIC_API_KEY.';
8392
}

src/providers/local.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type FetchLike = (
2121
init?: FetchInit,
2222
) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
2323

24+
/* v8 ignore next -- thin wrapper over the global fetch; tests inject fetchImpl. */
2425
const defaultFetch: FetchLike = (url, init) => fetch(url, init);
2526

2627
/** Config captured by {@link createLocalProvider}. */

tests/apple.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,17 @@ describe('detectSystemLanguage', () => {
160160
expect(lang.length).toBeGreaterThan(0);
161161
}
162162
});
163+
164+
it('returns undefined when the Intl runtime throws (e.g. a small-ICU build)', () => {
165+
const original = Intl.DateTimeFormat;
166+
// Simulate a Node build without full ICU, where Intl construction throws.
167+
(Intl as { DateTimeFormat: unknown }).DateTimeFormat = () => {
168+
throw new Error('no ICU');
169+
};
170+
try {
171+
expect(detectSystemLanguage()).toBeUndefined();
172+
} finally {
173+
(Intl as { DateTimeFormat: unknown }).DateTimeFormat = original;
174+
}
175+
});
163176
});

tests/cliArgs.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ describe('parseArgs', () => {
3939
expect(parseArgs(['--provider', 'claude-cli']).provider).toBe('claude-cli');
4040
});
4141

42+
it('parses --endpoint and --language', () => {
43+
const args = parseArgs(['--endpoint', 'http://localhost:11434/v1', '--language', 'French']);
44+
expect(args.endpoint).toBe('http://localhost:11434/v1');
45+
expect(args.language).toBe('French');
46+
});
47+
4248
it('parses a valid --max-tokens', () => {
4349
expect(parseArgs(['--max-tokens', '8000']).maxTokens).toBe(8000);
4450
});

tests/integration.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ describe('git + orchestration integration', () => {
8282
expect(notes).not.toContain('bravo');
8383
});
8484

85+
it('generateReleaseNotes resolves the range itself when none is given', async () => {
86+
// No `range`/`from`/`to`: it must auto-resolve (untagged repo → full history)
87+
// rather than requiring an explicit range.
88+
const notes = await generateReleaseNotes({ ai: false, cwd: untagged });
89+
expect(notes).toContain('## Features');
90+
expect(notes).toContain('only commit');
91+
});
92+
8593
it('generateReleaseNotes reports an empty range cleanly', async () => {
8694
const notes = await generateReleaseNotes({ range: 'HEAD..HEAD', ai: false, cwd: tagged });
8795
expect(notes.trim()).toBe('_No changes in `HEAD..HEAD`._');
@@ -116,6 +124,10 @@ describe('working-tree changes integration', () => {
116124

117125
// Untracked: a new file never added.
118126
writeFileSync(join(repo, 'untracked.txt'), 'brand new\n');
127+
128+
// Untracked but empty: git still reports it as a new file, so the diff must
129+
// surface it even with zero content.
130+
writeFileSync(join(repo, 'empty.txt'), '');
119131
});
120132

121133
afterAll(() => {
@@ -133,6 +145,7 @@ describe('working-tree changes integration', () => {
133145
expect(wc.staged).toContain('staged.txt');
134146
expect(wc.unstaged).toContain('tracked.txt');
135147
expect(wc.untracked).toContain('untracked.txt');
148+
expect(wc.untracked).toContain('empty.txt');
136149
expect(wc.diff).toContain('### Staged changes');
137150
expect(wc.diff).toContain('### Unstaged changes');
138151
expect(wc.diff).toContain('### New (untracked) files');
@@ -146,6 +159,31 @@ describe('working-tree changes integration', () => {
146159
expect(wc.untracked).toEqual([]);
147160
});
148161

162+
it('readWorkingChanges emits no sections when every requested category is empty', async () => {
163+
const clean = initRepo();
164+
try {
165+
writeFileSync(join(clean, 'committed.txt'), 'x\n');
166+
git(clean, 'add', 'committed.txt');
167+
commit(clean, 'feat: only commit');
168+
const wc = await readWorkingChanges({
169+
cwd: clean,
170+
staged: true,
171+
unstaged: true,
172+
untracked: true,
173+
});
174+
expect(wc.isEmpty).toBe(true);
175+
expect(wc.diff).toBe('');
176+
} finally {
177+
rmSync(clean, { recursive: true, force: true });
178+
}
179+
});
180+
181+
it('readWorkingChanges defaults cwd and returns empty when nothing is requested', async () => {
182+
// No options: cwd falls back to process.cwd() and no git category runs.
183+
const wc = await readWorkingChanges();
184+
expect(wc).toMatchObject({ staged: [], unstaged: [], untracked: [], isEmpty: true });
185+
});
186+
149187
it('generateReleaseNotes (--no-ai) renders an Uncommitted changes section', async () => {
150188
const notes = await generateReleaseNotes({
151189
cwd: repo,

tests/prompt.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ describe('cleanModelOutput (notes / template)', () => {
150150
it('leaves the _No changes_ sentinel (no heading) untouched', () => {
151151
expect(cleanModelOutput('_No user-facing changes._', 'notes')).toBe('_No user-facing changes._');
152152
});
153+
154+
it('returns empty for blank/whitespace-only output', () => {
155+
expect(cleanModelOutput('', 'notes')).toBe('');
156+
expect(cleanModelOutput(' \n ', 'notes')).toBe('');
157+
});
153158
});
154159

155160
describe('cleanModelOutput (commit)', () => {

0 commit comments

Comments
 (0)