Skip to content

Commit 4d8f09e

Browse files
authored
feat(config): user-level (global) config with per-repo consent (#1559)
* feat(config): user-level (global) config with per-repo consent Implements the global user config layer from spec docs/tasks/1448-user-level-config.md. - Registry: userConfig.consent section with get/set/list/clear per-repo decisions (TTL-exempt, pruned by missing-path only) - Config: resolveUserConfigPath (env var → XDG → APPDATA → ~/.codegraph fallback), sanitizeUserLayer (absolute dbPath guard), appliesTo glob matching, layered merge DEFAULTS → global → project → env → secrets, per-layer excludeTests shorthand hoisting - Consent model: disabled > enabled > appliesTo glob > undecided (§4.1/4.2) Non-interactive contexts (CI, MCP, programmatic) never prompt - loadConfigWithProvenance: per-key source map for --explain - setUserConfigOverride: CLI preAction hook wires --user-config/--no-user-config - computeConfigHash: stable hash of build-relevant config keys; stored as build_meta.config_hash; triggers full rebuild on change (closes pre-existing project-config incremental gap, see #1557) - promptForConsentIfNeeded: async TTY-gated prompt fired before build - CLI: codegraph config command (--explain, --enable-global, --disable-global, --list-global); global --user-config [path] / --no-user-config flags - Build pipeline: threads userConfig, emits ℹ notice when global layer injects build-affecting keys, stores config_hash in finalize - Lazy config in options.ts: defers loadConfig until first property access so CLI flags are parsed before config is evaluated - Tests: 26 new unit tests for config-user, 18 new tests for registry consent - Docs: configuration.md global config section; README pointer Deferred (#1558): --init / --edit scaffolding helpers * fix(builder): clear file_hashes on config-triggered full rebuild When opts.exclude is introduced on a second build, the config hash changes and promotes to forceFullRebuild. handleFullBuild deleted nodes/edges but left file_hashes intact, so previously-indexed files that are now excluded remained visible in file_hashes after the rebuild. Adds DELETE FROM file_hashes to the full rebuild statement so stale entries from excluded files are purged. insertNodes then re-inserts fresh hashes only for the files that were actually collected. * fix(config): address review feedback on user-level config - XDG_CONFIG_HOME: honour on all platforms (including Windows) — the previous code checked XDG_CONFIG_HOME only on non-Windows, so the Windows CI test 'uses XDG_CONFIG_HOME when set' returned null. - _lastAppliedGlobalPath stale on cache hit (P1): move the assignment before the early-return so programmatic callers making multiple buildGraph calls in the same process get the correct value for the build-time notice (Greptile P1, comment #3418566672). - Eliminate TOCTOU double file read: add _lastAppliedGlobalConfig alongside _lastAppliedGlobalPath; loadConfig populates it once; pipeline.ts build notice and loadConfigWithProvenance both read from the cache rather than re-opening the file (Greptile P2 comments #3418566729, #3418566865). - Remove void no-op suppressions in loadConfigWithProvenance — both variables are read in their respective loops and need no suppression (Greptile P2, outside-diff comment). - Redundant --json branch in codegraph config default case: both branches emitted identical JSON; unified into one write; the discovery hint on stderr is now suppressed by --json (Greptile P2, outside-diff comment). * fix(config): restore global config cache on loadConfig cache hit
1 parent d3865ba commit 4d8f09e

15 files changed

Lines changed: 1359 additions & 27 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,8 @@ Copy `.github/workflows/codegraph-impact.yml` to your repo, and every PR will ge
797797

798798
Create a `.codegraphrc.json` in your project root to customize behavior. The snippets below cover the most-used keys — see **[docs/guides/configuration.md](docs/guides/configuration.md)** for the full reference (every group, every key, every default).
799799

800+
**Global (user-level) config:** you can also define personal defaults once at `~/.config/codegraph/config.json` and opt individual repos into it with `codegraph config --enable-global`. The global layer merges below the project config so repos always win, and non-interactive contexts (CI, MCP) never apply it without explicit consent. See [docs/guides/configuration.md#user-level-global-configuration](docs/guides/configuration.md#user-level-global-configuration).
801+
800802
```json
801803
{
802804
"include": ["src/**", "lib/**"],

docs/guides/configuration.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,97 @@ Leaves `build.dbPath`, `build.driftThreshold`, etc. at their defaults. Arrays ar
3232

3333
---
3434

35+
## User-level (global) configuration
36+
37+
You can define **personal config defaults once** and reuse them across repositories — without committing anything to any repo, and without ever silently changing a repo's behavior.
38+
39+
**The defining property:** a global config file is inert until a specific repository explicitly consents to it. There is no blanket "apply everywhere" switch.
40+
41+
### Global config file location
42+
43+
Codegraph resolves the global config file in this order:
44+
45+
1. `CODEGRAPH_USER_CONFIG=<path>` env var (location override only — does not force application)
46+
2. `$XDG_CONFIG_HOME/codegraph/config.json` (Unix/macOS), or `%APPDATA%\codegraph\config.json` (Windows), falling back to `~/.config/codegraph/config.json`
47+
3. `~/.codegraph/config.json` (legacy fallback, next to `registry.json`)
48+
49+
### Format
50+
51+
The global config file uses the same schema as `.codegraphrc.json`. Two shapes are accepted:
52+
53+
```jsonc
54+
// Plain config — applies to any repo that has consented
55+
{ "query": { "defaultLimit": 50 }, "exclude": ["**/*.generated.*"] }
56+
```
57+
58+
```jsonc
59+
// With appliesTo — auto-consent for matching repo paths (power-user)
60+
{
61+
"appliesTo": ["~/work/**", "/Users/me/oss/*"],
62+
"config": { "query": { "defaultLimit": 50 } }
63+
}
64+
```
65+
66+
### Consent model
67+
68+
A global config file has no effect until a repository consents to it. Consent is per-repo and per-machine (stored in `~/.codegraph/registry.json`, never committed):
69+
70+
| Command | Effect |
71+
|---------|--------|
72+
| `codegraph config --enable-global` | Record `enabled` consent for the current repo |
73+
| `codegraph config --disable-global` | Record `disabled` consent for the current repo |
74+
| `codegraph config --list-global` | List every repo with a recorded decision |
75+
| `codegraph build` (interactive) | Prompts once if the repo is undecided and a global file exists |
76+
77+
**Non-interactive contexts** (CI, MCP, programmatic use, hooks) never get prompted and default to *off*, keeping builds reproducible.
78+
79+
**Per-run overrides** (do not record consent):
80+
- `--user-config [path]` — force-on for this run (optional custom path)
81+
- `--no-user-config` — force-off for this run
82+
83+
### Precedence
84+
85+
```
86+
DEFAULTS → global (if consented) → project (.codegraphrc.json) → env vars → secrets
87+
```
88+
89+
- Objects are **deep-merged** (later layers win per key)
90+
- Arrays and scalars **replace** (project `ignoreDirs` fully replaces global `ignoreDirs`)
91+
- A project that omits a key inherits from the global layer
92+
93+
### Safety guard
94+
95+
If the global file sets `build.dbPath` to an **absolute path**, codegraph drops that key with a warning (it would make every repo share one database). Relative `dbPath` values are allowed through unchanged.
96+
97+
### Transparency
98+
99+
- `codegraph config` — print the effective config; shows a discovery hint when a global file exists but is not applied
100+
- `codegraph config --explain` — per-key provenance (`default` / `user` / `project` / `env`) plus consent state and applied file paths
101+
- Build notice — when the global layer contributes build-affecting keys, codegraph prints a one-line notice: `ℹ global config applied (<path>) — injecting: ... · --no-user-config to ignore`
102+
103+
### Programmatic API
104+
105+
```typescript
106+
import { loadConfig, loadConfigWithProvenance } from '@optave/codegraph';
107+
108+
// Normal: honour per-repo consent from registry
109+
loadConfig('/path/to/repo');
110+
111+
// Force-on: apply the default global file
112+
loadConfig('/path/to/repo', { userConfig: true });
113+
114+
// Force-on with explicit file
115+
loadConfig('/path/to/repo', { userConfig: '/path/to/global.json' });
116+
117+
// Force-off
118+
loadConfig('/path/to/repo', { userConfig: false });
119+
120+
// With provenance info (for tooling)
121+
const { config, provenance, appliedGlobalPath } = loadConfigWithProvenance('/path/to/repo');
122+
```
123+
124+
---
125+
35126
## File selection
36127

37128
Controls which files codegraph parses.

src/cli/commands/build.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ export const command: CommandDefinition = {
1616
],
1717
async execute([dir], opts, ctx) {
1818
const root = path.resolve(dir || '.');
19-
const engine = ctx.program.opts().engine;
19+
const globalOpts = ctx.program.opts();
20+
const engine = globalOpts.engine;
21+
// Prompt for global-config consent on interactive TTY builds (§4.3).
22+
const promptForConsent = !process.env.CI && !!process.stdin.isTTY && !!process.stdout.isTTY;
2023
await buildGraph(root, {
2124
incremental: opts.incremental as boolean,
2225
ast: opts.ast as boolean,
@@ -25,6 +28,8 @@ export const command: CommandDefinition = {
2528
dataflow: opts.dataflow as boolean,
2629
cfg: opts.cfg as boolean,
2730
dbPath: opts.db ? path.resolve(opts.db as string) : undefined,
31+
userConfig: globalOpts.userConfig as string | boolean | undefined,
32+
promptForConsent,
2833
});
2934
},
3035
};

src/cli/commands/config.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import path from 'node:path';
2+
import {
3+
clearConfigCache,
4+
loadConfig,
5+
loadConfigWithProvenance,
6+
resolveUserConfigPath,
7+
} from '../../infrastructure/config.js';
8+
import {
9+
getUserConfigConsent,
10+
listUserConfigConsent,
11+
REGISTRY_PATH,
12+
setUserConfigConsent,
13+
} from '../../infrastructure/registry.js';
14+
import type { CommandDefinition } from '../types.js';
15+
16+
export const command: CommandDefinition = {
17+
name: 'config',
18+
description: 'Show or manage codegraph configuration (project + user-level global config)',
19+
options: [
20+
['-j, --json', 'Output as JSON'],
21+
['--explain', 'Show per-key provenance (default / user / project / env)'],
22+
['--enable-global', 'Record consent to apply the global config to this repo'],
23+
['--disable-global', 'Record consent to skip the global config for this repo'],
24+
['--list-global', 'List all repos with a recorded consent decision'],
25+
],
26+
execute(_args, opts, ctx) {
27+
const rootDir = path.resolve('.');
28+
29+
// ── Consent management ─────────────────────────────────────────────
30+
31+
if (opts.enableGlobal) {
32+
setUserConfigConsent(rootDir, 'enabled');
33+
clearConfigCache();
34+
const globalPath = resolveUserConfigPath();
35+
if (!globalPath) {
36+
process.stderr.write(
37+
`Consent recorded: "enabled" for ${rootDir}\n` +
38+
`Note: no global config file found. Create one at ~/.config/codegraph/config.json\n`,
39+
);
40+
} else {
41+
process.stderr.write(
42+
`Consent recorded: "enabled" for ${rootDir}\n` + `Global config: ${globalPath}\n`,
43+
);
44+
}
45+
return;
46+
}
47+
48+
if (opts.disableGlobal) {
49+
setUserConfigConsent(rootDir, 'disabled');
50+
clearConfigCache();
51+
process.stderr.write(`Consent recorded: "disabled" for ${rootDir}\n`);
52+
return;
53+
}
54+
55+
if (opts.listGlobal) {
56+
const entries = listUserConfigConsent(REGISTRY_PATH);
57+
if (opts.json) {
58+
process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
59+
return;
60+
}
61+
if (entries.length === 0) {
62+
process.stdout.write('No repos have a recorded global-config consent decision.\n');
63+
return;
64+
}
65+
process.stdout.write('Global config consent decisions:\n\n');
66+
for (const { path: p, decision } of entries) {
67+
process.stdout.write(
68+
` ${decision === 'enabled' ? '✔' : '✘'} ${decision.padEnd(8)} ${p}\n`,
69+
);
70+
}
71+
return;
72+
}
73+
74+
// ── Explain mode ───────────────────────────────────────────────────
75+
76+
if (opts.explain) {
77+
const { config, provenance, appliedGlobalPath, consentDecision } = loadConfigWithProvenance(
78+
rootDir,
79+
{
80+
userConfig: ctx.program.opts().userConfig,
81+
},
82+
);
83+
const globalPath = resolveUserConfigPath();
84+
const consent = getUserConfigConsent(rootDir);
85+
86+
if (opts.json) {
87+
process.stdout.write(
88+
`${JSON.stringify(
89+
{
90+
config,
91+
provenance,
92+
appliedGlobalPath,
93+
globalFilePath: globalPath,
94+
consentDecision: consentDecision ?? consent ?? 'undecided',
95+
},
96+
null,
97+
2,
98+
)}\n`,
99+
);
100+
return;
101+
}
102+
103+
// Human-readable explain output
104+
process.stdout.write('=== Codegraph config provenance ===\n\n');
105+
106+
const consentStr = consentDecision ?? consent ?? 'undecided';
107+
process.stdout.write(`Global config file : ${globalPath ?? '(none found)'}\n`);
108+
process.stdout.write(`Applied this run : ${appliedGlobalPath ? 'yes' : 'no'}\n`);
109+
process.stdout.write(`Consent for repo : ${consentStr}\n`);
110+
process.stdout.write(
111+
` (change with \`codegraph config --enable-global\` or \`--disable-global\`)\n`,
112+
);
113+
114+
if (!globalPath) {
115+
process.stdout.write(
116+
`\nDiscovery hint: create a global config at ~/.config/codegraph/config.json\n` +
117+
`then run \`codegraph config --enable-global\` in repos where you want it applied.\n`,
118+
);
119+
} else if (!appliedGlobalPath) {
120+
process.stdout.write(
121+
`\nDiscovery hint: global config exists but is not applied to this repo.\n` +
122+
`Run \`codegraph config --enable-global\` to enable it here.\n`,
123+
);
124+
}
125+
126+
process.stdout.write('\n--- Per-key provenance ---\n\n');
127+
const provenanceEntries = Object.entries(provenance).sort(([a], [b]) => a.localeCompare(b));
128+
for (const [key, source] of provenanceEntries) {
129+
process.stdout.write(` ${source.padEnd(8)} ${key}\n`);
130+
}
131+
return;
132+
}
133+
134+
// ── Default: print effective config ────────────────────────────────
135+
136+
const globalPath = resolveUserConfigPath();
137+
const consent = getUserConfigConsent(rootDir);
138+
const config = loadConfig(rootDir, { userConfig: ctx.program.opts().userConfig });
139+
140+
// Print effective config — always JSON; discovery hint only in non-JSON mode
141+
process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
142+
143+
if (!opts.json && globalPath && !consent) {
144+
process.stderr.write(
145+
`\nℹ Global config found at ${globalPath} — not applied to this repo.\n` +
146+
` Run \`codegraph config --enable-global\` to opt in, or\n` +
147+
` \`codegraph config --disable-global\` to dismiss this notice.\n`,
148+
);
149+
}
150+
},
151+
};

src/cli/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import { fileURLToPath, pathToFileURL } from 'node:url';
44
import { Command } from 'commander';
5+
import { setUserConfigOverride } from '../infrastructure/config.js';
56
import { setVerbose } from '../infrastructure/logger.js';
67
import { checkForUpdates, printUpdateNotification } from '../infrastructure/update-check.js';
78
import { ConfigError } from '../shared/errors.js';
@@ -25,9 +26,16 @@ program
2526
.version(pkg.version)
2627
.option('-v, --verbose', 'Enable verbose/debug output')
2728
.option('--engine <engine>', 'Parser engine: native, wasm, or auto (default: auto)', 'auto')
29+
.option('--user-config [path]', 'Apply global user config for this run (optional custom path)')
30+
.option('--no-user-config', 'Skip global user config for this run')
2831
.hook('preAction', (thisCommand) => {
2932
const opts = thisCommand.opts();
3033
if (opts.verbose) setVerbose(true);
34+
// Wire user-config flags into the config loader before any command runs.
35+
// Commander sets opts.userConfig = true (bare flag), a string (path), or undefined.
36+
// opts.userConfig is false when --no-user-config is passed (Commander negation).
37+
const uc = opts.userConfig as string | boolean | undefined;
38+
setUserConfigOverride(uc);
3139
})
3240
.hook('postAction', async (_thisCommand, actionCommand) => {
3341
const name = actionCommand.name();

src/cli/shared/options.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import type { Command } from 'commander';
22
import { loadConfig } from '../../infrastructure/config.js';
3+
import type { CodegraphConfig } from '../../types.js';
34
import type { CommandOpts } from '../types.js';
45

5-
const config = loadConfig(process.cwd());
6+
// Deferred so global --user-config / --no-user-config flags are parsed
7+
// before config is first accessed (Commander parses flags before any command
8+
// action runs, but module-level code executes at import time).
9+
let _config: CodegraphConfig | undefined;
10+
const config: CodegraphConfig = new Proxy({} as CodegraphConfig, {
11+
get(_t, prop: string) {
12+
if (_config === undefined) _config = loadConfig(process.cwd());
13+
return _config[prop as keyof CodegraphConfig];
14+
},
15+
}) as CodegraphConfig;
616

717
/**
818
* Attach the common query options shared by most analysis commands.

0 commit comments

Comments
 (0)