FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(config): user-level (global) config with per-repo consent by carlos-alm · Pull Request #1559 · optave/ops-codegraph-tool · GitHub

feat(config): user-level (global) config with per-repo consent - #1559

Merged
carlos-alm merged 6 commits into
mainfrom
feat/user-level-config-1448
Jun 16, 2026
Merged

feat(config): user-level (global) config with per-repo consent#1559
carlos-alm merged 6 commits into
mainfrom
feat/user-level-config-1448

Conversation

Copy link
Copy Markdown
Contributor

Summary

Implements the global user-level config layer from spec docs/tasks/1448-user-level-config.md.

  • Registry consent store: userConfig.consent section in ~/.codegraph/registry.json (separate from MCP repos, TTL-exempt, cleaned by missing-path pruning)
  • Global config file: resolved via CODEGRAPH_USER_CONFIG → XDG / APPDATA → ~/.config/codegraph/config.json → ~/.codegraph/config.json fallback
  • Consent model (§4.1/4.2): disabled > enabled > appliesTo glob > undecided. Non-interactive contexts never prompt.
  • Layered merge: DEFAULTS → global (if consented) → project → env → secrets. Objects deep-merge, arrays replace.
  • Safety guard: absolute build.dbPath in global file is dropped with a warning.
  • loadConfigWithProvenance: per-key source map (default / user / project / env) for --explain.
  • Config hash invalidation (config_hash in build_meta): full rebuild when build-relevant config changes — closes the pre-existing project-config gap (filed as feat: project-config changes should trigger full rebuild (config-hash invalidation) #1557).
  • Interactive consent prompt: async, TTY + non-CI + build-only, fired before pipeline setup.
  • CLI additions: codegraph config [--explain|--enable-global|--disable-global|--list-global]; global --user-config [path] / --no-user-config flags; lazy config in options.ts.
  • Build pipeline: threads userConfig opt; emits ℹ global config applied notice; stores config_hash in finalize.
  • Tests: 26 new unit tests (config-user.test.ts) + 18 new consent tests in registry.test.ts
  • Docs: configuration.md global config section + README pointer

No Rust changes

Native engine is a pure consumer — it receives loadConfig(rootDir) via JSON in the TS pipeline. The merge happens once in TS and reaches native automatically. Per CLAUDE.md and spec §9: no Rust changes, no two-source divergence risk.

Test plan

  • npm run lint — clean
  • npx vitest run tests/unit/config.test.ts — 54 pass
  • npx vitest run tests/unit/config-user.test.ts — 26 pass (new)
  • npx vitest run tests/unit/registry.test.ts — 51 pass (18 new)
  • npx vitest run tests/unit/index-exports.test.ts — 3 pass
  • Full unit suite: 1112 pass
  • Engine parity test (§14.3) — runs in CI via build-parity.test.ts

Deferred (per spec §16)

Closes #1448

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
carlos-alm force-pushed the feat/user-level-config-1448 branch from 7881737 to 6c03cee Compare June 16, 2026 06:17

github-actions Bot commented Jun 16, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

43 functions changed128 callers affected across 92 files

  • command.execute in src/cli/commands/build.ts:17 (0 transitive callers)
  • execute in src/cli/commands/build.ts:17 (0 transitive callers)
  • command.execute in src/cli/commands/config.ts:26 (0 transitive callers)
  • execute in src/cli/commands/config.ts:26 (0 transitive callers)
  • get in src/cli/shared/options.ts:11 (0 transitive callers)
  • checkEngineSchemaMismatch in src/domain/graph/builder/pipeline.ts:85 (4 transitive callers)
  • setupPipeline in src/domain/graph/builder/pipeline.ts:171 (5 transitive callers)
  • buildGraph in src/domain/graph/builder/pipeline.ts:382 (5 transitive callers)
  • handleFullBuild in src/domain/graph/builder/stages/detect-changes.ts:494 (3 transitive callers)
  • persistBuildMetadata in src/domain/graph/builder/stages/finalize.ts:78 (3 transitive callers)
  • setUserConfigOverride in src/infrastructure/config.ts:185 (1 transitive callers)
  • resolveUserConfigPath in src/infrastructure/config.ts:212 (29 transitive callers)
  • ParsedUserConfig.globalConfig in src/infrastructure/config.ts:248 (0 transitive callers)
  • ParsedUserConfig.appliesToGlobs in src/infrastructure/config.ts:249 (0 transitive callers)
  • loadUserConfigFile in src/infrastructure/config.ts:257 (80 transitive callers)
  • sanitizeUserLayer in src/infrastructure/config.ts:283 (76 transitive callers)
  • applyExcludeTestsShorthand in src/infrastructure/config.ts:304 (76 transitive callers)
  • ConsentResolutionResult.applied in src/infrastructure/config.ts:328 (0 transitive callers)
  • ConsentResolutionResult.globalPath in src/infrastructure/config.ts:329 (0 transitive callers)
  • ConsentResolutionResult.consentDecision in src/infrastructure/config.ts:330 (0 transitive callers)

greptile-apps Bot commented Jun 16, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR implements the user-level (global) config layer from spec #1448: a ~/.config/codegraph/config.json file that merges below project config, gated by per-repo consent stored in registry.json. All three TOCTOU/stale-state issues flagged in the previous review round are addressed in the current commit via _lastAppliedGlobalConfig caching and pre-cache-hit state updates.

  • Consent model: disabled > enabled > appliesTo glob > undecided; non-interactive/CI contexts never prompt; per-run --user-config / --no-user-config flags bypass the registry entirely.
  • Config hash invalidation: computeConfigHash over build-relevant keys is stored in build_meta.config_hash and compared on every build, closing the pre-existing config-change gap (feat: project-config changes should trigger full rebuild (config-hash invalidation) #1557). The detect-changes.ts full-rebuild SQL also now clears file_hashes, which was previously omitted.
  • Lazy config in options.ts: the eager module-level loadConfig call is replaced with a Proxy so global CLI flags are wired in before first access.

Confidence Score: 5/5

Safe to merge; the consent model is opt-in by design, no existing build behaviour changes for repos that never touch the new flags.

All three issues from the prior review round are demonstrably fixed. The consent-aware cache key (cwd::globalPath) means a newly-recorded consent decision is never served stale from cache. The only new findings are a misleading 'undecided' label in --explain output for appliesTo-matched repos, and a minor TypeScript type gap in the Proxy trap — neither affects correctness or security.

src/infrastructure/config.ts — the resolveConsent appliesTo branch and the --explain UX gap are worth a follow-up; everything else is straightforward.

Important Files Changed

Filename Overview
src/infrastructure/config.ts Core of the PR — adds global config resolution, consent model, config hash, and lazy cache; all three previously-reviewed TOCTOU/stale-state issues are addressed. Minor UX gap: resolveConsent returns consentDecision: undefined for both "undecided" and "auto via appliesTo", making --explain output ambiguous for the latter case.
src/cli/shared/options.ts Replaced eager module-level loadConfig call with a lazy Proxy so CLI flags are honoured before first config access. Proxy get trap uses prop: string where the spec requires `string
src/cli/commands/config.ts New codegraph config command implementing --explain, --enable-global, --disable-global, --list-global; consistent with the consent model.
src/domain/graph/builder/pipeline.ts Threads userConfig opt into loadConfig, adds consent prompt before pipeline setup, and emits a build-time notice using the pre-parsed cached global config.
src/domain/graph/builder/stages/detect-changes.ts Adds DELETE FROM file_hashes to the full-rebuild statement — previously omitted, stale hash entries could cause the next incremental pass to skip files that should be reprocessed.
src/domain/graph/builder/stages/finalize.ts Persists config_hash in build metadata for both native and JS-side DB paths.
src/infrastructure/registry.ts Adds userConfig.consent section with CRUD helpers and pruning for stale paths; TTL-exempt and isolated from the existing repos section.

Reviews (5): Last reviewed commit: "Merge remote-tracking branch 'origin/fea..." | Re-trigger Greptile

Comment thread src/infrastructure/config.ts Outdated
Comment on lines +511 to +514
const cached = _configCache.get(cacheKey);
if (cached) return structuredClone(cached);

_lastAppliedGlobalPath = applied ? globalPath : null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

_lastAppliedGlobalPath is only updated on a cache miss. On a cache hit, getLastAppliedGlobalPath() in setupPipeline returns the value written by whatever previous loadConfig call last missed the cache — which may belong to a different repo or different opts. In programmatic usage (multiple buildGraph calls in the same process, a common pattern since buildGraph is a public API), the build-time "global config applied" notice will show or be suppressed based on stale state from the prior call. The fix is to update _lastAppliedGlobalPath before the early-return on a cache hit.

Suggested change
const cached = _configCache.get(cacheKey);
if (cached) return structuredClone(cached);
_lastAppliedGlobalPath = applied ? globalPath : null;
const cached = _configCache.get(cacheKey);
if (cached) {
_lastAppliedGlobalPath = applied ? globalPath : null;
return structuredClone(cached);
}
_lastAppliedGlobalPath = applied ? globalPath : null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Fixed — _lastAppliedGlobalPath (and the new _lastAppliedGlobalConfig) are now set before the cache-hit early-return, so every call to loadConfig — whether it hits or misses the cache — reflects the current repo/opts. Commit f32fde4.

Comment thread src/infrastructure/config.ts Outdated
Comment on lines +586 to +622
let globalRaw: Record<string, unknown> | null = null;
if (applied && globalPath) {
const userFileData = loadUserConfigFile(globalPath);
if (userFileData) {
globalRaw = sanitizeUserLayer(userFileData.globalConfig);
for (const k of Object.keys(globalRaw)) provenance[k] = 'user';
}
}

// Layer 2: project
let projectRaw: Record<string, unknown> | null = null;
for (const name of CONFIG_FILES) {
const filePath = path.join(cwd, name);
if (fs.existsSync(filePath)) {
try {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
projectRaw = raw;
for (const k of Object.keys(raw)) provenance[k] = 'project';
break;
} catch {
// ignore
}
}
}

// Layer 3+: env overrides (LLM keys)
const ENV_LLM_KEYS = ['CODEGRAPH_LLM_PROVIDER', 'CODEGRAPH_LLM_API_KEY', 'CODEGRAPH_LLM_MODEL'];
if (ENV_LLM_KEYS.some((k) => process.env[k] !== undefined)) {
provenance.llm = 'env';
}

void globalRaw;
void projectRaw; // used for provenance tracking above

const config = loadConfig(cwd, opts);
return { config, provenance, appliedGlobalPath: applied ? globalPath : null, consentDecision };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Double global-file read + TOCTOU window in loadConfigWithProvenance

loadConfigWithProvenance reads the global config file once here to build the provenance map, then delegates to loadConfig which reads the same file a second time to build the merged config. If the file changes on disk between the two reads (e.g., during a slow build on a shared machine), the provenance map and the returned config will disagree about which keys came from the user layer — the --explain output would be misleading. Extracting a loadConfigFromLayers(...) helper that returns both the merged result and the per-layer raw data would eliminate the duplicate I/O and the window entirely.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Fixed — loadConfigWithProvenance now calls loadConfig first (which populates _lastAppliedGlobalConfig), then reads from that cache for the global-layer provenance walk instead of re-opening the file. The new getLastAppliedGlobalConfig() getter is also used in pipeline.ts for the build notice. Commit f32fde4.

Comment on lines +205 to +232
// ── Build-time global-config notice ──────────────────────────────
const appliedGlobalPath = getLastAppliedGlobalPath();
if (appliedGlobalPath) {
const buildAffectingKeys = [
'include',
'exclude',
'ignoreDirs',
'extensions',
'aliases',
'build',
];
try {
const raw = JSON.parse(fs.readFileSync(appliedGlobalPath, 'utf-8')) as Record<
string,
unknown
>;
const globalData: Record<string, unknown> =
'appliesTo' in raw && raw.config ? (raw.config as Record<string, unknown>) : raw;
const injectedKeys = buildAffectingKeys.filter((k) => k in globalData);
if (injectedKeys.length > 0) {
process.stderr.write(
`ℹ global config applied (${appliedGlobalPath}) — injecting: ${injectedKeys.join(', ')} · --no-user-config to ignore\n`,
);
}
} catch {
// Non-critical — skip on any read error
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Global config file read a second time for the build notice

loadConfig (line 191) already parses and caches the global file; getLastAppliedGlobalPath() returns the path. But the notice code at line 217 opens and parses the same file a second time via fs.readFileSync. Besides the redundant I/O, there is a small TOCTOU window: if the file is replaced between the loadConfig call and this read, the injected-keys list shown in the notice could differ from the keys that are actually in the merged config. The already-loaded ParsedUserConfig data (from inside loadConfig) is not surfaced; consider exposing it alongside the path via getLastAppliedGlobalPath or returning it from loadConfig as optional metadata.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Fixed — the build notice in setupPipeline now reads from getLastAppliedGlobalConfig() (the sanitized data cached by loadConfig) instead of re-reading the file via fs.readFileSync. This eliminates the redundant I/O and the TOCTOU window. Commit f32fde4.

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.
- 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).

Copy link
Copy Markdown
Contributor Author

Addressed all Greptile review feedback:

  • P1: _lastAppliedGlobalPath stale on cache hit — moved the assignment before the early-return on cache hits; also added _lastAppliedGlobalConfig which is updated at the same point. (comment #3418566672)

  • P2: Double global-file read + TOCTOU in loadConfigWithProvenance — loadConfigWithProvenance now calls loadConfig first (populating _lastAppliedGlobalConfig), then reuses that cached data for the global-layer provenance walk. No second disk read. (comment #3418566729)

  • P2: Second global file read for build notice in pipeline.ts — replaced the fs.readFileSync in the build notice with getLastAppliedGlobalConfig(), the already-parsed data from loadConfig. (comment #3418566865)

  • P2: Confusing void suppression no-ops — removed both void globalRaw; void projectRaw; lines, which were redundant since both variables are read earlier in the same function. (outside-diff comment)

  • P2: Redundant --json branch in default case — unified both branches into a single process.stdout.write; the stderr discovery hint is now suppressed when --json is passed. (outside-diff comment)

Also fixed two CI test failures:

  • Windows: resolveUserConfigPath now checks XDG_CONFIG_HOME first on all platforms (was Windows-only ignored).
  • All platforms: handleFullBuild now includes DELETE FROM file_hashes so excluded files don't linger in the DB after a config-triggered full rebuild.

Copy link
Copy Markdown
Contributor Author

@greptileai

Copy link
Copy Markdown
Contributor Author

Addressed the correctness gap Greptile flagged in the summary: _lastAppliedGlobalConfig was reset to null before the cache check but never restored on a cache hit, so any programmatic caller that invoked loadConfig before loadConfigWithProvenance got a provenance map with no user attribution for global-layer keys.

Fix (commit fa7dc18): added a parallel _globalConfigCache map keyed by the same cache key. On cache save, both caches are written together. On cache hit, _lastAppliedGlobalConfig is restored from _globalConfigCache before returning — so loadConfigWithProvenance always sees the correct global layer regardless of call order. Both clearConfigCache and setUserConfigOverride clear the parallel cache too. A new unit test (returns correct user provenance even when loadConfig was called first) guards this path.

Copy link
Copy Markdown
Contributor Author

@greptileai

carlos-alm merged commit 4d8f09e into main Jun 16, 2026
23 checks passed
carlos-alm deleted the feat/user-level-config-1448 branch June 16, 2026 10:15
github-actions Bot locked and limited conversation to collaborators Jun 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Support global user-level config merged with project config

1 participant


Back | FazBrowse Home | New Git URL