zencommit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +421 -0
  2. package/bin/zencommit.js +37 -0
  3. package/package.json +68 -0
  4. package/scripts/install.mjs +146 -0
  5. package/scripts/platform.mjs +34 -0
  6. package/src/auth/secrets.ts +234 -0
  7. package/src/commands/auth.ts +138 -0
  8. package/src/commands/config.ts +83 -0
  9. package/src/commands/default.ts +322 -0
  10. package/src/commands/models.ts +67 -0
  11. package/src/config/load.test.ts +47 -0
  12. package/src/config/load.ts +118 -0
  13. package/src/config/merge.test.ts +25 -0
  14. package/src/config/merge.ts +30 -0
  15. package/src/config/types.ts +119 -0
  16. package/src/config/validate.ts +139 -0
  17. package/src/git/commit.ts +17 -0
  18. package/src/git/diff.ts +89 -0
  19. package/src/git/repo.ts +10 -0
  20. package/src/index.ts +207 -0
  21. package/src/llm/generate.ts +188 -0
  22. package/src/llm/prompt-template.ts +44 -0
  23. package/src/llm/prompt.ts +83 -0
  24. package/src/llm/prompts/base.md +119 -0
  25. package/src/llm/prompts/conventional.md +123 -0
  26. package/src/llm/prompts/gitmoji.md +212 -0
  27. package/src/llm/prompts/system.md +21 -0
  28. package/src/llm/providers.ts +102 -0
  29. package/src/llm/tokens.test.ts +22 -0
  30. package/src/llm/tokens.ts +46 -0
  31. package/src/llm/truncate.test.ts +60 -0
  32. package/src/llm/truncate.ts +552 -0
  33. package/src/metadata/cache.ts +28 -0
  34. package/src/metadata/index.ts +94 -0
  35. package/src/metadata/providers/local.ts +66 -0
  36. package/src/metadata/providers/modelsdev.ts +145 -0
  37. package/src/metadata/types.ts +20 -0
  38. package/src/ui/editor.ts +33 -0
  39. package/src/ui/prompts.ts +99 -0
  40. package/src/util/exec.ts +57 -0
  41. package/src/util/fs.ts +46 -0
  42. package/src/util/logger.ts +50 -0
  43. package/src/util/redact.ts +30 -0
@@ -0,0 +1,322 @@
1
+ import { ConfigLoadError, resolveConfig, resolveConfigWithSources } from '../config/load.js';
2
+ import { validateConfig } from '../config/validate.js';
3
+ import type { CommitStyle, DiffMode, ResolvedConfig } from '../config/types.js';
4
+ import { createMetadataResolver } from '../metadata/index.js';
5
+ import { getRepoRoot } from '../git/repo.js';
6
+ import { getDiff, getFileList, getFileSummary } from '../git/diff.js';
7
+ import { commitMessage } from '../git/commit.js';
8
+ import { buildPrompt, buildPromptWithoutDiff } from '../llm/prompt.js';
9
+ import { computeTokenBudget, countTokens, getEncodingForModel } from '../llm/tokens.js';
10
+ import { truncateDiffByFile, truncateDiffSmart } from '../llm/truncate.js';
11
+ import { generateCommitMessage } from '../llm/generate.js';
12
+ import { confirmCommit } from '../ui/prompts.js';
13
+ import { openEditor } from '../ui/editor.js';
14
+ import { exec, ExecError } from '../util/exec.js';
15
+ import yoctoSpinner from 'yocto-spinner';
16
+ import { getVerbosity, logBlock, logJson, logVerbose, setVerbosity } from '../util/logger.js';
17
+ import { redactObject } from '../util/redact.js';
18
+
19
+ const DEFAULT_MAX_SUBJECT_CHARS = 72;
20
+
21
+ export interface DefaultCommandArgs {
22
+ yes?: boolean;
23
+ dryRun?: boolean;
24
+ all?: boolean;
25
+ unstaged?: boolean;
26
+ commit?: boolean;
27
+ model?: string;
28
+ format?: CommitStyle;
29
+ lang?: string;
30
+ noBody?: boolean;
31
+ verbose?: number;
32
+ '--'?: string[];
33
+ }
34
+
35
+ const applyOverrides = (config: ResolvedConfig, args: DefaultCommandArgs): ResolvedConfig => {
36
+ const updated = { ...config };
37
+ if (args.model) {
38
+ updated.ai = { ...updated.ai, model: args.model };
39
+ }
40
+ if (args.format) {
41
+ updated.commit = { ...updated.commit, style: args.format };
42
+ }
43
+ if (args.lang) {
44
+ updated.commit = { ...updated.commit, language: args.lang };
45
+ }
46
+ if (args.noBody) {
47
+ updated.commit = { ...updated.commit, includeBody: false };
48
+ }
49
+ return updated;
50
+ };
51
+
52
+ const resolveDiffMode = (config: ResolvedConfig, args: DefaultCommandArgs): DiffMode => {
53
+ if (args.unstaged) {
54
+ return 'unstaged';
55
+ }
56
+ if (args.all) {
57
+ return 'staged';
58
+ }
59
+ return config.git.diffMode;
60
+ };
61
+
62
+ const maybeAutoStage = async (shouldStage: boolean, cwd?: string): Promise<void> => {
63
+ if (!shouldStage) {
64
+ return;
65
+ }
66
+ await exec(['git', 'add', '-A'], { cwd });
67
+ };
68
+
69
+ const formatPreview = (subject: string, body: string): string => {
70
+ if (body.trim().length === 0) {
71
+ return subject.trim();
72
+ }
73
+ return `${subject.trim()}\n\n${body.trim()}`;
74
+ };
75
+
76
+ const parseEditedMessage = (text: string): { subject: string; body: string } => {
77
+ const lines = text.split(/\r?\n/);
78
+ const subject = lines.shift() ?? '';
79
+ const body = lines.join('\n').trim();
80
+ return { subject: subject.trim(), body };
81
+ };
82
+
83
+ export const runDefaultCommand = async (args: DefaultCommandArgs): Promise<void> => {
84
+ try {
85
+ if (typeof args.verbose === 'number') {
86
+ setVerbosity(args.verbose);
87
+ }
88
+ const repoRoot = await getRepoRoot();
89
+ if (!repoRoot) {
90
+ console.error('Not inside a git repository.');
91
+ process.exit(3);
92
+ }
93
+ logVerbose(1, `repo root: ${repoRoot}`);
94
+
95
+ let config: ResolvedConfig;
96
+ try {
97
+ if (getVerbosity() >= 1) {
98
+ const resolved = await resolveConfigWithSources(repoRoot);
99
+ config = resolved.config;
100
+ logJson(1, 'config sources', resolved.sourceMap);
101
+ } else {
102
+ config = await resolveConfig(repoRoot);
103
+ }
104
+ } catch (error) {
105
+ if (error instanceof ConfigLoadError) {
106
+ console.error(error.message);
107
+ process.exit(2);
108
+ }
109
+ throw error;
110
+ }
111
+ if (getVerbosity() >= 2) {
112
+ logJson(2, 'resolved config', redactObject(config));
113
+ }
114
+ const validation = validateConfig(config);
115
+ if (!validation.valid) {
116
+ console.error('Config validation failed:');
117
+ validation.errors.forEach((error) => {
118
+ console.error(`- ${error.path}: ${error.message}`);
119
+ });
120
+ process.exit(2);
121
+ }
122
+
123
+ config = applyOverrides(config, args);
124
+ if (getVerbosity() >= 2) {
125
+ logJson(2, 'cli overrides', {
126
+ model: args.model,
127
+ format: args.format,
128
+ lang: args.lang,
129
+ noBody: args.noBody ?? false,
130
+ });
131
+ }
132
+ const requestedDiffMode = resolveDiffMode(config, args);
133
+ const effectiveDiffMode: DiffMode = requestedDiffMode === 'all' ? 'staged' : requestedDiffMode;
134
+ const autoStage = args.all || config.git.autoStage || requestedDiffMode === 'all';
135
+ logVerbose(
136
+ 1,
137
+ `diff mode: requested=${requestedDiffMode} effective=${effectiveDiffMode} autoStage=${autoStage}`,
138
+ );
139
+
140
+ if (autoStage && effectiveDiffMode !== 'unstaged') {
141
+ await maybeAutoStage(true, repoRoot);
142
+ }
143
+
144
+ const useSmart = config.diff.truncateStrategy === 'smart';
145
+ const diffText = await getDiff({ mode: effectiveDiffMode, cwd: repoRoot, compact: useSmart });
146
+ if (getVerbosity() >= 2) {
147
+ const diffLines = diffText ? diffText.split(/\r?\n/).length : 0;
148
+ logVerbose(2, `diff length: ${diffText.length} chars, ${diffLines} lines`);
149
+ }
150
+
151
+ if (!diffText.trim()) {
152
+ console.error('No diff to summarize.');
153
+ process.exit(3);
154
+ }
155
+
156
+ const fileList = config.diff.includeFileList
157
+ ? (await getFileList({ mode: effectiveDiffMode, cwd: repoRoot }))
158
+ .slice(0, config.diff.maxFiles)
159
+ .join('\n')
160
+ : '';
161
+ if (getVerbosity() >= 2) {
162
+ const fileCount = fileList ? fileList.split(/\r?\n/).length : 0;
163
+ logVerbose(2, `file list: ${fileCount} files`);
164
+ }
165
+
166
+ const metadataResolver = createMetadataResolver(config.metadata, repoRoot);
167
+ const modelMetadata = await metadataResolver.getModel(config.ai.model);
168
+
169
+ const limits = modelMetadata?.limits ?? ({ context: 8000, input: 8000, output: null } as const);
170
+
171
+ if (!modelMetadata) {
172
+ console.warn('Model metadata not found. Using conservative token limits.');
173
+ } else if (getVerbosity() >= 2) {
174
+ logJson(2, 'model metadata', modelMetadata);
175
+ }
176
+
177
+ const encoding = getEncodingForModel(config.ai.model);
178
+ const promptInput = {
179
+ style: config.commit.style,
180
+ language: config.commit.language,
181
+ includeBody: config.commit.includeBody,
182
+ emoji: config.commit.emoji,
183
+ maxSubjectChars: DEFAULT_MAX_SUBJECT_CHARS,
184
+ fileList,
185
+ diffText: '',
186
+ };
187
+
188
+ const promptWithoutDiff = await buildPromptWithoutDiff(promptInput);
189
+ const overheadTokens = countTokens(
190
+ `${promptWithoutDiff.system}\n${promptWithoutDiff.user}`,
191
+ encoding,
192
+ );
193
+
194
+ const budget = computeTokenBudget(limits, config.ai.maxOutputTokens, overheadTokens);
195
+ if (getVerbosity() >= 2) {
196
+ logJson(2, 'token budget', budget);
197
+ }
198
+
199
+ let truncatedText = '';
200
+ if (useSmart) {
201
+ const fileSummary = await getFileSummary({ mode: effectiveDiffMode, cwd: repoRoot });
202
+ const truncated = truncateDiffSmart(
203
+ fileSummary,
204
+ diffText,
205
+ budget.availableTokens,
206
+ config.diff,
207
+ encoding,
208
+ );
209
+ truncatedText = truncated.text;
210
+ if (getVerbosity() >= 2) {
211
+ logVerbose(2, `truncation: mode=${truncated.mode} usedTokens=${truncated.usedTokens}`);
212
+ }
213
+ } else if (budget.availableTokens <= 0) {
214
+ const fileSummary = await getFileSummary({ mode: effectiveDiffMode, cwd: repoRoot });
215
+ truncatedText = fileSummary.trim() ? `File summary:\\n${fileSummary.trim()}` : fileList;
216
+ } else {
217
+ const truncated = truncateDiffByFile(diffText, budget.availableTokens, encoding);
218
+ truncatedText = truncated.text;
219
+ if (getVerbosity() >= 2) {
220
+ logVerbose(2, `truncation: mode=${truncated.mode} usedTokens=${truncated.usedTokens}`);
221
+ }
222
+ }
223
+
224
+ encoding.free();
225
+
226
+ const prompt = await buildPrompt({ ...promptInput, diffText: truncatedText });
227
+ if (getVerbosity() >= 3) {
228
+ logBlock(3, 'prompt system', prompt.system);
229
+ logBlock(3, 'prompt user', prompt.user);
230
+ }
231
+ const spinner = process.stderr.isTTY
232
+ ? yoctoSpinner({ text: 'Generating commit message...' }).start()
233
+ : null;
234
+ let message: { subject: string; body: string };
235
+ try {
236
+ if (getVerbosity() >= 2) {
237
+ logJson(2, 'llm request', {
238
+ modelId: config.ai.model,
239
+ temperature: config.ai.temperature,
240
+ maxOutputTokens: Math.min(config.ai.maxOutputTokens, budget.outputTokens),
241
+ timeoutMs: config.ai.timeoutMs,
242
+ style: config.commit.style,
243
+ });
244
+ }
245
+ message = await generateCommitMessage({
246
+ modelId: config.ai.model,
247
+ system: prompt.system,
248
+ user: prompt.user,
249
+ temperature: config.ai.temperature,
250
+ maxOutputTokens: Math.min(config.ai.maxOutputTokens, budget.outputTokens),
251
+ timeoutMs: config.ai.timeoutMs,
252
+ maxSubjectChars: DEFAULT_MAX_SUBJECT_CHARS,
253
+ style: config.commit.style,
254
+ openaiCompatible: config.ai.openaiCompatible,
255
+ });
256
+ spinner?.success('Generated commit message.');
257
+ if (getVerbosity() >= 2) {
258
+ logJson(2, 'llm response', message);
259
+ }
260
+ } catch (error) {
261
+ spinner?.error('Failed to generate commit message.');
262
+ throw error;
263
+ }
264
+
265
+ if (!config.commit.includeBody) {
266
+ message = { ...message, body: '' };
267
+ }
268
+
269
+ console.log('\n' + formatPreview(message.subject, message.body) + '\n');
270
+
271
+ const extraArgs = args['--'] ?? [];
272
+ const allowCommit = effectiveDiffMode !== 'unstaged' || args.commit;
273
+ const shouldCommit = allowCommit && !args.dryRun;
274
+ if (getVerbosity() >= 1) {
275
+ logJson(1, 'commit decision', {
276
+ allowCommit,
277
+ shouldCommit,
278
+ extraArgs,
279
+ dryRun: args.dryRun ?? false,
280
+ });
281
+ }
282
+
283
+ if (!shouldCommit) {
284
+ if (effectiveDiffMode === 'unstaged' && !args.commit) {
285
+ console.warn('Unstaged diff selected; skipping commit unless --commit is provided.');
286
+ }
287
+ process.exit(0);
288
+ }
289
+
290
+ const skipConfirm = args.yes || !config.git.confirmBeforeCommit;
291
+ if (getVerbosity() >= 2) {
292
+ logVerbose(2, `commit confirmation: ${skipConfirm ? 'skipped' : 'prompted'}`);
293
+ }
294
+ if (!skipConfirm) {
295
+ const action = await confirmCommit('Commit with this message?');
296
+ if (action === 'cancel') {
297
+ process.exit(0);
298
+ }
299
+ if (action === 'edit') {
300
+ logVerbose(2, 'commit message: opening editor');
301
+ const edited = await openEditor(formatPreview(message.subject, message.body));
302
+ const parsed = parseEditedMessage(edited);
303
+ message = { subject: parsed.subject, body: parsed.body };
304
+ }
305
+ }
306
+
307
+ await commitMessage(message.subject, message.body, extraArgs, repoRoot);
308
+ } catch (error) {
309
+ if (error instanceof ExecError) {
310
+ console.error(error.stderr || error.message);
311
+ process.exit(3);
312
+ }
313
+ const message = (error as Error).message ?? 'Unknown error.';
314
+ if (/API key/i.test(message)) {
315
+ console.error(message);
316
+ console.error('Run `zencommit auth login` to store credentials.');
317
+ process.exit(2);
318
+ }
319
+ console.error(message);
320
+ process.exit(4);
321
+ }
322
+ };
@@ -0,0 +1,67 @@
1
+ import { createMetadataResolver } from '../metadata/index.js';
2
+ import { ConfigLoadError, resolveConfig } from '../config/load.js';
3
+ import { getRepoRoot } from '../git/repo.js';
4
+ import { promptForModelSelection } from '../ui/prompts.js';
5
+ import { logVerbose } from '../util/logger.js';
6
+
7
+ export const runModelsSearch = async (query?: string, maxItems = 10): Promise<void> => {
8
+ try {
9
+ const repoRoot = await getRepoRoot();
10
+ logVerbose(1, `models search: repo root ${repoRoot ?? 'unknown'}`);
11
+ const config = await resolveConfig(repoRoot);
12
+ const resolver = createMetadataResolver(config.metadata, repoRoot);
13
+ const list = resolver.list ? await resolver.list() : [];
14
+ const models = list.length > 0 ? list : query ? await resolver.search(query) : [];
15
+ logVerbose(2, `models search: candidates=${models.length}`);
16
+ if (models.length === 0) {
17
+ console.log('No models found.');
18
+ return;
19
+ }
20
+
21
+ const selectedId = await promptForModelSelection(
22
+ models.map((model) => ({ id: model.id, name: model.name })),
23
+ query,
24
+ maxItems,
25
+ );
26
+ if (!selectedId) {
27
+ return;
28
+ }
29
+
30
+ const selected =
31
+ models.find((model) => model.id === selectedId) ?? (await resolver.getModel(selectedId));
32
+ if (selected) {
33
+ console.log(JSON.stringify(selected, null, 2));
34
+ } else {
35
+ console.log(selectedId);
36
+ }
37
+ } catch (error) {
38
+ if (error instanceof ConfigLoadError) {
39
+ console.error(error.message);
40
+ process.exit(2);
41
+ }
42
+ throw error;
43
+ }
44
+ };
45
+
46
+ export const runModelsInfo = async (modelId: string): Promise<void> => {
47
+ try {
48
+ const repoRoot = await getRepoRoot();
49
+ logVerbose(1, `models info: repo root ${repoRoot ?? 'unknown'}`);
50
+ const config = await resolveConfig(repoRoot);
51
+ const resolver = createMetadataResolver(config.metadata, repoRoot);
52
+ const model = await resolver.getModel(modelId);
53
+ if (!model) {
54
+ console.error(`Model not found: ${modelId}`);
55
+ console.error('Try switching metadata providers or providing a local metadata file.');
56
+ process.exit(2);
57
+ }
58
+
59
+ console.log(JSON.stringify(model, null, 2));
60
+ } catch (error) {
61
+ if (error instanceof ConfigLoadError) {
62
+ console.error(error.message);
63
+ process.exit(2);
64
+ }
65
+ throw error;
66
+ }
67
+ };
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { resolveConfig } from './load.js';
6
+
7
+ const writeJson = async (filePath: string, data: unknown) => {
8
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
9
+ await fs.writeFile(filePath, JSON.stringify(data), 'utf8');
10
+ };
11
+
12
+ const withTempDir = async (fn: (dir: string) => Promise<void>) => {
13
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'zencommit-test-'));
14
+ await fn(dir);
15
+ };
16
+
17
+ describe('resolveConfig precedence', () => {
18
+ it('merges config sources in order', async () => {
19
+ const originalEnv = { ...process.env };
20
+ await withTempDir(async (dir) => {
21
+ const repoRoot = path.join(dir, 'repo');
22
+ await fs.mkdir(repoRoot, { recursive: true });
23
+
24
+ const globalRoot = path.join(dir, 'global');
25
+ process.env.XDG_CONFIG_HOME = globalRoot;
26
+
27
+ const globalConfigPath = path.join(globalRoot, 'zencommit', 'config.json');
28
+ await writeJson(globalConfigPath, { commit: { style: 'freeform' } });
29
+
30
+ const customPath = path.join(dir, 'custom.json');
31
+ await writeJson(customPath, { ai: { model: 'openai/test-model' } });
32
+ process.env.ZENCOMMIT_CONFIG = customPath;
33
+
34
+ const projectPath = path.join(repoRoot, 'zencommit.json');
35
+ await writeJson(projectPath, { commit: { language: 'es' } });
36
+
37
+ process.env.ZENCOMMIT_CONFIG_CONTENT = JSON.stringify({ ai: { temperature: 0.9 } });
38
+
39
+ const config = await resolveConfig(repoRoot);
40
+ expect(config.commit.style).toBe('freeform');
41
+ expect(config.ai.model).toBe('openai/test-model');
42
+ expect(config.commit.language).toBe('es');
43
+ expect(config.ai.temperature).toBe(0.9);
44
+ });
45
+ process.env = originalEnv;
46
+ });
47
+ });
@@ -0,0 +1,118 @@
1
+ import path from 'node:path';
2
+ import { deepMerge } from './merge.js';
3
+ import type { ResolvedConfig } from './types.js';
4
+ import { defaultConfig } from './types.js';
5
+ import { getConfigRoot, readJsonFile, resolvePath } from '../util/fs.js';
6
+
7
+ export type ConfigSourceName = 'global' | 'custom' | 'project' | 'inline';
8
+
9
+ export interface ConfigSource {
10
+ name: ConfigSourceName;
11
+ path?: string;
12
+ data: unknown;
13
+ }
14
+
15
+ export class ConfigLoadError extends Error {
16
+ source: ConfigSourceName | 'inline' | 'unknown';
17
+ path?: string;
18
+
19
+ constructor(message: string, source: ConfigSourceName | 'inline' | 'unknown', path?: string) {
20
+ super(message);
21
+ this.name = 'ConfigLoadError';
22
+ this.source = source;
23
+ this.path = path;
24
+ }
25
+ }
26
+
27
+ const readConfigFile = async (
28
+ filePath: string,
29
+ source: ConfigSourceName,
30
+ ): Promise<Record<string, unknown> | null> => {
31
+ try {
32
+ return await readJsonFile<Record<string, unknown>>(filePath);
33
+ } catch (error) {
34
+ throw new ConfigLoadError(
35
+ `Failed to parse ${source} config at ${filePath}: ${(error as Error).message}`,
36
+ source,
37
+ filePath,
38
+ );
39
+ }
40
+ };
41
+
42
+ export const getGlobalConfigPath = (): string =>
43
+ path.join(getConfigRoot(), 'zencommit', 'config.json');
44
+
45
+ export const getProjectConfigPath = (repoRoot: string | null): string | null => {
46
+ if (!repoRoot) {
47
+ return null;
48
+ }
49
+ return path.join(repoRoot, 'zencommit.json');
50
+ };
51
+
52
+ export const loadConfigSources = async (repoRoot: string | null): Promise<ConfigSource[]> => {
53
+ const sources: ConfigSource[] = [];
54
+
55
+ const globalPath = getGlobalConfigPath();
56
+ const globalConfig = await readConfigFile(globalPath, 'global');
57
+ if (globalConfig) {
58
+ sources.push({ name: 'global', path: globalPath, data: globalConfig });
59
+ }
60
+
61
+ const customPathEnv = process.env.ZENCOMMIT_CONFIG;
62
+ if (customPathEnv) {
63
+ const resolvedPath = resolvePath(customPathEnv, repoRoot ?? process.cwd());
64
+ const customConfig = await readConfigFile(resolvedPath, 'custom');
65
+ if (customConfig) {
66
+ sources.push({ name: 'custom', path: resolvedPath, data: customConfig });
67
+ }
68
+ }
69
+
70
+ const projectPath = getProjectConfigPath(repoRoot);
71
+ if (projectPath) {
72
+ const projectConfig = await readConfigFile(projectPath, 'project');
73
+ if (projectConfig) {
74
+ sources.push({ name: 'project', path: projectPath, data: projectConfig });
75
+ }
76
+ }
77
+
78
+ const inlineContent = process.env.ZENCOMMIT_CONFIG_CONTENT;
79
+ if (inlineContent) {
80
+ try {
81
+ const inlineConfig = JSON.parse(inlineContent) as Record<string, unknown>;
82
+ sources.push({ name: 'inline', data: inlineConfig });
83
+ } catch (error) {
84
+ throw new ConfigLoadError(
85
+ `Invalid JSON in ZENCOMMIT_CONFIG_CONTENT: ${(error as Error).message}`,
86
+ 'inline',
87
+ );
88
+ }
89
+ }
90
+
91
+ return sources;
92
+ };
93
+
94
+ export const resolveConfig = async (repoRoot: string | null): Promise<ResolvedConfig> => {
95
+ const sources = await loadConfigSources(repoRoot);
96
+ return sources.reduce(
97
+ (config, source) => deepMerge(config, source.data as Partial<ResolvedConfig>),
98
+ defaultConfig,
99
+ );
100
+ };
101
+
102
+ export const resolveConfigWithSources = async (
103
+ repoRoot: string | null,
104
+ ): Promise<{ config: ResolvedConfig; sourceMap: Record<string, ConfigSourceName> }> => {
105
+ const sources = await loadConfigSources(repoRoot);
106
+ let config = defaultConfig;
107
+ const sourceMap: Record<string, ConfigSourceName> = {};
108
+
109
+ for (const source of sources) {
110
+ const data = source.data as Record<string, unknown>;
111
+ for (const key of Object.keys(data)) {
112
+ sourceMap[key] = source.name;
113
+ }
114
+ config = deepMerge(config, data as Partial<ResolvedConfig>);
115
+ }
116
+
117
+ return { config, sourceMap };
118
+ };
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deepMerge } from './merge.js';
3
+
4
+ describe('deepMerge', () => {
5
+ it('merges nested objects', () => {
6
+ const base = { a: { b: 1, c: 2 }, d: 3 };
7
+ const override = { a: { b: 9 } };
8
+ const result = deepMerge(base, override);
9
+ expect(result).toEqual({ a: { b: 9, c: 2 }, d: 3 });
10
+ });
11
+
12
+ it('replaces arrays', () => {
13
+ const base = { items: [1, 2, 3] };
14
+ const override = { items: [9] };
15
+ const result = deepMerge(base, override);
16
+ expect(result).toEqual({ items: [9] });
17
+ });
18
+
19
+ it('replaces scalars', () => {
20
+ const base = { value: 1 };
21
+ const override = { value: 2 };
22
+ const result = deepMerge(base, override);
23
+ expect(result).toEqual({ value: 2 });
24
+ });
25
+ });
@@ -0,0 +1,30 @@
1
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
2
+ if (!value || typeof value !== 'object') {
3
+ return false;
4
+ }
5
+ const proto = Object.getPrototypeOf(value) as object | null;
6
+ return proto === Object.prototype || proto === null;
7
+ };
8
+
9
+ export const deepMerge = <T>(base: T, override: Partial<T>): T => {
10
+ if (Array.isArray(base) || Array.isArray(override)) {
11
+ return (override ?? base) as T;
12
+ }
13
+ if (!isPlainObject(base) || !isPlainObject(override)) {
14
+ return (override ?? base) as T;
15
+ }
16
+
17
+ const result: Record<string, unknown> = { ...base };
18
+ for (const [key, value] of Object.entries(override)) {
19
+ const baseValue = (base as Record<string, unknown>)[key];
20
+ if (Array.isArray(value)) {
21
+ result[key] = value;
22
+ } else if (isPlainObject(value) && isPlainObject(baseValue)) {
23
+ result[key] = deepMerge(baseValue, value);
24
+ } else if (value !== undefined) {
25
+ result[key] = value;
26
+ }
27
+ }
28
+
29
+ return result as T;
30
+ };