* fix(#1882): distinguish unterminated frontmatter from absent frontmatter
extractFrontmatter returned {} both for a document with no frontmatter and for
one whose fence was opened and never closed, so a file truncated mid-write was
byte-identical to a legitimate no-metadata file. Verified live through
`gsd-tools frontmatter get`: both printed {} with exit 0 and nothing on stderr.
Per ADR-1411's "corrupt is not absent" amendment the {} return is preserved
exactly -- no caller may break -- and the cause is surfaced out-of-band as a
deduplicated, unconditional stderr diagnostic. That mechanism lands as a shared
leaf module rather than a per-site copy because three sibling findings in the
same epic need it identically; four hand-rolled copies of one behaviour is the
generative-fix-divergence defect class.
The discriminator is deliberately not "opened but never closed". A Markdown
document whose first line is a thematic break takes that exact branch, so
flagging on the missing fence alone reports corruption on good Markdown -- the
failure mode this class of check has shipped with before. The unterminated
region is instead run through extractFrontmatter's own parser (extracted as
parseYamlRegion so the probe and the real parse can never diverge) and reported
only when it yields at least one key.
Also folds an inline defect found while working: src/config-loader.cts carried
two NUL bytes in the JSDoc added by this epic's Phase 1 (3eb1cede2), making it
the only non-text file under src. file(1) reported it as data and text tools
silently skipped it, defeating the audit rule that says to search the authored
source; tsc passed because the bytes sat inside a comment, so no gate caught it.
It is live on next.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(#1882): pin unterminated-frontmatter detection and its negative space
Covers the discriminator on both sides. The positive rows are the issue's own
repro (LF and CRLF) plus the key-count boundary 0/1/2 around the ">= 1 parsed
key" threshold. The negative rows are the documents that reach the same branch
and must stay silent -- above all a Markdown thematic break at byte 0, which is
how this class of check has previously shipped a false positive on valid
Markdown.
Deduplication is tested on both halves of the composite key: a repeat of the
same (path, cause) is suppressed, a genuine second failure in a different file
is not, and a Windows and POSIX spelling of one path resolve to a single key.
The reset seam is asserted to actually clear -- #2674 is the precedent where a
reset that silently failed to clear made every later dedup assertion a vacuous
pass, and the cases only passed because each happened to pick an unused key, so
every case here uses a path unique to itself.
Assertions are on typed surfaces throughout -- the frozen reason enum and the
dedup-set size -- never on diagnostic prose. The one CLI-level case asserts a
differential between two runs (whether stderr is empty) rather than matching a
message, and is the wired user-reachable surface for this fix. Stream failure is
injected by overriding process.stderr.write and restoring it, never chmod 0o000,
which root bypasses.
Two properties guard the ~50 call sites of the changed function: the new
optional path argument is inert with respect to the parsed value, and LF/CRLF
spellings of a document still parse identically.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#1882): raise the truncation threshold and repair the dedup key
Isolated adversarial review found the one-key discriminator false-positives on
ordinary Markdown: a thematic break above a single labelled line -- `Note:`,
`Author:`, `TODO:`, `See:` -- parses as exactly one key and was reported as
corruption, which is the precise failure the design claimed to prevent and the
changeset promised was fixed. The threshold is now two keys. A file truncated
after exactly one key becomes a false negative; that is the same
precision-over-recall direction already taken at zero keys, and every GSD
artefact this guards carries two or more frontmatter keys.
Three dedup-key defects, each of which could silently swallow a real diagnostic:
- Backslash normalization is removed. A backslash is a legal filename character
on Linux and macOS, so folding it to a forward slash made two genuinely
different files share one key. Two spellings of one Windows path may now
report twice; two distinct files can never silence each other. Lost signal is
the worse failure.
- The key namespaces are tagged so a file literally named like the unnamed
digest fallback can no longer collide with a path-less caller whose content
hashes to that digest -- computable for any predictable content, no brute
force needed.
- The source identity is computed once rather than hashed twice per emission.
Corrects the previous commit. The two NUL bytes in src/config-loader.cts were
NOT in a JSDoc comment as that message claimed; they were deliberate separators
in the live dedup key, and stripping them degraded it to bare concatenation.
They are restored as escape sequences -- byte-identical runtime string, and the
file is text again so grep can see it. The diagnostic script that misled me
indexed a character-offset string with a byte offset.
Also threads sourcePath through the STATE.md and PLAN.md readers so the two
artefacts epic #1879 is actually about name their file rather than reporting
under a content digest.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(#1882): correct fixtures and assertions left behind by the review fixes
The previous commit changed two behaviours deliberately and the suite still
encoded the old ones, so gsd-test came back red with six failures across both
lanes -- all of them mine.
Fixtures carrying a single frontmatter key no longer clear the two-key
truncation threshold, so the CLI differential and the two path-less dedup cases
were asserting a diagnostic that is now correctly withheld. They now carry two
keys, which is what a real interrupted write of a GSD artefact looks like.
The Windows/POSIX case asserted that two spellings of one path collapse to a
single key -- the exact folding that was removed because it also collapsed
genuinely distinct POSIX files whose names contain a backslash. Inverted to
assert they now report separately, with the reasoning recorded inline so the
trade is not silently reversed later: mild duplicate noise on one Windows path
is acceptable, a swallowed diagnostic is not.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#1882): name the file at every read site, and report each file once
The diagnostic reached only the four frontmatter CLI verbs, so ~47 of 53 call
sites reported a truncated file under an anonymous content digest instead of
naming it. Since naming the file is the whole point -- it is what an operator
can act on -- that was a gap in the deliverable, not a scoping choice. 43 of 53
sites now pass the resolved path.
Closing it surfaced a defect the original design missed. A single truncated
STATE.md is parsed twice in a normal run: once by the read wrapper, which holds
the path, and again by a pure core downstream, which is handed only the string
and cannot know it. Those two parses keyed separately, so one file produced two
diagnostics -- and wiring more sites made the collision more likely, not less.
Every emission now registers both identities the input could be known by and
checks both before writing, so whichever caller arrives first speaks and the
other is suppressed. Distinct files with distinct content still report
separately, which is the property ADR-1411 actually requires; two files whose
truncated content is byte-identical collapse to one report, which stays the
documented limit.
Ten call sites deliberately keep no path. Two are frontmatter's own round-trip
checks during set and merge, where passing a path would report on every write.
The other eight are the state-transition pure cores, which ADR-1769 defines as
(content, intent, deps) -> newContent with injected I/O; threading a path
through them would contradict that recorded decision, so it is surfaced rather
than taken unilaterally. With the widened key they no longer double-report, and
in the normal flow the named parse runs first, so the file is still named.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#1882): inject the STATE.md path into the transition cores
The six state-transition cores parsed STATE.md frontmatter without knowing
which file it came from, so a truncated STATE.md reached the operator as an
anonymous content digest on exactly the artefact epic #1879 is named for.
ADR-1769 section 3 shapes these as (content, intent, deps) -> newContent with
injected deps, and deps is the seam for precisely this: something the core
cannot derive without doing I/O. It already carries roadmapProvider and a
phase-inventory provider on that basis, each documented as injected rather than
imported so the core stays pure and testable without disk access. A resolved
path is data, not I/O, so an optional sourcePath member extends the established
pattern rather than contradicting it, and every existing stub keeps compiling
because the member is optional.
updateCore and reconcileCurrentPosition take no deps and are left alone. With
the widened dedup key they cannot double-report, and in the normal flow the read
wrapper has already named the file by the time they run.
Also regenerates gsd-core/bin/lib/state-transition.cjs. That artifact is tracked
rather than gitignored, unlike most of its siblings, so leaving it stale would
have shipped a runtime without this change to anyone reading the repo without
building. tsc had skipped the re-emit because its incremental build info still
recorded an emit that had since been reverted, so the stale output survived a
clean build; clearing tsconfig.build.tsbuildinfo forced it. The
compiled-artifact-sync gate is what surfaced the drift and now reports all nine
tracked artifacts matching their source.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#1882): stop the widened dedup key from hiding a second file
The previous commit widened the dedup guard so one file parsed twice -- once by
a read wrapper holding the path, once by a pure core holding only the string --
reported once instead of twice. It did that by checking BOTH keys before
emitting, which silently traded one defect for a worse one: two DIFFERENT files
whose truncated content happened to be byte-identical now collided on the shared
content digest, and the second file's diagnostic was swallowed. That is the
over-coarse keying ADR-1411 explicitly forbids, reintroduced while fixing
something else.
The guard now checks only the key matching what the caller actually knows -- a
named read checks its path key, a path-less read checks its digest key -- while
still recording every key the input could later be identified by. The redundant
path-less re-parse of an already-named file stays silent, and two distinct files
always both report.
Verified across all six orderings: same file named-then-anonymous reports once;
two different files with identical content report twice; two different files
with different content report twice; the same path twice reports once; two
path-less parses of identical content report once; two path-less parses of
different content report twice.
The suite caught this -- twenty failures, all in the unusable-input tests that
reuse one truncated fixture across different paths. The local probe written
alongside the broken change did not, because it compared two files with
different content and could therefore only confirm the expected behaviour.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(#1882): count diagnostics emitted, not identities interned
The suite measured the size of the dedup set as a stand-in for "how many
diagnostics were emitted". That held only while one emission recorded exactly
one key. Once an emission began recording every identity the input could later
be matched by -- a path key and a content key for the same file -- the set grew
by two per write and twenty assertions read 2 where they expected 1.
The production behaviour was correct throughout; the proxy was not. Set size
counts identities, which is an implementation detail of the guard. The
behavioural claim these tests exist to make is how many diagnostics an operator
actually saw, so the module now exposes that directly as an emission counter and
the suite asserts on it. The set-size accessor stays for assertions genuinely
about key shape.
The local probe written alongside the change did not catch this because it
counted process.stderr.write calls -- the right thing -- while the suite counted
set growth. Verification now asserts both and requires them to agree, so a
future divergence between the counter and real writes fails immediately rather
than being discovered a bench run later.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(#1882): retire two assertions that outlived the behaviour they described
Both tests encoded assumptions the dedup fix invalidated, and both were caught
by the suite rather than by the probe written alongside the change.
The forged-path case asserted that a file named like the anonymous digest
fallback must not suppress a later path-less report. That premise is gone: an
emission now records every identity its input could be matched by, so ANY named
report of some content silences the anonymous re-parse of that same content --
which is the same-file guard working as intended, and has nothing to do with the
crafted name. The property still worth defending is that a crafted filename can
never silence a real file reported under its own path, so that is what the test
now asserts, with the deliberate suppression documented beside it.
The reset-seam case ended by reading the size of the dedup set and expecting 1.
Set size counts interned identities, not diagnostics written, and one emission
now interns two. It asserts the emission counter for the event and keeps a
weaker set-size check for the interning.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(#1882): close the review findings on the discriminator, dry-run and counter
Three orthogonal review passes ran against the final diff. Their findings:
A labelled preamble under a leading rule was still misreported. Raising the key
threshold to two only moved the boundary, because two colon-labelled lines are
as common in ordinary prose as one -- a document opening with a rule over an
Author and a Reviewed-by line, then prose, was called corrupt. Key count alone
cannot separate the two. What does is what follows: a write interrupted part way
through a frontmatter block ends mid-block, so every line of the region is still
frontmatter-shaped, whereas a document merely opening with a rule goes on to
prose. Both conditions are now required, and each closes a false-positive class
the other leaves open. Nested list values and indented continuations stay
frontmatter-shaped, so legitimate truncations are unaffected.
`state rebuild --dry-run` reported a truncated STATE.md anonymously. The write
path is named only because readModifyWriteStateMd parses with the path first;
the dry-run branch reads the file directly and never did. Dry-run is the
read-only mode an operator reaches for first when they suspect corruption, so it
is the one that most needed to name the file. reconcileCurrentPosition takes the
path as an optional argument now and rebuildCore passes it down. That function
was previously left alone on the grounds that a read wrapper always names the
file first -- this is the flow that disproves it.
The emission counter counted write attempts rather than writes, so on a broken
stderr it claimed a diagnostic had reached the operator when nothing had. It is
incremented only after a write that completed, and the broken-stderr test now
asserts the count as well as the return value.
Two documentation defects. The module described a guarantee it does not keep:
one file yields one diagnostic only when the named read comes first. The reverse
ordering emits twice, and that is deliberate -- a path-less caller cannot
identify its file, so suppressing the later named report would also suppress a
genuine second failure in a different file whenever two files share identical
truncated bytes, which ADR-1411 ranks the worse failure. The comment now states
the asymmetric guarantee and a test pins it. Separately, the CONTEXT.md glossary
entry still described backslash normalization that a later commit removed, and
asserted the opposite of what the tests pin; no lint checks prose against code,
so nothing caught it.
Also converts three body-level try/finally blocks to t.after(), per
CONTRIBUTING.md's rule that try/finally belongs only in helpers with no test
context -- the file's own emissionsDuring helper already did this correctly.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(#1882): tell the operator what the truncated-frontmatter warning means
A user who has just seen the new warning is acting, not studying, so this lands
in the How-To quadrant beside the other "if you see X" branches in
debug-a-failed-execution, not in reference or explanation. It gives them what
the warning means for this run, three steps to restore the file, and the fact
that the warning changes no return value or exit code.
It also states the case that matters more than the warning itself: silence does
not prove the file is intact. GSD says nothing when the partial block carries
fewer than two fields or reads as prose, because a Markdown document opening
with a horizontal rule is indistinguishable from one of those. A reader chasing
missing metadata needs to know not to treat quiet as clean. Why that threshold
exists is explanation and deliberately stays out of a how-to.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(#1882): backfill changeset pr number to 2712
* test(#1882): constrain each branch of the frontmatter-shape check
CI's mutation gate came in at 61.56 against a threshold of 62, and the surviving
mutants were concentrated in isFrontmatterShaped -- the function added last, in
response to review, and the only one never given tests of its own. It was
exercised solely through extractFrontmatter, which covers the composite decision
but leaves each branch of the predicate unconstrained: drop the blank-line
filter, or any one of the three shape alternatives, and every existing assertion
still passed.
Four cases now pin the halves independently. A blank line inside an interrupted
block must not disqualify it, which constrains the filter and its comparison. An
unindented list item and an indented folded-scalar continuation each exercise one
shape alternative that no other case reaches on its own -- the folded line is
neither a key nor a list item, so it is the only input that distinguishes the
indented branch. And two keys followed by prose must stay silent, which is the
negative half: it fails if the predicate is ever mutated to accept everything,
and it is the case that proves key count alone was never sufficient.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(#1882): register the unusable-input suite with the frontmatter mutation shard
The mutation gate reported an identical 61.56 across two runs whose only
difference was four added tests. That is the tell: the tests were never
executed. The frontmatter shard runs a fixed file list in stryker.config.mjs and
scripts/mutation-matrix.cjs, and tests/unusable-input.test.cjs was in neither, so
the entire suite covering the new unterminated-fence branch was invisible to the
gate while passing perfectly well in the normal run.
So the score was not measuring weak tests, it was measuring absent ones: #1882
added mutants to frontmatter.cjs and no test in the shard covered them. Both
lists gain the file; the config already notes they must stay in sync.
This is a registration ripple a new test file carries when it covers a
mutation-tracked module, alongside the .gitignore, eslint, inventory, glossary
and size-baseline ripples a new module carries. Nothing warned about it, which
is why two runs were spent before the identical score gave it away.
Refs #1879
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
975 lines
48 KiB
TypeScript
975 lines
48 KiB
TypeScript
/**
|
|
* Config Loader — Project configuration loading
|
|
*
|
|
* ADR-857 rollout phase 2e: extracted from core.cts (issue #885).
|
|
* Owns project configuration loading: reads `.planning/config.json`,
|
|
* merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`),
|
|
* normalizes legacy keys, applies the active-workstream overlay, validates
|
|
* against the config schema, and warns on unknown keys/profile overrides.
|
|
* Behaviour is preserved byte-for-behaviour from the prior location; only
|
|
* the module boundary moved. The core.cjs re-export spine was retired in
|
|
* epic #1267; callers import loadConfig from config-loader.cjs directly.
|
|
*
|
|
* Dependencies (leaf modules only):
|
|
* - node:fs / node:os / node:path (stdlib)
|
|
* - ./configuration.cjs (normalizeLegacyKeys, CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
|
|
* - ./config-schema.cjs (VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS)
|
|
* - ./planning-workspace.cjs (planningDir, planningRoot)
|
|
* - ./shell-command-projection.cjs (execGit, platformWriteSync, platformReadSync)
|
|
* - ./core-utils.cjs (detectSubRepos)
|
|
* - ./model-catalog.cjs (KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES)
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
const { planningDir, planningRoot } = planningWorkspace;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import coreUtilsModule = require('./core-utils.cjs');
|
|
const { detectSubRepos } = coreUtilsModule;
|
|
// ─── Configuration Module (generated CJS mirror) ────────────────────────────
|
|
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import configSchema = require('./config-schema.cjs');
|
|
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
|
|
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS, ADAPTIVE_TIER_VALUES } from './model-catalog.cjs';
|
|
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import federatedConfigModule = require('./federated-config.cjs');
|
|
const { mergeFederatedConfig } = federatedConfigModule;
|
|
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
|
|
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
|
|
// This is the FROZEN first-party registry — used as the test-seam default and the
|
|
// fallback. Overlay (installed third-party) config-key federation is cwd-dependent
|
|
// and composed PER loadConfig CALL by _federatedConfigSchema(cwd) below (ADR-1244 D2),
|
|
// never eagerly at module load.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
|
|
|
|
// Module-level registry reference. Defaults to the real generated registry.
|
|
// Overridable for tests via _setFederatedRegistryForTests.
|
|
let _capabilityRegistry: { configSchema?: Record<string, unknown> } = _capabilityRegistryReal;
|
|
|
|
/** Test-only seam: inject a synthetic registry. Call _resetFederatedRegistryForTests() to restore. */
|
|
function _setFederatedRegistryForTests(reg: { configSchema?: Record<string, unknown> }): void {
|
|
_capabilityRegistry = reg;
|
|
}
|
|
|
|
/** Test-only seam: restore the real generated registry. */
|
|
function _resetFederatedRegistryForTests(): void {
|
|
_capabilityRegistry = _capabilityRegistryReal;
|
|
}
|
|
|
|
// ─── File & Config utilities ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Canonical config defaults — flat-key projection for CJS consumers.
|
|
*
|
|
* Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested
|
|
* manifest loaded by configuration.generated.cjs). The flat shape is
|
|
* preserved here so legacy consumers (config.cjs, verify.cjs, tests that
|
|
* regex-parse this source) continue to work without changes. The key names
|
|
* and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept.
|
|
*
|
|
* Mapping notes:
|
|
* - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this)
|
|
* - git.* → flat git keys (branching_strategy, templates)
|
|
* - workflow.* → flat names (research, verifier, …)
|
|
* - planning.sub_repos → sub_repos
|
|
* - planning.commit_docs / search_gitignored → top-level flat keys
|
|
*/
|
|
|
|
// CANONICAL_CONFIG_DEFAULTS is typed as Record<string, unknown> from configuration.cjs;
|
|
// we use a typed accessor to avoid repeated casts.
|
|
function _getConfigDefault(key: string): unknown {
|
|
return (CANONICAL_CONFIG_DEFAULTS)[key];
|
|
}
|
|
function _getNestedConfigDefault(section: string, field: string): unknown {
|
|
const sec = (CANONICAL_CONFIG_DEFAULTS)[section];
|
|
if (sec && typeof sec === 'object' && !Array.isArray(sec)) {
|
|
return (sec as Record<string, unknown>)[field];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const CONFIG_DEFAULTS = {
|
|
model_profile: _getConfigDefault('model_profile'),
|
|
commit_docs: _getConfigDefault('commit_docs'),
|
|
search_gitignored: _getConfigDefault('search_gitignored'),
|
|
branching_strategy: _getNestedConfigDefault('git', 'branching_strategy'),
|
|
phase_branch_template: _getNestedConfigDefault('git', 'phase_branch_template'),
|
|
milestone_branch_template: _getNestedConfigDefault('git', 'milestone_branch_template'),
|
|
quick_branch_template: _getNestedConfigDefault('git', 'quick_branch_template'),
|
|
research: _getNestedConfigDefault('workflow', 'research'),
|
|
plan_checker: _getNestedConfigDefault('workflow', 'plan_check'), // flat CJS name maps to workflow.plan_check
|
|
verifier: _getNestedConfigDefault('workflow', 'verifier'),
|
|
nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'),
|
|
ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'),
|
|
api_coverage_gate: _getNestedConfigDefault('workflow', 'api_coverage_gate'),
|
|
parallelization: _getConfigDefault('parallelization'),
|
|
brave_search: _getConfigDefault('brave_search'),
|
|
firecrawl: _getConfigDefault('firecrawl'),
|
|
exa_search: _getConfigDefault('exa_search'),
|
|
text_mode: _getNestedConfigDefault('workflow', 'text_mode'),
|
|
sub_repos: _getNestedConfigDefault('planning', 'sub_repos'),
|
|
resolve_model_ids: _getConfigDefault('resolve_model_ids'),
|
|
context_window: _getConfigDefault('context_window'),
|
|
phase_naming: _getConfigDefault('phase_naming'),
|
|
project_code: _getConfigDefault('project_code'),
|
|
subagent_timeout: _getNestedConfigDefault('workflow', 'subagent_timeout'),
|
|
security_enforcement: _getNestedConfigDefault('workflow', 'security_enforcement'),
|
|
security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'),
|
|
security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'),
|
|
post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'),
|
|
smart_zone_tokens: _getNestedConfigDefault('workflow', 'smart_zone_tokens'),
|
|
};
|
|
|
|
/**
|
|
* Deep-merge two plain config objects. `overlay` wins on key conflict.
|
|
* Explicit `null` in overlay overrides base (null means "unset this key").
|
|
* Arrays are replaced, not merged. Non-object primitives use overlay value.
|
|
*
|
|
* Note: `undefined` in overlay is treated as "no value provided" and falls
|
|
* back to base (preserves inheritance). Explicit `null` overrides base.
|
|
*/
|
|
function _deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown> | null | undefined): Record<string, unknown> | null | undefined {
|
|
if (overlay === null || overlay === undefined) return overlay;
|
|
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
|
|
const result: Record<string, unknown> = { ...base };
|
|
for (const key of Object.keys(overlay)) {
|
|
// Prototype-pollution guard — mirrors the four sibling guards in this file
|
|
// (lines ~315/319/331/341/549). Without it a workstream/root config.json with
|
|
// {"__proto__": {...}} pollutes this merged object's prototype chain and can
|
|
// spoof unset config flags. (Per-object pollution, not global Object.prototype.)
|
|
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
|
|
result[key] = _deepMergeConfig((base[key] ?? {}) as Record<string, unknown>, overlay[key] as Record<string, unknown>);
|
|
} else {
|
|
result[key] = overlay[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Module-level deduplication for unknown-key warnings (#3523).
|
|
// A single `init phase-op N` call invokes loadConfig more than once; this Set
|
|
// prevents the same warning from being echoed on each invocation.
|
|
const _warnedUnknownConfigKeys = new Set<string>();
|
|
|
|
// Normalization result shape from configuration.cjs
|
|
interface NormalizationEntry {
|
|
requiresFilesystem?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// Typed parsed config shape used internally
|
|
interface ParsedConfig {
|
|
[key: string]: unknown;
|
|
planning?: Record<string, unknown>;
|
|
}
|
|
|
|
// ─── Git utilities ────────────────────────────────────────────────────────────
|
|
|
|
const _gitIgnoredCache = new Map<string, boolean>();
|
|
|
|
function isGitIgnored(cwd: string, targetPath: string): boolean {
|
|
// #2206: strip trailing slashes — `git check-ignore` has a quirk where a
|
|
// CRLF .gitignore with blank lines falsely reports a trailing-slash path
|
|
// (e.g. `.planning/`) as ignored. Normalizing here protects every call site.
|
|
const normalized = targetPath.replace(/\/+$/, '');
|
|
const key = cwd + '::' + normalized;
|
|
if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key)!;
|
|
// --no-index checks .gitignore rules regardless of whether the file is tracked.
|
|
const result = execGit(['check-ignore', '-q', '--no-index', '--', normalized], { cwd });
|
|
const ignored = result.exitCode === 0;
|
|
_gitIgnoredCache.set(key, ignored);
|
|
return ignored;
|
|
}
|
|
|
|
// ─── Model alias resolution ───────────────────────────────────────────────────
|
|
|
|
// Catalog-derived (model-catalog.cts) so this vocabulary can never drift from
|
|
// VALID_TIERS in verify.cts — see #2070 "Generative Fix Divergence". Excludes
|
|
// 'inherit' (unlike VALID_TIERS): runtime overrides always resolve to a
|
|
// concrete tier, never the adaptive sentinel.
|
|
const RUNTIME_OVERRIDE_TIERS = ADAPTIVE_TIER_VALUES;
|
|
const _warnedConfigKeys = new Set<string>();
|
|
|
|
function _warnUnknownProfileOverrides(parsed: Record<string, unknown>, configLabel: string): void {
|
|
if (!parsed || typeof parsed !== 'object') return;
|
|
|
|
const runtime = parsed['runtime'];
|
|
if (runtime && typeof runtime === 'string' && !(KNOWN_RUNTIMES).has(runtime)) {
|
|
const key = `${configLabel}::runtime::${runtime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — config key "runtime" has unknown value "${runtime}". ` +
|
|
`Known runtimes: ${[...(KNOWN_RUNTIMES)].sort().join(', ')}. ` +
|
|
`Resolution will fall back to safe defaults. (#2517)\n`
|
|
);
|
|
} catch { /* stderr might be closed in some test harnesses */ }
|
|
}
|
|
}
|
|
|
|
const overrides = parsed['model_profile_overrides'];
|
|
if (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) {
|
|
for (const [overrideRuntime, tierMap] of Object.entries(overrides as Record<string, unknown>)) {
|
|
if (!(KNOWN_RUNTIMES).has(overrideRuntime)) {
|
|
const key = `${configLabel}::override-runtime::${overrideRuntime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
|
|
`unknown runtime "${overrideRuntime}". Known runtimes: ` +
|
|
`${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#2517)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
if (!tierMap || typeof tierMap !== 'object') continue;
|
|
for (const tierName of Object.keys(tierMap)) {
|
|
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
|
|
const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
|
|
`uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const policy = parsed['model_policy'];
|
|
if (policy && typeof policy === 'object' && !Array.isArray(policy)) {
|
|
const policyObj = policy as Record<string, unknown>;
|
|
const provider = policyObj['provider'];
|
|
const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']);
|
|
if (provider && typeof provider === 'string' &&
|
|
!(KNOWN_PROVIDERS).has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) {
|
|
const pkey = `${configLabel}::model_policy::provider::${provider}`;
|
|
if (!_warnedConfigKeys.has(pkey)) {
|
|
_warnedConfigKeys.add(pkey);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.provider has unknown value "${provider}". ` +
|
|
`Known providers: ${[...(KNOWN_PROVIDERS)].sort().join(', ')}. ` +
|
|
`For manual model IDs use provider="custom". (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
const rtOverrides = policyObj['runtime_tiers'];
|
|
if (rtOverrides && typeof rtOverrides === 'object' && !Array.isArray(rtOverrides)) {
|
|
for (const [pruntime, tierMap] of Object.entries(rtOverrides as Record<string, unknown>)) {
|
|
if (!(KNOWN_RUNTIMES).has(pruntime)) {
|
|
const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` +
|
|
`unknown runtime "${pruntime}". Known runtimes: ` +
|
|
`${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
if (!tierMap || typeof tierMap !== 'object') continue;
|
|
for (const tierName of Object.keys(tierMap)) {
|
|
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
|
|
const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` +
|
|
`uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Internal helper exposed for tests so per-process warning state can be reset
|
|
// between cases that intentionally exercise the warning path repeatedly.
|
|
// Clears BOTH dedup sets: _warnedConfigKeys (runtime/model-policy/tier warnings)
|
|
// and _warnedUnknownConfigKeys (unknown top-level keys). Omitting the latter made
|
|
// this a silent no-op for the suite that exists to test it — the leaked state
|
|
// suppressed any later case reusing a key, and the existing cases only passed
|
|
// because each picked a key name no other case reused (#2674).
|
|
function _resetRuntimeWarningCacheForTests(): void {
|
|
_warnedConfigKeys.clear();
|
|
_warnedUnknownConfigKeys.clear();
|
|
_warnedUnusableConfig.clear();
|
|
}
|
|
|
|
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
|
|
|
|
/**
|
|
* Apply federated key values into a mutable config object.
|
|
* Handles N-level dotted keys (e.g. "a.b.c" → obj.a.b.c).
|
|
* Only adds keys that are not already present (does not clobber).
|
|
* Inline prototype-pollution guards at every segment.
|
|
*/
|
|
function _applyFederatedValues(
|
|
obj: Record<string, unknown>,
|
|
values: Record<string, unknown>,
|
|
validKeys: string[],
|
|
): void {
|
|
for (const dottedKey of validKeys) {
|
|
// S2: inline literal guard on full key
|
|
if (dottedKey === '__proto__' || dottedKey === 'constructor' || dottedKey === 'prototype') continue;
|
|
const parts = dottedKey.split('.');
|
|
if (parts.length === 1) {
|
|
const topKey = parts[0];
|
|
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
|
|
if (!Object.prototype.hasOwnProperty.call(obj, topKey)) {
|
|
obj[topKey] = values[dottedKey];
|
|
}
|
|
}
|
|
} else {
|
|
// N-level nested key: traverse/create intermediate objects
|
|
let cur: Record<string, unknown> = obj;
|
|
let ok = true;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const seg = parts[i];
|
|
// S2: inline literal guard on each segment
|
|
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { ok = false; break; }
|
|
if (!Object.prototype.hasOwnProperty.call(cur, seg) || cur[seg] === null) {
|
|
cur[seg] = {};
|
|
}
|
|
if (typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) { ok = false; break; }
|
|
cur = cur[seg] as Record<string, unknown>;
|
|
}
|
|
if (!ok) continue;
|
|
const leafKey = parts[parts.length - 1];
|
|
// S2: inline literal guard on leaf
|
|
if (leafKey === '__proto__' || leafKey === 'constructor' || leafKey === 'prototype') continue;
|
|
if (!Object.prototype.hasOwnProperty.call(cur, leafKey)) {
|
|
cur[leafKey] = values[dottedKey];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FIX 2: Apply the federated overlay to a base config object.
|
|
* When validKeys is empty (current registry — all keys are central),
|
|
* returns the baseConfig UNCHANGED (true no-op, preserves reference identity).
|
|
* When validKeys is non-empty, applies values into a shallow clone to avoid
|
|
* mutating shared CONFIG_DEFAULTS/module constants.
|
|
*/
|
|
// Resolve the federated capability config-schema for a project (ADR-1244 D2).
|
|
// A test override (via _setFederatedRegistryForTests) wins; otherwise, when a
|
|
// project cwd is available, compose the installed overlay for THAT project —
|
|
// LAZILY (never at module load, so a bare require never scans the filesystem and
|
|
// the result is never cached for the wrong cwd) — falling back to the frozen
|
|
// first-party schema when there is no cwd or the loader is unavailable.
|
|
function _federatedConfigSchema(cwd?: string): Record<string, unknown> | undefined {
|
|
if (_capabilityRegistry !== _capabilityRegistryReal) {
|
|
return _capabilityRegistry.configSchema; // explicit test override
|
|
}
|
|
if (typeof cwd === 'string' && cwd) {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
|
|
// #1459 IC-04: thread the consent home explicitly so a consented project cap's federated config
|
|
// key resolves at the SAME user-owned home that gated its activation (never the wrong home).
|
|
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd, gsdHome: process.env['GSD_HOME'] }).configSchema;
|
|
if (schema && typeof schema === 'object') return schema;
|
|
} catch { /* fall back to first-party */ }
|
|
}
|
|
return _capabilityRegistryReal.configSchema;
|
|
}
|
|
|
|
function _applyFederatedOverlay(
|
|
baseConfig: Record<string, unknown>,
|
|
userConfig: Record<string, unknown>,
|
|
cwd?: string,
|
|
): Record<string, unknown> {
|
|
const _fedRegistrySchema = _federatedConfigSchema(cwd);
|
|
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
|
|
const _fedOverlay = mergeFederatedConfig({
|
|
configSchema: _fedRegistrySchema,
|
|
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
|
userConfig,
|
|
});
|
|
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
|
|
if (_fedOverlay.validKeys.length === 0) return baseConfig;
|
|
// Clone shallowly to avoid mutating shared constants, then apply nested values
|
|
const cloned: Record<string, unknown> = { ...baseConfig };
|
|
_applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
|
|
return cloned;
|
|
}
|
|
|
|
// ─── Resolution Provenance (ADR-1411, #1415) ─────────────────────────────────
|
|
|
|
/** Source of a resolved config: which layer actually supplied the config. */
|
|
type ConfigSource = 'workstream' | 'root' | 'builtin-defaults' | 'global-defaults';
|
|
|
|
/**
|
|
* Result of loadConfigResolved — wraps the config object with provenance metadata.
|
|
* - source: which layer supplied the config
|
|
* - degraded: true when the resolution did not deliver the configuration it
|
|
* should have — either a workstream was requested but its
|
|
* config.json was absent (fell back to root), or a file on the
|
|
* resolution path exists but is unusable (#1880). `reason` says which.
|
|
*/
|
|
/**
|
|
* Machine-readable outcome of a config resolution (#1880, ADR-1411 amendment
|
|
* "corrupt is not absent"). `Resolution<T>`'s four documented values all
|
|
* describe a resolution *miss*; the two `config_un*` values below are the
|
|
* unusable-input class that amendment introduced, and they are what makes a
|
|
* corrupt file distinguishable from an absent one.
|
|
*
|
|
* Frozen enum rather than bare strings so tests assert on the typed surface
|
|
* instead of diagnostic prose (CONTRIBUTING.md — Prohibited: Raw Text Matching
|
|
* on Test Outputs).
|
|
*/
|
|
const CONFIG_REASON = Object.freeze({
|
|
/** A config file was found, parsed, and supplied at least one setting. */
|
|
RESOLVED: 'resolved',
|
|
/** No config file exists at the resolved path. Genuine absence — NOT degraded. */
|
|
NOT_CONFIGURED: 'not_configured',
|
|
/** A config file exists and parsed, but carried no settings (`{}`). */
|
|
CONFIGURED_EMPTY: 'configured_empty',
|
|
/** A workstream was requested but had no config; fell back to root. */
|
|
WORKSTREAM_FALLBACK: 'workstream_fallback',
|
|
/** The file exists but is not valid JSON — settings were NOT applied. */
|
|
CONFIG_UNPARSEABLE: 'config_unparseable',
|
|
/** The file exists but could not be read (EACCES/EIO/…) — NOT applied. */
|
|
CONFIG_UNREADABLE: 'config_unreadable',
|
|
} as const);
|
|
|
|
type ConfigReason = (typeof CONFIG_REASON)[keyof typeof CONFIG_REASON];
|
|
|
|
/** A config file that exists but cannot be used. Absence is NOT a fault. */
|
|
interface ConfigFault {
|
|
reason: typeof CONFIG_REASON.CONFIG_UNPARSEABLE | typeof CONFIG_REASON.CONFIG_UNREADABLE;
|
|
/** Resolved path of the offending file — half of the diagnostic dedup key. */
|
|
path: string;
|
|
/** errno for an unreadable file; '' for a parse failure. The other half. */
|
|
code: string;
|
|
}
|
|
|
|
interface ConfigResolution {
|
|
config: Record<string, unknown>;
|
|
source: ConfigSource;
|
|
degraded: boolean;
|
|
/**
|
|
* Why this resolution produced what it did. `degraded` alone cannot separate
|
|
* "no config here" from "your config is corrupt and was discarded" — both
|
|
* previously returned identical objects (#1880).
|
|
*/
|
|
reason: ConfigReason;
|
|
}
|
|
|
|
/**
|
|
* Read + JSON-parse a config file, keeping *absent* distinguishable from
|
|
* *unusable*. `platformReadSync` returns null on ENOENT and re-throws every
|
|
* other errno, which is the seam that makes this separable at all.
|
|
*/
|
|
function _readConfigFile(filePath: string):
|
|
| { kind: 'ok'; data: Record<string, unknown> }
|
|
| { kind: 'absent' }
|
|
| { kind: 'fault'; fault: ConfigFault } {
|
|
let raw: string | null;
|
|
try {
|
|
raw = platformReadSync(filePath);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code ?? 'EUNKNOWN';
|
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNREADABLE, path: filePath, code } };
|
|
}
|
|
if (raw === null) return { kind: 'absent' };
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
|
|
}
|
|
// Shape, not just parseability (ADR-227). `0`, `"x"`, `[]` and `null` are all
|
|
// valid JSON but are not a config object. Accepting them let a PRESENT file
|
|
// parse "ok", then throw downstream, and be reported not_configured by the
|
|
// outer catch — a corrupt file indistinguishable from an absent one, which is
|
|
// the exact defect this change closes. Caught by the fast-check property.
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { kind: 'fault', fault: { reason: CONFIG_REASON.CONFIG_UNPARSEABLE, path: filePath, code: '' } };
|
|
}
|
|
return { kind: 'ok', data: parsed as Record<string, unknown> };
|
|
}
|
|
|
|
/**
|
|
* Dedup set for the unusable-config diagnostic. Keyed on resolved path + errno
|
|
* per the ADR-1411 amendment — never on message text, which would couple the
|
|
* guard to wording, and never on the errno alone, which would suppress a
|
|
* genuine second failure in a different file.
|
|
*/
|
|
const _warnedUnusableConfig = new Set<string>();
|
|
|
|
/**
|
|
* The wiring clause (ADR-1411 amendment). `reason` lives on `ConfigResolution`,
|
|
* but `loadConfig` — the wrapper roughly fifty call sites use — returns
|
|
* `.config` alone and would never surface it. Without this diagnostic the field
|
|
* is unreachable to almost every consumer, and the user whose config was
|
|
* silently discarded still gets no signal. That was the whole defect in #1880.
|
|
*/
|
|
function _warnUnusableConfig(fault: ConfigFault): void {
|
|
// The NUL separators are load-bearing: without them `path`+`reason`+`code` is bare
|
|
// concatenation and two distinct faults can key alike. They are written as escapes rather
|
|
// than literal 0x00 bytes because a literal NUL makes the whole file binary to file(1) and
|
|
// grep(1), which silently skipped it — RULESET.AUDIT.search-source-not-generated tells
|
|
// agents to search this exact source to confirm an invariant exists, and it was returning
|
|
// nothing. Same runtime string, still greppable.
|
|
const key = `${fault.path}\u0000${fault.reason}\u0000${fault.code}`;
|
|
if (_warnedUnusableConfig.has(key)) return;
|
|
_warnedUnusableConfig.add(key);
|
|
const what = fault.reason === CONFIG_REASON.CONFIG_UNPARSEABLE
|
|
? 'is not valid JSON'
|
|
: `could not be read (${fault.code})`;
|
|
process.stderr.write(
|
|
`gsd-tools: warning: ${fault.path} ${what} — its settings were NOT applied; using defaults instead\n`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* loadConfigResolved — provenance-aware config loading (#1415, ADR-1411 P2).
|
|
*
|
|
* Identical to loadConfig in every observable way except it returns
|
|
* { config, source, degraded } instead of just the config object.
|
|
* loadConfig now delegates to this function (byte-identical back-compat).
|
|
*
|
|
* Branch → source/degraded/reason mapping:
|
|
* A1: ws set + ws config.json found → source:'workstream', degraded:false, reason:'resolved'|'configured_empty'
|
|
* A2: ws null + config.json found → source:'root', degraded:false, reason:'resolved'|'configured_empty'
|
|
* B: catch + .planning/ + rootParsed set (ws fallback) → source:'root', degraded:true, reason:'workstream_fallback'
|
|
* C: catch + .planning/ + rootParsed null (federated defaults) → source:'builtin-defaults', degraded:false, reason:'not_configured'
|
|
* D: catch + no .planning/ + ~/.gsd/defaults.json readable → source:'global-defaults', degraded:false, reason:'not_configured'
|
|
* E: catch + no .planning/ + no global → source:'builtin-defaults', degraded:false, reason:'not_configured'
|
|
*
|
|
* ORTHOGONAL to all of the above (#1880, ADR-1411 "corrupt is not absent"): if
|
|
* any config file on the resolution path exists but is UNUSABLE — invalid JSON,
|
|
* or an errno such as EACCES — every branch instead returns degraded:true with
|
|
* reason:'config_unparseable'|'config_unreadable', and a deduplicated stderr
|
|
* diagnostic names the file. Before this, a trailing comma in config.json was
|
|
* byte-identical to the file not existing: builtin defaults, degraded:false,
|
|
* and the user's entire configuration silently discarded.
|
|
*/
|
|
function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}): ConfigResolution {
|
|
// NOTE: loadConfigResolved resolves from cwd AS-IS (no walk-up).
|
|
// Callers that need ancestor-anchoring (e.g. cmdAgentSkills) must do so
|
|
// themselves via findProjectRoot() before calling this function.
|
|
// This preserves back-compat for the ~30 other loadConfig callers (#1415).
|
|
|
|
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
|
? options['workstream']
|
|
: (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
|
|
? (options['workstreamContext'] as Record<string, unknown>)['ws']
|
|
: (process.env['GSD_WORKSTREAM'] || null);
|
|
const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
|
|
// wsRequested: true when caller explicitly requested a non-empty workstream.
|
|
// Used for source labeling (Fix 4) and early absent-dir intercept (Fix 2).
|
|
const wsRequested = ws != null && ws !== '';
|
|
|
|
let cachedSubRepos: string[] | undefined;
|
|
const getDetectedSubRepos = (): string[] => {
|
|
if (cachedSubRepos === undefined) cachedSubRepos = detectSubRepos(cwd);
|
|
return cachedSubRepos.slice();
|
|
};
|
|
// Faults are captured, not thrown: the existing control flow (one broad catch
|
|
// that falls back to defaults) is preserved exactly — see #1880. All that is
|
|
// added is knowing WHY the fallback fired, which is the whole defect.
|
|
let configFault: ConfigFault | null = null;
|
|
|
|
/**
|
|
* Stamp a fallback return with its reason. Every branch below reaches defaults
|
|
* (or the root config) — what differs is WHY, and before #1880 that was
|
|
* unrecoverable: a corrupt file and an absent one produced identical objects.
|
|
*
|
|
* An unusable file always wins and always sets `degraded:true`; genuine
|
|
* absence keeps whatever `degraded` the branch already decided, so the
|
|
* existing #1366 workstream-fallback semantics are untouched.
|
|
*/
|
|
const fallback = (r: Omit<ConfigResolution, 'reason'>): ConfigResolution => {
|
|
if (configFault) return { ...r, degraded: true, reason: configFault.reason };
|
|
return {
|
|
...r,
|
|
reason: r.degraded ? CONFIG_REASON.WORKSTREAM_FALLBACK : CONFIG_REASON.NOT_CONFIGURED,
|
|
};
|
|
};
|
|
|
|
let rootParsed: ParsedConfig | null = null;
|
|
if (ws) {
|
|
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
|
try {
|
|
const rootRead = _readConfigFile(rootConfigPath);
|
|
if (rootRead.kind === 'fault') {
|
|
configFault = rootRead.fault;
|
|
_warnUnusableConfig(rootRead.fault);
|
|
}
|
|
if (rootRead.kind !== 'ok') throw new Error('root config absent or unusable');
|
|
rootParsed = rootRead.data;
|
|
const { parsed: rootNormalized, normalizations: rootNorms } = normalizeLegacyKeys(rootParsed);
|
|
if (rootNorms.length > 0) {
|
|
for (const norm of rootNorms as unknown as NormalizationEntry[]) {
|
|
if (norm.requiresFilesystem && !(rootNormalized as ParsedConfig).planning?.['sub_repos']) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
if (!(rootNormalized as ParsedConfig).planning) (rootNormalized as ParsedConfig).planning = {};
|
|
(rootNormalized as ParsedConfig).planning!['sub_repos'] = detected;
|
|
(rootNormalized as ParsedConfig).planning!['commit_docs'] = false;
|
|
}
|
|
}
|
|
}
|
|
rootParsed = rootNormalized;
|
|
try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch { /* ignore */ }
|
|
} else {
|
|
rootParsed = rootNormalized;
|
|
}
|
|
} catch {
|
|
// Root config missing or unparseable — workstream config stands alone
|
|
}
|
|
}
|
|
|
|
const configPath = path.join(planningDir(cwd, ws), 'config.json');
|
|
const defaults = CONFIG_DEFAULTS;
|
|
|
|
try {
|
|
const read = _readConfigFile(configPath);
|
|
if (read.kind === 'fault') {
|
|
// The workstream/root config that ACTUALLY governs this resolution is
|
|
// unusable. This outranks any earlier root-config fault for reporting.
|
|
configFault = read.fault;
|
|
_warnUnusableConfig(read.fault);
|
|
}
|
|
if (read.kind !== 'ok') throw new Error('config absent or unusable');
|
|
const fileData: ParsedConfig = read.data;
|
|
// Snapshot BEFORE normalizeLegacyKeys mutates fileData in place.
|
|
const fileHadKeys = Object.keys(read.data).length > 0;
|
|
|
|
let configDirty = false;
|
|
{
|
|
const { parsed: normalized, normalizations } = normalizeLegacyKeys(fileData);
|
|
if (normalizations.length > 0) {
|
|
Object.keys(fileData).forEach(k => delete (fileData as Record<string, unknown>)[k]);
|
|
Object.assign(fileData, normalized);
|
|
configDirty = true;
|
|
for (const norm of normalizations as unknown as NormalizationEntry[]) {
|
|
if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
if (!fileData.planning) fileData.planning = {};
|
|
fileData.planning['sub_repos'] = detected;
|
|
fileData.planning['commit_docs'] = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentSubRepos = (fileData.planning?.['sub_repos'] as string[] | undefined) || [];
|
|
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
const sorted = [...currentSubRepos].sort();
|
|
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
|
if (!fileData.planning) fileData.planning = {};
|
|
fileData.planning['sub_repos'] = detected;
|
|
configDirty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (configDirty) {
|
|
try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* ignore */ }
|
|
}
|
|
|
|
const parsed: ParsedConfig = rootParsed
|
|
? (_deepMergeConfig(rootParsed, fileData) as ParsedConfig ?? fileData)
|
|
: fileData;
|
|
|
|
const KNOWN_TOP_LEVEL = new Set([
|
|
...[...VALID_CONFIG_KEYS].map((k: string) => k.split('.')[0]),
|
|
...(DYNAMIC_KEY_PATTERNS as unknown as Array<{ topLevel: string }>).map(p => p.topLevel),
|
|
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
|
|
'depth', 'multiRepo', 'branching_strategy', 'research',
|
|
]);
|
|
|
|
let _preWarningFedValidKeys: string[] = [];
|
|
try {
|
|
const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd);
|
|
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
|
|
const _earlyOverlay = mergeFederatedConfig({
|
|
configSchema: _fedRegistrySchemaEarly,
|
|
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
|
userConfig: parsed,
|
|
});
|
|
_preWarningFedValidKeys = _earlyOverlay.validKeys;
|
|
for (const dottedKey of _preWarningFedValidKeys) {
|
|
const topKey = dottedKey.split('.')[0];
|
|
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
|
|
KNOWN_TOP_LEVEL.add(topKey);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Defensive
|
|
}
|
|
|
|
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
|
|
if (unknownKeys.length > 0) {
|
|
const warnKey = unknownKeys.join(',');
|
|
if (!_warnedUnknownConfigKeys.has(warnKey)) {
|
|
_warnedUnknownConfigKeys.add(warnKey);
|
|
process.stderr.write(
|
|
`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
|
|
);
|
|
}
|
|
}
|
|
|
|
_warnUnknownProfileOverrides(parsed, '.planning/config.json');
|
|
|
|
const get = (key: string, nested?: { section: string; field: string }): unknown => {
|
|
if (parsed[key] !== undefined) return parsed[key];
|
|
if (nested && parsed[nested.section] && typeof parsed[nested.section] === 'object' && parsed[nested.section] !== null) {
|
|
const sec = parsed[nested.section] as Record<string, unknown>;
|
|
if (sec[nested.field] !== undefined) {
|
|
return sec[nested.field];
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const parallelization = (() => {
|
|
const val = get('parallelization');
|
|
if (typeof val === 'boolean') return val;
|
|
if (typeof val === 'object' && val !== null && 'enabled' in (val)) return (val as Record<string, unknown>)['enabled'];
|
|
return defaults.parallelization;
|
|
})();
|
|
|
|
const _baseConfig: Record<string, unknown> = {
|
|
model_profile: get('model_profile') ?? defaults.model_profile,
|
|
commit_docs: (() => {
|
|
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
|
|
if (explicit !== undefined) return explicit;
|
|
if (isGitIgnored(cwd, '.planning/')) return false;
|
|
return defaults.commit_docs;
|
|
})(),
|
|
search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
|
|
branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
|
|
phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
|
|
milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
|
|
quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
|
|
research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
|
|
plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
|
|
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
|
|
nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
|
|
post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps,
|
|
parallelization,
|
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
|
firecrawl: get('firecrawl') ?? defaults.firecrawl,
|
|
exa_search: get('exa_search') ?? defaults.exa_search,
|
|
mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
|
|
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
|
|
auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
|
|
_auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
|
|
mode: get('mode') ?? 'interactive',
|
|
sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
|
|
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
|
context_window: get('context_window') ?? defaults.context_window,
|
|
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
|
project_code: get('project_code') ?? defaults.project_code,
|
|
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
|
|
model_overrides: (parsed['model_overrides']) || null,
|
|
models: (parsed['models']) || null,
|
|
granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
|
|
granularities: (parsed['granularities']) || null,
|
|
planning: (parsed['planning']) || null,
|
|
dynamic_routing: (parsed['dynamic_routing']) || null,
|
|
runtime: (parsed['runtime']) || null,
|
|
model_profile_overrides: (parsed['model_profile_overrides']) || null,
|
|
model_policy: (parsed['model_policy']) || null,
|
|
effort: (parsed['effort']) || null,
|
|
fast_mode: (parsed['fast_mode']) || null,
|
|
agent_skills: (parsed['agent_skills']) || {},
|
|
agent_skills_security: (parsed['agent_skills_security']) || null,
|
|
manager: (parsed['manager']) || {},
|
|
response_language: get('response_language') || null,
|
|
claude_md_path: get('claude_md_path') || null,
|
|
claude_md_assembly: (parsed['claude_md_assembly']) || null,
|
|
};
|
|
|
|
// ADR-857 phase 3b: federated config overlay
|
|
try {
|
|
if (_preWarningFedValidKeys.length > 0) {
|
|
const _fedRegistrySchema = _federatedConfigSchema(cwd);
|
|
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
|
|
const _fedOverlay = mergeFederatedConfig({
|
|
configSchema: _fedRegistrySchema,
|
|
isCentralKey: (key: string) => _isCentralConfigKeyFn(key),
|
|
userConfig: parsed,
|
|
});
|
|
_applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
|
|
}
|
|
}
|
|
} catch {
|
|
// Defensive: keep no-throw contract
|
|
}
|
|
|
|
// A1 vs A2: disambiguate by whether a real workstream was requested.
|
|
// Fix 4: empty-string ws ('') resolves the root path → source:'root'.
|
|
const source: ConfigSource = wsRequested ? 'workstream' : 'root';
|
|
|
|
// 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
|
|
// settings were silently dropped. Reporting `resolved` here would reopen
|
|
// the exact hole this change closes, for the common case of a project that
|
|
// uses workstreams at all.
|
|
if (configFault) {
|
|
return { config: _baseConfig, source, degraded: true, reason: configFault.reason };
|
|
}
|
|
|
|
// Emptiness is judged on the FILE THAT WAS READ, not on `parsed` (the
|
|
// root+workstream merge). An empty workstream file inheriting a non-empty
|
|
// root would otherwise report `resolved` while carrying no settings of its
|
|
// own — the opposite of the not-configured/configured-empty distinction
|
|
// ADR-1411 rule 3 requires.
|
|
const reason = fileHadKeys
|
|
? CONFIG_REASON.RESOLVED
|
|
: CONFIG_REASON.CONFIGURED_EMPTY;
|
|
return { config: _baseConfig, source, degraded: false, reason };
|
|
|
|
} catch {
|
|
// Fix 2: Early intercept — workstream requested but ws config.json absent (or dir absent)
|
|
// AND root config was loaded. Covers BOTH "dir exists, no config.json" AND "dir absent".
|
|
// This delivers the #1366 acceptance criterion: nonexistent GSD_WORKSTREAM yields root, degraded.
|
|
if (wsRequested && rootParsed) {
|
|
const fb = loadConfigResolved(cwd, { workstream: null });
|
|
return fallback({ config: fb.config, source: 'root', degraded: true });
|
|
}
|
|
|
|
// Branch B, C, D, E
|
|
if (fs.existsSync(planningDir(cwd, ws))) {
|
|
if (rootParsed) {
|
|
// Branch B: workstream requested but ws config.json absent; root config present.
|
|
// (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
|
|
const fb = loadConfigResolved(cwd, { workstream: null });
|
|
return fallback({ config: fb.config, source: 'root', degraded: true });
|
|
}
|
|
// Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
|
|
try {
|
|
return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
|
|
} catch {
|
|
return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
|
|
}
|
|
}
|
|
// Branch D or E: no .planning/
|
|
try {
|
|
const home = process.env['GSD_HOME'] || os.homedir();
|
|
const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json');
|
|
const globalRead = _readConfigFile(globalDefaultsPath);
|
|
if (globalRead.kind === 'fault') {
|
|
// ~/.gsd/defaults.json is present but unusable. Only report it when the
|
|
// project config did not already fail — the nearer file is the one the
|
|
// user is most likely to be able to act on.
|
|
if (!configFault) configFault = globalRead.fault;
|
|
_warnUnusableConfig(globalRead.fault);
|
|
}
|
|
if (globalRead.kind !== 'ok') throw new Error('global defaults absent or unusable');
|
|
const globalDefaults = globalRead.data;
|
|
const _globalBaseCfg: Record<string, unknown> = {
|
|
...defaults,
|
|
model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
|
|
commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
|
|
research: (globalDefaults['research']) ?? defaults.research,
|
|
plan_checker: (globalDefaults['plan_checker']) ?? defaults.plan_checker,
|
|
verifier: (globalDefaults['verifier']) ?? defaults.verifier,
|
|
nyquist_validation: (globalDefaults['nyquist_validation']) ?? defaults.nyquist_validation,
|
|
post_planning_gaps: (globalDefaults['post_planning_gaps'])
|
|
?? (globalDefaults['workflow'] as Record<string, unknown> | undefined)?.['post_planning_gaps']
|
|
?? defaults.post_planning_gaps,
|
|
parallelization: (globalDefaults['parallelization']) ?? defaults.parallelization,
|
|
text_mode: (globalDefaults['text_mode']) ?? defaults.text_mode,
|
|
resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids,
|
|
context_window: (globalDefaults['context_window']) ?? defaults.context_window,
|
|
subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout,
|
|
model_overrides: (globalDefaults['model_overrides']) || null,
|
|
models: (globalDefaults['models']) || null,
|
|
granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null,
|
|
granularities: (globalDefaults['granularities']) || null,
|
|
planning: (globalDefaults['planning']) || null,
|
|
dynamic_routing: (globalDefaults['dynamic_routing']) || null,
|
|
effort: (globalDefaults['effort']) || null,
|
|
fast_mode: (globalDefaults['fast_mode']) || null,
|
|
agent_skills: (globalDefaults['agent_skills']) || {},
|
|
response_language: (globalDefaults['response_language']) || null,
|
|
// #2069: forward model_policy / model_profile_overrides / runtime so the global-defaults
|
|
// path is at parity with the project-config path (which forwards these three from
|
|
// parsed['…'] at the top of this function). Without these entries, ~/.gsd/defaults.json
|
|
// silently drops them — model_policy/provider/budget etc. are honored when set in a
|
|
// project but ignored when set globally.
|
|
runtime: (globalDefaults['runtime']) || null,
|
|
model_profile_overrides: (globalDefaults['model_profile_overrides']) || null,
|
|
model_policy: (globalDefaults['model_policy']) || null,
|
|
};
|
|
// Branch D: global-defaults
|
|
try {
|
|
return fallback({ config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false });
|
|
} catch {
|
|
return fallback({ config: _globalBaseCfg, source: 'global-defaults', degraded: false });
|
|
}
|
|
} catch {
|
|
// Branch E: no global defaults
|
|
try {
|
|
return fallback({ config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false });
|
|
} catch {
|
|
return fallback({ config: defaults, source: 'builtin-defaults', degraded: false });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* loadConfig — backwards-compatible config loading, now a thin wrapper over loadConfigResolved.
|
|
* Returns the config object only; for provenance metadata use loadConfigResolved.
|
|
*/
|
|
function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<string, unknown> {
|
|
return loadConfigResolved(cwd, options).config;
|
|
}
|
|
|
|
export = {
|
|
loadConfig,
|
|
loadConfigResolved,
|
|
CONFIG_REASON,
|
|
_warnedUnusableConfig,
|
|
isGitIgnored,
|
|
CONFIG_DEFAULTS,
|
|
_getConfigDefault,
|
|
_getNestedConfigDefault,
|
|
_deepMergeConfig,
|
|
_warnedUnknownConfigKeys,
|
|
_warnUnknownProfileOverrides,
|
|
_resetRuntimeWarningCacheForTests,
|
|
_warnedConfigKeys,
|
|
_gitIgnoredCache,
|
|
RUNTIME_OVERRIDE_TIERS,
|
|
_setFederatedRegistryForTests,
|
|
_resetFederatedRegistryForTests,
|
|
};
|