fix(#3532): warn when global defaults keys are shadowed by a project config
This commit is contained in:
5
.changeset/graceful-seals-march.md
Normal file
5
.changeset/graceful-seals-march.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 0
|
||||
---
|
||||
**`~/.gsd/defaults.json` shadowing is now diagnosed instead of silent** — in any project with a `.planning/config.json`, global model-side keys (`model_profile`, `model_overrides`, `models`, `dynamic_routing`, `runtime`, …) were silently ignored for model resolution; a file named `defaults.json` applied to no real project with no signal. GSD now prints a one-time stderr warning naming the shadowed keys. Resolution precedence is unchanged; global `effort` keeps working via effort sync and never warns. (#3532)
|
||||
@@ -1744,6 +1744,22 @@ Save settings as global defaults for future projects:
|
||||
|
||||
When `/gsd-new-project` creates a new `config.json`, it reads global defaults and merges them as the starting configuration. Per-project settings always override globals.
|
||||
|
||||
### What a global file can and cannot set at runtime
|
||||
|
||||
Two different rules apply, and the difference is deliberate ([#3532](https://github.com/open-gsd/gsd-core/issues/3532)):
|
||||
|
||||
- **In a directory with no `.planning/` at all**, `~/.gsd/defaults.json` is the active
|
||||
configuration — model resolution reads it directly.
|
||||
- **In a real project (`.planning/config.json` present, even if empty)**, the global file is
|
||||
**not read for model resolution** — every model-side key it sets (`model_profile`,
|
||||
`model_overrides`, `models`, `dynamic_routing`, `runtime`, and the rest of the resolution
|
||||
set) is inert there. GSD prints a one-time stderr warning naming the shadowed keys when it
|
||||
detects this, instead of failing silently. To apply a global model setting to a project,
|
||||
put it in that project's `.planning/config.json`.
|
||||
- **`effort` is the exception**: the install-time effort channel always merges
|
||||
`~/.gsd/defaults.json` with the project config (that is how `effort sync` works), so a
|
||||
global `effort` block keeps working in projects and does not trigger the warning.
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
@@ -317,6 +317,49 @@ function _resetRuntimeWarningCacheForTests(): void {
|
||||
_warnedConfigKeys.clear();
|
||||
_warnedUnknownConfigKeys.clear();
|
||||
_warnedUnusableConfig.clear();
|
||||
_warnedShadowedGlobalKeys.clear();
|
||||
}
|
||||
|
||||
// ─── #3532 (10b): shadowed global-defaults diagnostic ────────────────────────
|
||||
|
||||
// The keys Branch D's `_globalBaseCfg` demonstrably honors from
|
||||
// ~/.gsd/defaults.json when no project config exists. Under a project
|
||||
// .planning/config.json (Branch A — every real project) the global file is
|
||||
// never opened, so each of these set globally is silently inert for resolution.
|
||||
// `effort` is in Branch D's honored set but is EXCLUDED from the shadow warning:
|
||||
// the install-time effort sync (readGsdEffectiveEffortConfig) DOES merge the
|
||||
// global file, so warning on it would be false for the channel users actually
|
||||
// control via `effort sync`. Keep this list in lockstep with `_globalBaseCfg`
|
||||
// below — the per-key canary in tests/config-loader.test.cjs fails first on
|
||||
// drift in either direction.
|
||||
const GLOBAL_DEFAULTS_RESOLUTION_KEYS = [
|
||||
'model_profile', 'commit_docs', 'research', 'plan_checker', 'verifier',
|
||||
'nyquist_validation', 'post_planning_gaps', 'parallelization', 'text_mode',
|
||||
'resolve_model_ids', 'context_window', 'subagent_timeout', 'model_overrides',
|
||||
'models', 'granularity', 'granularities', 'planning', 'dynamic_routing',
|
||||
'effort', 'fast_mode', 'agent_skills', 'response_language', 'runtime',
|
||||
'model_profile_overrides', 'model_policy',
|
||||
];
|
||||
|
||||
// Module-level dedup keyed on the sorted shadowed-key set: a later call with
|
||||
// the same shadowed set stays quiet, while a config that grows a new shadowed
|
||||
// key re-arms the warning (same discipline as _warnedUnknownConfigKeys).
|
||||
const _warnedShadowedGlobalKeys = new Set<string>();
|
||||
|
||||
function _warnShadowedGlobalDefaults(globalDefaults: Record<string, unknown>, globalPath: string): void {
|
||||
const shadowed = GLOBAL_DEFAULTS_RESOLUTION_KEYS.filter(k =>
|
||||
k !== 'effort' && Object.prototype.hasOwnProperty.call(globalDefaults, k));
|
||||
if (shadowed.length === 0) return;
|
||||
const dedupKey = shadowed.slice().sort().join(',');
|
||||
if (_warnedShadowedGlobalKeys.has(dedupKey)) return;
|
||||
_warnedShadowedGlobalKeys.add(dedupKey);
|
||||
try {
|
||||
process.stderr.write(
|
||||
`gsd-tools: warning: ${globalPath} sets ${shadowed.join(', ')} but this project's ` +
|
||||
`.planning/config.json takes precedence — those global keys are ignored for model ` +
|
||||
`resolution here. (#3532)\n`,
|
||||
);
|
||||
} catch { /* stderr might be closed in some test harnesses */ }
|
||||
}
|
||||
|
||||
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
|
||||
@@ -836,6 +879,22 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
|
||||
// Fix 4: empty-string ws ('') resolves the root path → source:'root'.
|
||||
const source: ConfigSource = wsRequested ? 'workstream' : 'root';
|
||||
|
||||
// #3532 (10b): a parsed project config means Branch D never runs, so every
|
||||
// key ~/.gsd/defaults.json sets that Branch D would honor is silently inert
|
||||
// here. Observation only — one deduped stderr warning; precedence is
|
||||
// untouched. Faults in the global file stay silent in this branch (the
|
||||
// project config governs; the nearer file is the actionable one).
|
||||
try {
|
||||
const shadowHome = process.env['GSD_HOME'] || os.homedir();
|
||||
const shadowPath = path.join(shadowHome, '.gsd', 'defaults.json');
|
||||
const shadowRead = _readConfigFile(shadowPath);
|
||||
if (shadowRead.kind === 'ok') {
|
||||
_warnShadowedGlobalDefaults(shadowRead.data, shadowPath);
|
||||
}
|
||||
} catch {
|
||||
// Observation only — never let the diagnostic perturb resolution.
|
||||
}
|
||||
|
||||
// This config parsed — but a DIFFERENT file on the resolution path may not
|
||||
// have. A workstream config that loads cleanly while the root config it
|
||||
// inherits from is corrupt is still a degraded resolution: the root's
|
||||
@@ -965,6 +1024,8 @@ export = {
|
||||
_getNestedConfigDefault,
|
||||
_deepMergeConfig,
|
||||
_warnedUnknownConfigKeys,
|
||||
_warnedShadowedGlobalKeys,
|
||||
GLOBAL_DEFAULTS_RESOLUTION_KEYS,
|
||||
_warnUnknownProfileOverrides,
|
||||
_resetRuntimeWarningCacheForTests,
|
||||
_warnedConfigKeys,
|
||||
|
||||
Reference in New Issue
Block a user