@gaunt-sloth/core 2.0.0-alpha.4 → 2.0.0-alpha.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -20
- package/dist/config/colour.d.ts +38 -0
- package/dist/config/colour.js +36 -0
- package/dist/config/colour.js.map +1 -0
- package/dist/config/configDiscovery.d.ts +79 -0
- package/dist/config/configDiscovery.js +80 -0
- package/dist/config/configDiscovery.js.map +1 -0
- package/dist/config/defaults.d.ts +21 -21
- package/dist/config/defaults.js +11 -9
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/filesystem-tools.d.ts +41 -0
- package/dist/config/filesystem-tools.js +56 -0
- package/dist/config/filesystem-tools.js.map +1 -0
- package/dist/config/jsonc.d.ts +12 -0
- package/dist/config/jsonc.js +41 -0
- package/dist/config/jsonc.js.map +1 -0
- package/dist/config/loader.d.ts +165 -6
- package/dist/config/loader.js +963 -109
- package/dist/config/loader.js.map +1 -1
- package/dist/config/mouse.d.ts +50 -0
- package/dist/config/mouse.js +44 -0
- package/dist/config/mouse.js.map +1 -0
- package/dist/config/profiles.d.ts +68 -0
- package/dist/config/profiles.js +93 -0
- package/dist/config/profiles.js.map +1 -0
- package/dist/config/providerKeys.d.ts +69 -0
- package/dist/config/providerKeys.js +69 -0
- package/dist/config/providerKeys.js.map +1 -0
- package/dist/config/schema.d.ts +2695 -130
- package/dist/config/schema.js +1385 -68
- package/dist/config/schema.js.map +1 -1
- package/dist/config/shell-policy.d.ts +899 -111
- package/dist/config/shell-policy.js +800 -70
- package/dist/config/shell-policy.js.map +1 -1
- package/dist/config/tool-descriptions.d.ts +211 -0
- package/dist/config/tool-descriptions.js +272 -0
- package/dist/config/tool-descriptions.js.map +1 -0
- package/dist/config/types.d.ts +372 -34
- package/dist/config/types.js +1 -0
- package/dist/config/types.js.map +1 -1
- package/dist/config.d.ts +35 -1
- package/dist/config.js +16 -1
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +45 -0
- package/dist/constants.js +45 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/GthAbstractAgent.d.ts +190 -11
- package/dist/core/GthAbstractAgent.js +551 -35
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +565 -57
- package/dist/core/GthAgentRunner.js +1546 -140
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +117 -2
- package/dist/core/GthLangChainAgent.js +644 -18
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/approvals/annotations.d.ts +122 -0
- package/dist/core/approvals/annotations.js +137 -0
- package/dist/core/approvals/annotations.js.map +1 -0
- package/dist/core/approvals/grants.d.ts +216 -0
- package/dist/core/approvals/grants.js +469 -0
- package/dist/core/approvals/grants.js.map +1 -0
- package/dist/core/approvals/matcher.d.ts +202 -0
- package/dist/core/approvals/matcher.js +267 -0
- package/dist/core/approvals/matcher.js.map +1 -0
- package/dist/core/approvals/mcpSubjects.d.ts +40 -0
- package/dist/core/approvals/mcpSubjects.js +99 -0
- package/dist/core/approvals/mcpSubjects.js.map +1 -0
- package/dist/core/approvals/promptHeader.d.ts +28 -0
- package/dist/core/approvals/promptHeader.js +62 -0
- package/dist/core/approvals/promptHeader.js.map +1 -0
- package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
- package/dist/core/approvals/toolAnnotationSources.js +277 -0
- package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
- package/dist/core/approvals/toolHost.d.ts +46 -0
- package/dist/core/approvals/toolHost.js +108 -0
- package/dist/core/approvals/toolHost.js.map +1 -0
- package/dist/core/debugCapture.d.ts +74 -0
- package/dist/core/debugCapture.js +100 -0
- package/dist/core/debugCapture.js.map +1 -0
- package/dist/core/gthLeanAgentFactory.d.ts +9 -0
- package/dist/core/gthLeanAgentFactory.js +10 -0
- package/dist/core/gthLeanAgentFactory.js.map +1 -0
- package/dist/core/launchBanner.d.ts +127 -0
- package/dist/core/launchBanner.js +414 -0
- package/dist/core/launchBanner.js.map +1 -0
- package/dist/core/modelLabel.d.ts +19 -0
- package/dist/core/modelLabel.js +26 -0
- package/dist/core/modelLabel.js.map +1 -0
- package/dist/core/plainToolIndication.d.ts +15 -0
- package/dist/core/plainToolIndication.js +174 -0
- package/dist/core/plainToolIndication.js.map +1 -0
- package/dist/core/reasoningBlocks.d.ts +65 -0
- package/dist/core/reasoningBlocks.js +103 -0
- package/dist/core/reasoningBlocks.js.map +1 -0
- package/dist/core/refusal.d.ts +53 -0
- package/dist/core/refusal.js +133 -0
- package/dist/core/refusal.js.map +1 -0
- package/dist/core/runHeader.d.ts +38 -0
- package/dist/core/runHeader.js +42 -0
- package/dist/core/runHeader.js.map +1 -0
- package/dist/core/runStats.d.ts +52 -0
- package/dist/core/runStats.js +118 -0
- package/dist/core/runStats.js.map +1 -0
- package/dist/core/shell/ShellCommandFailedError.d.ts +53 -0
- package/dist/core/shell/ShellCommandFailedError.js +67 -0
- package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
- package/dist/core/shell/abstention.d.ts +88 -0
- package/dist/core/shell/abstention.js +184 -0
- package/dist/core/shell/abstention.js.map +1 -0
- package/dist/core/shell/approvalCapture.d.ts +271 -0
- package/dist/core/shell/approvalCapture.js +108 -0
- package/dist/core/shell/approvalCapture.js.map +1 -0
- package/dist/core/shell/approvalStop.d.ts +123 -0
- package/dist/core/shell/approvalStop.js +269 -0
- package/dist/core/shell/approvalStop.js.map +1 -0
- package/dist/core/shell/arity.d.ts +6 -0
- package/dist/core/shell/arity.js +20 -6
- package/dist/core/shell/arity.js.map +1 -1
- package/dist/core/shell/denylist.d.ts +11 -0
- package/dist/core/shell/denylist.js +37 -0
- package/dist/core/shell/denylist.js.map +1 -0
- package/dist/core/shell/escalationSeverity.d.ts +141 -0
- package/dist/core/shell/escalationSeverity.js +89 -0
- package/dist/core/shell/escalationSeverity.js.map +1 -0
- package/dist/core/shell/framing.d.ts +190 -0
- package/dist/core/shell/framing.js +633 -0
- package/dist/core/shell/framing.js.map +1 -0
- package/dist/core/shell/hardline.d.ts +103 -0
- package/dist/core/shell/hardline.js +780 -0
- package/dist/core/shell/hardline.js.map +1 -0
- package/dist/core/shell/negotiation.d.ts +328 -0
- package/dist/core/shell/negotiation.js +488 -0
- package/dist/core/shell/negotiation.js.map +1 -0
- package/dist/core/shell/normalize.d.ts +44 -4
- package/dist/core/shell/normalize.js +61 -7
- package/dist/core/shell/normalize.js.map +1 -1
- package/dist/core/shell/openWorld.d.ts +263 -0
- package/dist/core/shell/openWorld.js +1188 -0
- package/dist/core/shell/openWorld.js.map +1 -0
- package/dist/core/shell/rater.d.ts +873 -0
- package/dist/core/shell/rater.js +1454 -0
- package/dist/core/shell/rater.js.map +1 -0
- package/dist/core/shell/raterModel.d.ts +41 -0
- package/dist/core/shell/raterModel.js +51 -0
- package/dist/core/shell/raterModel.js.map +1 -0
- package/dist/core/shell/raterVocabulary.d.ts +121 -0
- package/dist/core/shell/raterVocabulary.js +116 -0
- package/dist/core/shell/raterVocabulary.js.map +1 -0
- package/dist/core/shell/rejection.d.ts +69 -0
- package/dist/core/shell/rejection.js +38 -0
- package/dist/core/shell/rejection.js.map +1 -0
- package/dist/core/toolCallRepair/grammar.d.ts +41 -0
- package/dist/core/toolCallRepair/grammar.js +116 -0
- package/dist/core/toolCallRepair/grammar.js.map +1 -0
- package/dist/core/toolCallRepair/index.d.ts +2 -0
- package/dist/core/toolCallRepair/index.js +7 -0
- package/dist/core/toolCallRepair/index.js.map +1 -0
- package/dist/core/toolCallRepair/payload.d.ts +36 -0
- package/dist/core/toolCallRepair/payload.js +341 -0
- package/dist/core/toolCallRepair/payload.js.map +1 -0
- package/dist/core/toolCallRepair/promote.d.ts +45 -0
- package/dist/core/toolCallRepair/promote.js +90 -0
- package/dist/core/toolCallRepair/promote.js.map +1 -0
- package/dist/core/toolDisplay.d.ts +123 -0
- package/dist/core/toolDisplay.js +451 -0
- package/dist/core/toolDisplay.js.map +1 -0
- package/dist/core/toolOutputChannel.d.ts +95 -0
- package/dist/core/toolOutputChannel.js +165 -0
- package/dist/core/toolOutputChannel.js.map +1 -0
- package/dist/core/types.d.ts +378 -16
- package/dist/core/types.js.map +1 -1
- package/dist/history/historyFormat.d.ts +28 -0
- package/dist/history/historyFormat.js +127 -0
- package/dist/history/historyFormat.js.map +1 -0
- package/dist/history/historyStore.d.ts +198 -0
- package/dist/history/historyStore.js +482 -0
- package/dist/history/historyStore.js.map +1 -0
- package/dist/history/recordSession.d.ts +37 -0
- package/dist/history/recordSession.js +56 -0
- package/dist/history/recordSession.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/anthropic.d.ts +1 -1
- package/dist/providers/anthropic.js +17 -10
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/configurationPassthrough.d.ts +107 -0
- package/dist/providers/configurationPassthrough.js +148 -0
- package/dist/providers/configurationPassthrough.js.map +1 -0
- package/dist/providers/deepseek.d.ts +1 -1
- package/dist/providers/deepseek.js +5 -10
- package/dist/providers/deepseek.js.map +1 -1
- package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
- package/dist/providers/geminiSchemaSanitizer.js +347 -0
- package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
- package/dist/providers/geminiThinking.d.ts +60 -0
- package/dist/providers/geminiThinking.js +92 -0
- package/dist/providers/geminiThinking.js.map +1 -0
- package/dist/providers/google-genai.d.ts +1 -1
- package/dist/providers/google-genai.js +23 -11
- package/dist/providers/google-genai.js.map +1 -1
- package/dist/providers/groq.d.ts +1 -1
- package/dist/providers/groq.js +17 -10
- package/dist/providers/groq.js.map +1 -1
- package/dist/providers/huggingface.d.ts +25 -0
- package/dist/providers/huggingface.js +69 -0
- package/dist/providers/huggingface.js.map +1 -0
- package/dist/providers/modelCatalog.d.ts +109 -0
- package/dist/providers/modelCatalog.js +245 -0
- package/dist/providers/modelCatalog.js.map +1 -0
- package/dist/providers/modelDiscovery.d.ts +153 -6
- package/dist/providers/modelDiscovery.js +295 -41
- package/dist/providers/modelDiscovery.js.map +1 -1
- package/dist/providers/ollama.d.ts +19 -5
- package/dist/providers/ollama.js +72 -52
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts +1 -1
- package/dist/providers/openai.js +39 -10
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +27 -5
- package/dist/providers/openrouter.js +88 -36
- package/dist/providers/openrouter.js.map +1 -1
- package/dist/providers/vertexai.d.ts +1 -1
- package/dist/providers/vertexai.js +24 -11
- package/dist/providers/vertexai.js.map +1 -1
- package/dist/providers/xai.d.ts +1 -1
- package/dist/providers/xai.js +25 -10
- package/dist/providers/xai.js.map +1 -1
- package/dist/runtime/askStructured.d.ts +105 -0
- package/dist/runtime/askStructured.js +120 -0
- package/dist/runtime/askStructured.js.map +1 -0
- package/dist/runtime/conversation.d.ts +64 -0
- package/dist/runtime/conversation.js +171 -0
- package/dist/runtime/conversation.js.map +1 -0
- package/dist/runtime/singleShot.d.ts +39 -5
- package/dist/runtime/singleShot.js +115 -36
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/runtime/structuredOutput.d.ts +104 -0
- package/dist/runtime/structuredOutput.js +393 -0
- package/dist/runtime/structuredOutput.js.map +1 -0
- package/dist/utils/ProgressIndicator.d.ts +21 -0
- package/dist/utils/ProgressIndicator.js +30 -3
- package/dist/utils/ProgressIndicator.js.map +1 -1
- package/dist/utils/aiignoreUtils.js.map +1 -1
- package/dist/utils/binaryOutputUtils.js.map +1 -1
- package/dist/utils/consoleUtils.d.ts +95 -0
- package/dist/utils/consoleUtils.js +112 -2
- package/dist/utils/consoleUtils.js.map +1 -1
- package/dist/utils/crashHandler.d.ts +87 -0
- package/dist/utils/crashHandler.js +128 -0
- package/dist/utils/crashHandler.js.map +1 -0
- package/dist/utils/debugDump.d.ts +134 -0
- package/dist/utils/debugDump.js +381 -0
- package/dist/utils/debugDump.js.map +1 -0
- package/dist/utils/debugUtils.d.ts +13 -4
- package/dist/utils/debugUtils.js +36 -13
- package/dist/utils/debugUtils.js.map +1 -1
- package/dist/utils/displayWidth.d.ts +53 -0
- package/dist/utils/displayWidth.js +195 -0
- package/dist/utils/displayWidth.js.map +1 -0
- package/dist/utils/fileUtils.d.ts +20 -1
- package/dist/utils/fileUtils.js +35 -2
- package/dist/utils/fileUtils.js.map +1 -1
- package/dist/utils/llmUtils.d.ts +39 -8
- package/dist/utils/llmUtils.js +76 -8
- package/dist/utils/llmUtils.js.map +1 -1
- package/dist/utils/redactSecrets.d.ts +63 -0
- package/dist/utils/redactSecrets.js +286 -0
- package/dist/utils/redactSecrets.js.map +1 -0
- package/dist/utils/systemPromptNotes.d.ts +222 -0
- package/dist/utils/systemPromptNotes.js +338 -0
- package/dist/utils/systemPromptNotes.js.map +1 -0
- package/dist/utils/systemUtils.d.ts +18 -1
- package/dist/utils/systemUtils.js +38 -3
- package/dist/utils/systemUtils.js.map +1 -1
- package/dist/utils/toolMatching.d.ts +30 -0
- package/dist/utils/toolMatching.js +44 -0
- package/dist/utils/toolMatching.js.map +1 -0
- package/dist/utils/untrustedText.d.ts +86 -0
- package/dist/utils/untrustedText.js +101 -0
- package/dist/utils/untrustedText.js.map +1 -0
- package/package.json +21 -6
- package/schema/gsloth-config.schema.json +1979 -353
- package/dist/core/shell/allowlist.d.ts +0 -75
- package/dist/core/shell/allowlist.js +0 -187
- package/dist/core/shell/allowlist.js.map +0 -1
- package/dist/core/shell/judge.d.ts +0 -161
- package/dist/core/shell/judge.js +0 -261
- package/dist/core/shell/judge.js.map +0 -1
package/dist/config/loader.js
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @packageDocumentation
|
|
3
3
|
* Configuration discovery + the layered load/merge pipeline (global + project layers,
|
|
4
|
-
* format fall-through JSON → JS → MJS, schema validation, deep-merge with defaults).
|
|
4
|
+
* format fall-through JSON → JSONC → JS → MJS, schema validation, deep-merge with defaults).
|
|
5
5
|
* Extracted from the former `config.ts` god-file; behaviour is unchanged.
|
|
6
6
|
*/
|
|
7
|
-
import { GSLOTH_DIR, GSLOTH_SETTINGS_DIR, USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_JSON, USER_PROJECT_CONFIG_MJS, USER_PROJECT_CONFIG_TS, } from '#src/constants.js';
|
|
7
|
+
import { GSLOTH_DIR, GSLOTH_SETTINGS_DIR, USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_JSON, USER_PROJECT_CONFIG_JSONC, USER_PROJECT_CONFIG_MJS, USER_PROJECT_CONFIG_TS, } from '#src/constants.js';
|
|
8
8
|
import { StatusLevel } from '#src/core/types.js';
|
|
9
9
|
import { displayDebug, displayError, displayInfo, displayWarning, setConsoleLevel, } from '#src/utils/consoleUtils.js';
|
|
10
|
-
import { findUnknownTopLevelKeys, formatConfigValidationError,
|
|
10
|
+
import { findApprovalsGrammarIssues, findApprovalsRaterProfiles, findDeprecatedConfigIssues, findUnknownTopLevelKeys, formatConfigValidationError, formatDeprecatedConfigIssues, isRecordConfig, rawGthConfigSchema, unresolvedRaterProfileMessage, validateRawGthConfig, } from '#src/config/schema.js';
|
|
11
|
+
import { parseJsonc } from '#src/config/jsonc.js';
|
|
12
|
+
import { isMissingProviderKeyError, MissingProviderKeyError } from '#src/config/providerKeys.js';
|
|
13
|
+
import { ConfigDiscoveryError, isConfigDiscoveryError } from '#src/config/configDiscovery.js';
|
|
11
14
|
import { getGslothConfigReadPath, importExternalFile } from '#src/utils/fileUtils.js';
|
|
12
15
|
import { getGlobalGslothConfigReadPath } from '#src/utils/globalConfigUtils.js';
|
|
13
|
-
import {
|
|
16
|
+
import { env, getCurrentWorkDir, isStdoutTTY, isTTY, setProjectDir, setUseColour, } from '#src/utils/systemUtils.js';
|
|
17
|
+
import { resolveUseColour } from '#src/config/colour.js';
|
|
18
|
+
import { resolveUseMouse } from '#src/config/mouse.js';
|
|
14
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
15
20
|
import { homedir } from 'node:os';
|
|
16
21
|
import { dirname, resolve } from 'node:path';
|
|
@@ -21,48 +26,108 @@ import { DEFAULT_CONFIG } from '#src/config/defaults.js';
|
|
|
21
26
|
* config shape.
|
|
22
27
|
*
|
|
23
28
|
* Steps, in order:
|
|
24
|
-
* 1.
|
|
25
|
-
* per-command)
|
|
26
|
-
*
|
|
27
|
-
*
|
|
29
|
+
* 1. Deprecated-shape reject (GS2-28): a removed pre-2.0 shape — a top-level command key
|
|
30
|
+
* or a deprecated `*Provider*` name (root + per-command), detected by
|
|
31
|
+
* {@link findDeprecatedConfigIssues} — is a HARD error naming the canonical replacement +
|
|
32
|
+
* migration path. 2.0 dropped back-compat coercion, so these fail rather than
|
|
33
|
+
* remap. Runs FIRST so a deprecated name never merely surfaces as an unknown-key warning.
|
|
34
|
+
* 1b. Approvals rule-grammar reject (EXT-71): a bare string in `allow`/`deny`/`escalate`, or a
|
|
35
|
+
* configured `mcpServers` key named `*`, detected by {@link findApprovalsGrammarIssues} — a
|
|
36
|
+
* HARD error, before the parse so its message (which shows the object form of the string the
|
|
37
|
+
* user wrote) is the only one they see.
|
|
28
38
|
* 2. Unknown top-level keys: warn (do NOT fail) so likely typos are surfaced while
|
|
29
39
|
* forward-compatible / extension keys still pass through untouched.
|
|
30
|
-
* 3. Schema parse: on a genuine type mismatch on a known field,
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* so the original `raw` (pre-mapped) is returned unchanged on success.
|
|
40
|
+
* 3. Schema parse: on a genuine type mismatch on a known field, a friendly, path-scoped
|
|
41
|
+
* error. Validation is shape-only — the loose schema preserves unknown keys,
|
|
42
|
+
* so the original `raw` is returned unchanged on success.
|
|
34
43
|
*
|
|
35
|
-
*
|
|
44
|
+
* CFG-36 / CFG-47 — every hard failure above RAISES a {@link ConfigDiscoveryError} rather than
|
|
45
|
+
* printing and calling `exit(1)`, and so does every other "config present and unusable" site in this
|
|
46
|
+
* file. Config loading is a library operation: the caller chooses the exit code (the CLI's top-level
|
|
47
|
+
* guard prints the message and exits 1; `gth eval` classifies it as a harness error and exits 2).
|
|
48
|
+
* **This file no longer calls `exit` at all** — that is the invariant, and it is easier to keep than
|
|
49
|
+
* a list of which sites do. Because these throw, every `catch` between here and a top level must
|
|
50
|
+
* re-raise them rather than fall through to another format or treat the layer as absent — see the
|
|
51
|
+
* {@link isConfigDiscoveryError} re-raises in {@link loadGlobalRawConfig}, {@link initConfig},
|
|
52
|
+
* {@link tryModuleConfig} and {@link tryJsonConfig}. Swallowing one would silently downgrade a hard
|
|
53
|
+
* config error to a different (or absent) config, which is the false-green this change exists to
|
|
54
|
+
* prevent.
|
|
55
|
+
*
|
|
56
|
+
* @param raw The freshly loaded config layer (read-only here).
|
|
36
57
|
* @param sourceLabel Human-readable source name for messages (e.g. the filename).
|
|
58
|
+
* @throws ConfigDiscoveryError when the layer carries a hard configuration error.
|
|
37
59
|
*/
|
|
38
60
|
function validateRawConfigLayer(raw, sourceLabel) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
// Only an object config can carry deprecated/unknown keys; a null/array/primitive config skips
|
|
62
|
+
// the scans (they'd throw a raw TypeError) and falls to safeParse, which emits a clean
|
|
63
|
+
// "expected object" error + exit — never a coercion to {} (which would wrongly pass).
|
|
64
|
+
if (isRecordConfig(raw)) {
|
|
65
|
+
const deprecatedIssues = findDeprecatedConfigIssues(raw);
|
|
66
|
+
if (deprecatedIssues.length > 0) {
|
|
67
|
+
throw new ConfigDiscoveryError(`Invalid configuration in ${sourceLabel}:\n${formatDeprecatedConfigIssues(deprecatedIssues)}`, { sourceLabel });
|
|
68
|
+
}
|
|
69
|
+
// EXT-71 — the rule-grammar errors that must be seen BEFORE the schema parse: a bare string in
|
|
70
|
+
// a rule list (whose message shows the object form of that same string) and a reserved `*`
|
|
71
|
+
// MCP server name. Checked in the same place as the deprecated scan, and in `gth config
|
|
72
|
+
// validate` too, so the validator can never green-light a config a real run refuses.
|
|
73
|
+
const grammarIssues = findApprovalsGrammarIssues(raw);
|
|
74
|
+
if (grammarIssues.length > 0) {
|
|
75
|
+
throw new ConfigDiscoveryError(`Invalid configuration in ${sourceLabel}:\n${formatDeprecatedConfigIssues(grammarIssues)}`, { sourceLabel });
|
|
76
|
+
}
|
|
77
|
+
const unknownKeys = findUnknownTopLevelKeys(raw);
|
|
78
|
+
if (unknownKeys.length > 0) {
|
|
79
|
+
displayWarning(`Unknown top-level config ${unknownKeys.length === 1 ? 'key' : 'keys'} in ${sourceLabel}: ` +
|
|
80
|
+
`${unknownKeys.join(', ')}. ${unknownKeys.length === 1 ? 'It is' : 'They are'} kept as-is ` +
|
|
81
|
+
'but ignored by Gaunt Sloth; check for typos.');
|
|
82
|
+
}
|
|
48
83
|
}
|
|
49
|
-
const result = rawGthConfigSchema.safeParse(
|
|
84
|
+
const result = rawGthConfigSchema.safeParse(raw);
|
|
50
85
|
if (!result.success) {
|
|
51
|
-
|
|
52
|
-
exit(1);
|
|
86
|
+
throw new ConfigDiscoveryError(`Invalid configuration in ${sourceLabel}:\n${formatConfigValidationError(result.error)}`, { sourceLabel });
|
|
53
87
|
}
|
|
54
|
-
|
|
88
|
+
// CFG-26 — `approvals.rater` STRICT resolution (GS2-62): a named profile that does not
|
|
89
|
+
// resolve to a real profile config is a hard error, never a silent fallback to the main model.
|
|
90
|
+
// Checked HERE rather than in the zod schema on purpose — resolution needs the filesystem and
|
|
91
|
+
// `schema.ts` must stay pure (it also feeds `z.toJSONSchema`).
|
|
92
|
+
if (isRecordConfig(raw)) {
|
|
93
|
+
for (const ref of findApprovalsRaterProfiles(raw)) {
|
|
94
|
+
if (!resolveIdentityProfileConfigPath(ref.profile)) {
|
|
95
|
+
throw new ConfigDiscoveryError(`Invalid configuration in ${sourceLabel}:\n` +
|
|
96
|
+
` - ${ref.path}: ${unresolvedRaterProfileMessage(ref)}`, { sourceLabel, identityProfile: ref.profile });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return raw;
|
|
55
101
|
}
|
|
56
102
|
/**
|
|
57
|
-
* Project config file lookup order, highest precedence first. JSON wins, then
|
|
58
|
-
*
|
|
103
|
+
* Project config file lookup order, highest precedence first. JSON wins, then its `.jsonc`
|
|
104
|
+
* spelling (GS2-69 — same {@link parseJsonc} parse either way), then the `configure()`-style
|
|
105
|
+
* module formats (JS → MJS → TS). Used to pick THE config within a dir.
|
|
59
106
|
*/
|
|
60
107
|
const PROJECT_CONFIG_FORMATS = [
|
|
61
108
|
USER_PROJECT_CONFIG_JSON,
|
|
109
|
+
USER_PROJECT_CONFIG_JSONC,
|
|
62
110
|
USER_PROJECT_CONFIG_JS,
|
|
63
111
|
USER_PROJECT_CONFIG_MJS,
|
|
64
112
|
USER_PROJECT_CONFIG_TS,
|
|
65
113
|
];
|
|
114
|
+
/**
|
|
115
|
+
* GS2-69 — the lookup order for the two JSON-family filenames wherever they are probed as a
|
|
116
|
+
* pair (the global `~/.gsloth` lookup and its read-side mirror). `.json` first, so it wins
|
|
117
|
+
* when both exist — matching {@link PROJECT_CONFIG_FORMATS}.
|
|
118
|
+
*/
|
|
119
|
+
const JSON_CONFIG_FILENAMES = [
|
|
120
|
+
USER_PROJECT_CONFIG_JSON,
|
|
121
|
+
USER_PROJECT_CONFIG_JSONC,
|
|
122
|
+
];
|
|
123
|
+
/**
|
|
124
|
+
* True when `path` belongs to the JSONC-parsing branch (a `.json` OR `.jsonc` file, GS2-69) as
|
|
125
|
+
* opposed to the `configure()`-module importer. Single-sources the run-path gate so an explicit
|
|
126
|
+
* `-c foo.jsonc` can never fall through to the module importer again.
|
|
127
|
+
*/
|
|
128
|
+
function isJsonConfigPath(path) {
|
|
129
|
+
return path.endsWith('.json') || path.endsWith('.jsonc');
|
|
130
|
+
}
|
|
66
131
|
/**
|
|
67
132
|
* Dir-aware version of {@link getGslothConfigReadPath} for ancestor dirs during the up-tree
|
|
68
133
|
* walk. Mirrors its `.gsloth/.gsloth-settings[/<profile>]/<filename>` resolution but against an
|
|
@@ -95,6 +160,25 @@ function resolveConfigPath(baseDir, filename, identityProfile) {
|
|
|
95
160
|
? getGslothConfigReadPath(filename, identityProfile)
|
|
96
161
|
: resolveProjectConfigPathInDir(baseDir, filename, identityProfile);
|
|
97
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Yield each directory to search during config discovery, from cwd up to (and INCLUDING) the stop
|
|
165
|
+
* boundary — a dir containing `.git` (the git root), the user's home dir, or the filesystem root,
|
|
166
|
+
* whichever comes first (the dir at the boundary is itself searched, then ascent stops). Single-
|
|
167
|
+
* sources the up-tree boundary so {@link findProjectConfigPath} and
|
|
168
|
+
* {@link resolveIdentityProfileConfigPath} can never drift on where the walk starts or stops.
|
|
169
|
+
*/
|
|
170
|
+
function* walkConfigSearchDirs() {
|
|
171
|
+
const home = homedir();
|
|
172
|
+
let dir = getCurrentWorkDir();
|
|
173
|
+
for (;;) {
|
|
174
|
+
yield dir;
|
|
175
|
+
const parent = dirname(dir);
|
|
176
|
+
if (existsSync(resolve(dir, '.git')) || dir === home || parent === dir) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
dir = parent;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
98
182
|
/**
|
|
99
183
|
* Find THE project config by walking up from cwd toward a stop boundary, returning the FIRST
|
|
100
184
|
* match (first-match-win: nearest dir, then format precedence within that dir — NOT a merged
|
|
@@ -107,6 +191,12 @@ function resolveConfigPath(baseDir, filename, identityProfile) {
|
|
|
107
191
|
*
|
|
108
192
|
* A `customConfigPath` override wins outright (no walking).
|
|
109
193
|
*
|
|
194
|
+
* NOTE (identity profile): with an `identityProfile` set, each dir's per-format resolver
|
|
195
|
+
* ({@link resolveConfigPath}) tries the profile path `.gsloth/.gsloth-settings/<profile>/<file>`
|
|
196
|
+
* but FALLS BACK to the plain `<dir>/<file>` when the profile file is absent. So a match here does
|
|
197
|
+
* NOT prove the named profile itself has a config — it may be a plain (non-profile) config. Use
|
|
198
|
+
* {@link resolveIdentityProfileConfigPath} when you need to know a profile specifically resolved.
|
|
199
|
+
*
|
|
110
200
|
* @returns the matched `{ dir, path }`, or `undefined` when no project config exists within the
|
|
111
201
|
* boundary.
|
|
112
202
|
*/
|
|
@@ -119,24 +209,89 @@ export function findProjectConfigPath(commandLineConfigOverrides) {
|
|
|
119
209
|
}
|
|
120
210
|
: undefined;
|
|
121
211
|
}
|
|
122
|
-
const home = homedir();
|
|
123
|
-
let dir = getCurrentWorkDir();
|
|
124
212
|
// Walk up: search each dir, then stop at the boundary (git root / home / fs root).
|
|
125
|
-
for (
|
|
213
|
+
for (const dir of walkConfigSearchDirs()) {
|
|
126
214
|
for (const filename of PROJECT_CONFIG_FORMATS) {
|
|
127
215
|
const candidate = resolveConfigPath(dir, filename, commandLineConfigOverrides.identityProfile);
|
|
128
216
|
if (existsSync(candidate)) {
|
|
129
217
|
return { dir, path: candidate };
|
|
130
218
|
}
|
|
131
219
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* CFG-26 — the read-side validator's fs-backed hook, so `gth config validate`
|
|
225
|
+
* ({@link collectConfigValidationLayers}) enforces the SAME `approvals.rater` existence
|
|
226
|
+
* rule the loader hard-exits on. `schema.ts` stays pure; the filesystem knowledge lives here.
|
|
227
|
+
*/
|
|
228
|
+
const RAW_CONFIG_VALIDATION_OPTIONS = {
|
|
229
|
+
resolveProfile: (profile) => resolveIdentityProfileConfigPath(profile) !== undefined,
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* STRICT existence check for an EXPLICITLY-named identity profile: does
|
|
233
|
+
* `.gsloth/.gsloth-settings/<identityProfile>/<config>` resolve to a real config file anywhere in
|
|
234
|
+
* the same up-tree search {@link findProjectConfigPath} walks? Returns the resolved profile config
|
|
235
|
+
* path (nearest dir, then format precedence) when the profile has its OWN config, `undefined`
|
|
236
|
+
* otherwise.
|
|
237
|
+
*
|
|
238
|
+
* Unlike {@link findProjectConfigPath}, it matches ONLY the profile-specific path — it NEVER falls
|
|
239
|
+
* through to a plain `<dir>/<config>` and NEVER falls back to the global config. That strictness is
|
|
240
|
+
* the whole point: it lets a caller distinguish "this named profile really exists" from "a bare
|
|
241
|
+
* config happens to be present / a global config exists," a distinction the loader's fall-through
|
|
242
|
+
* deliberately blurs.
|
|
243
|
+
*
|
|
244
|
+
* PURE PREDICATE — never throws, never calls `exit`, so it can be asked the question without
|
|
245
|
+
* committing to an outcome. {@link initConfig} uses it to enforce that an explicitly-named profile
|
|
246
|
+
* really exists (raising a catchable {@link ConfigDiscoveryError} when it does not), and callers
|
|
247
|
+
* that want to CLASSIFY rather than fail — BATCH-12's identity matrix checks every declared identity
|
|
248
|
+
* up front so one message can name them all — ask it directly. A blank/whitespace-only name counts
|
|
249
|
+
* as "no profile" → `undefined`.
|
|
250
|
+
*
|
|
251
|
+
* @param identityProfile The explicitly-requested identity profile name.
|
|
252
|
+
* @returns The resolved profile config path, or `undefined` when the profile has no config.
|
|
253
|
+
*/
|
|
254
|
+
export function resolveIdentityProfileConfigPath(identityProfile) {
|
|
255
|
+
const profile = identityProfile?.trim();
|
|
256
|
+
if (!profile) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
for (const dir of walkConfigSearchDirs()) {
|
|
260
|
+
const profileDir = resolve(dir, GSLOTH_DIR, GSLOTH_SETTINGS_DIR, profile);
|
|
261
|
+
for (const filename of PROJECT_CONFIG_FORMATS) {
|
|
262
|
+
const candidate = resolve(profileDir, filename);
|
|
263
|
+
if (existsSync(candidate)) {
|
|
264
|
+
return candidate;
|
|
265
|
+
}
|
|
135
266
|
}
|
|
136
|
-
dir = parent;
|
|
137
267
|
}
|
|
138
268
|
return undefined;
|
|
139
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* CFG-36 — the ONE statement of "an explicitly-named identity profile that does not exist", shared
|
|
272
|
+
* by the run path ({@link initConfig}, which raises) and the read path ({@link validateConfig},
|
|
273
|
+
* which records a not-ok layer). Single-sourced deliberately: GS2-29's invariant is that
|
|
274
|
+
* `gth config validate` can never green-light a config a real run refuses, and two copies of this
|
|
275
|
+
* rule is exactly how that invariant rots.
|
|
276
|
+
*
|
|
277
|
+
* Returns the offending profile name, or `undefined` when there is nothing to complain about —
|
|
278
|
+
* no profile named (a blank/whitespace name counts as none, keeping the CFG-8 no-profile path
|
|
279
|
+
* untouched), an explicit `--config` path (which names the file to load and bypasses discovery),
|
|
280
|
+
* or a profile that resolves to its own config.
|
|
281
|
+
*/
|
|
282
|
+
function findUnresolvedExplicitProfile(commandLineConfigOverrides) {
|
|
283
|
+
const profile = commandLineConfigOverrides.identityProfile?.trim();
|
|
284
|
+
if (!profile || commandLineConfigOverrides.customConfigPath) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
return resolveIdentityProfileConfigPath(profile) ? undefined : profile;
|
|
288
|
+
}
|
|
289
|
+
/** The message both paths report for {@link findUnresolvedExplicitProfile}'s failure. */
|
|
290
|
+
function identityProfileNotFoundMessage(profile) {
|
|
291
|
+
return (`identity profile "${profile}" not found: no config file in ` +
|
|
292
|
+
`${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${profile}/ ` +
|
|
293
|
+
`(checked ${PROJECT_CONFIG_FORMATS.join(', ')})`);
|
|
294
|
+
}
|
|
140
295
|
/**
|
|
141
296
|
* Loads the global gsloth config (if present) from the global `~/.gsloth` folder.
|
|
142
297
|
*
|
|
@@ -145,7 +300,7 @@ export function findProjectConfigPath(commandLineConfigOverrides) {
|
|
|
145
300
|
* user-controlled layer (still above {@link DEFAULT_CONFIG}).
|
|
146
301
|
*
|
|
147
302
|
* Lookup order within the global folder, first match wins:
|
|
148
|
-
* `.gsloth.config.json` -> `.gsloth.config.js` -> `.gsloth.config.mjs`
|
|
303
|
+
* `.gsloth.config.json` -> `.gsloth.config.jsonc` -> `.gsloth.config.js` -> `.gsloth.config.mjs`
|
|
149
304
|
*
|
|
150
305
|
* Absence of every variant is a no-op: returns `undefined` so behaviour is unchanged.
|
|
151
306
|
*
|
|
@@ -155,17 +310,26 @@ export function findProjectConfigPath(commandLineConfigOverrides) {
|
|
|
155
310
|
* @returns The raw global config object, or `undefined` when no global config exists.
|
|
156
311
|
*/
|
|
157
312
|
export async function loadGlobalRawConfig() {
|
|
158
|
-
// JSON first (the must-have
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
313
|
+
// JSON/JSONC first (the must-have formats; `.json` wins when both exist — GS2-69).
|
|
314
|
+
for (const filename of JSON_CONFIG_FILENAMES) {
|
|
315
|
+
const jsonPath = getGlobalGslothConfigReadPath(filename);
|
|
316
|
+
if (existsSync(jsonPath)) {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = parseJsonc(readFileSync(jsonPath, 'utf8'), `${filename} (global)`);
|
|
319
|
+
return validateRawConfigLayer(parsed, `${filename} (global)`);
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
// CFG-36 — this catch exists to treat an UNREADABLE global as absent. A global that read
|
|
323
|
+
// fine and is MALFORMED is a hard configuration error (it used to `exit(1)` from inside
|
|
324
|
+
// the validator); swallowing it here would silently downgrade that to "ignoring it" and
|
|
325
|
+
// run under a different config — the exact false-green the throw was introduced to avoid.
|
|
326
|
+
if (isConfigDiscoveryError(e)) {
|
|
327
|
+
throw e;
|
|
328
|
+
}
|
|
329
|
+
displayDebug(e instanceof Error ? e : String(e));
|
|
330
|
+
displayWarning(`Failed to read global config from ${jsonPath}, ignoring it.`);
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
169
333
|
}
|
|
170
334
|
}
|
|
171
335
|
// Then JS / MJS variants (dynamic import of a `configure()` module).
|
|
@@ -178,6 +342,10 @@ export async function loadGlobalRawConfig() {
|
|
|
178
342
|
return validateRawConfigLayer(configured, `${filename} (global)`);
|
|
179
343
|
}
|
|
180
344
|
catch (e) {
|
|
345
|
+
// CFG-36 — see the JSON branch above: a malformed global is a hard error, not an absent one.
|
|
346
|
+
if (isConfigDiscoveryError(e)) {
|
|
347
|
+
throw e;
|
|
348
|
+
}
|
|
181
349
|
displayDebug(e instanceof Error ? e : String(e));
|
|
182
350
|
displayWarning(`Failed to read global config from ${modulePath}, ignoring it.`);
|
|
183
351
|
return undefined;
|
|
@@ -198,6 +366,152 @@ async function applyGlobalConfigBase(projectRawConfig) {
|
|
|
198
366
|
}
|
|
199
367
|
return deepMerge(globalRawConfig, projectRawConfig);
|
|
200
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* GS2-41 — hard cap on the `extends` chain length: a belt-and-suspenders backstop BEHIND the
|
|
371
|
+
* name-cycle guard. Even if a base name somehow failed to register in the visited chain, a chain
|
|
372
|
+
* this long is a misconfiguration and must fail fast rather than recurse without bound.
|
|
373
|
+
*/
|
|
374
|
+
const MAX_EXTENDS_CHAIN_DEPTH = 50;
|
|
375
|
+
/**
|
|
376
|
+
* GS2-73 — the typed failure the `extends` traversal ({@link resolveExtendsChain}) raises on a
|
|
377
|
+
* cycle, a missing base, an over-deep chain, or an unreadable base. It carries the SAME clear,
|
|
378
|
+
* user-facing message the run path prints, so the two consumers can translate one shared failure
|
|
379
|
+
* into their own convention WITHOUT the traversal being forked or the checks duplicated:
|
|
380
|
+
* - the run path ({@link resolveConfigExtends}) → re-raised as a {@link ConfigDiscoveryError}, so
|
|
381
|
+
* the caller classifies it and chooses the exit code (CFG-36),
|
|
382
|
+
* - the read path ({@link validateConfig}) → a `not-ok` layer with this message (collect, never
|
|
383
|
+
* `exit`), so `gth config validate` mirrors what a real run would hit (GS2-29 invariant).
|
|
384
|
+
*/
|
|
385
|
+
class ConfigExtendsError extends Error {
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* GS2-41 — resolve a named profile's `extends` inheritance into a single composed raw config,
|
|
389
|
+
* riding the SAME GS2-1 deep-merge the config LAYERS use (NO second merge engine). When the given
|
|
390
|
+
* profile config declares `extends: "<base>"`, the base profile's config resolves FIRST
|
|
391
|
+
* (recursively — a base may itself extend another, so base-of-base resolves first), then this
|
|
392
|
+
* profile's own fields merge on top with last-wins semantics: the child overrides the base, nested
|
|
393
|
+
* objects merge, arrays REPLACE except the additive-array fields (`allowDirs`, `aiignore.patterns`
|
|
394
|
+
* and the three `approvals` rule lists, see {@link isAdditiveArrayField}) which accumulate
|
|
395
|
+
* base+child. The `extends` key itself is consumed and never leaks into the composed output.
|
|
396
|
+
*
|
|
397
|
+
* A config WITHOUT `extends` is returned UNCHANGED — every non-inheriting config (the vast
|
|
398
|
+
* majority) is untouched and behaves exactly as before.
|
|
399
|
+
*
|
|
400
|
+
* Composition is CONFINED to the profile-dir layer: it produces the single raw config that then
|
|
401
|
+
* acts as the project-file layer the global config underlays and CLI flags overlay, preserving
|
|
402
|
+
* GS2-33's outer precedence `CLI flags > profile (base+child composed) > global > defaults`. It is
|
|
403
|
+
* therefore invoked in {@link initConfig} on the loaded project/profile config BEFORE
|
|
404
|
+
* {@link applyGlobalConfigBase}.
|
|
405
|
+
*
|
|
406
|
+
* The base profile is discovered with {@link resolveIdentityProfileConfigPath} (the SAME strict
|
|
407
|
+
* up-tree profile walk `--profile` uses), so `extends` names a profile exactly as a user selects
|
|
408
|
+
* one; a name with no config dir is a hard, clearly-named error.
|
|
409
|
+
*
|
|
410
|
+
* CYCLE GUARD: the chain of profile NAMES is tracked (seeded with the selected profile's own name);
|
|
411
|
+
* because `extends` is single-valued the chain is linear, so a repeated name — `A extends B extends
|
|
412
|
+
* A`, or a self-extend — is an unambiguous cycle and fails fast with a clear error NAMING the cycle,
|
|
413
|
+
* never infinite-looping / stack-overflowing. A hard {@link MAX_EXTENDS_CHAIN_DEPTH} cap backstops
|
|
414
|
+
* it regardless of how the base path was derived.
|
|
415
|
+
*
|
|
416
|
+
* @param rawConfig the just-loaded, schema-validated raw config that MAY declare `extends`.
|
|
417
|
+
* @param profileLabel the selected profile's own name (for cycle detection + messages); undefined
|
|
418
|
+
* for a plain (non-profile) project config.
|
|
419
|
+
*/
|
|
420
|
+
export async function resolveConfigExtends(rawConfig, profileLabel) {
|
|
421
|
+
try {
|
|
422
|
+
return await composeExtends(rawConfig, profileLabel);
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
// GS2-73 — the traversal RAISES a {@link ConfigExtendsError} rather than exiting inline, so the
|
|
426
|
+
// read side ({@link validateConfig}) can report the same failure without terminating.
|
|
427
|
+
//
|
|
428
|
+
// CFG-36 — the RUN path re-raises it as a {@link ConfigDiscoveryError} instead of printing and
|
|
429
|
+
// calling `exit(1)`. A profile whose `extends` base is missing (or forms a cycle) is a bad
|
|
430
|
+
// profile exactly as a profile with no config at all is, and both must be classifiable by the
|
|
431
|
+
// caller: exiting here from inside a library collapses `gth eval`'s harness-error (exit 2) and
|
|
432
|
+
// product-failure (exit 1) contract onto the same code, which is the collapse this node exists
|
|
433
|
+
// to remove. The CLI's top-level guard prints the same message and exits 1, so what a person at
|
|
434
|
+
// a terminal sees is unchanged.
|
|
435
|
+
//
|
|
436
|
+
// The message already names the profile, the base and the cycle; the optional detail fields are
|
|
437
|
+
// deliberately left unset rather than guessed at. `identityProfile` means "the profile that did
|
|
438
|
+
// not resolve", which for a missing base referenced from another profile is the BASE, not the
|
|
439
|
+
// `profileLabel` in hand here — a wrong value would be worse than an absent one.
|
|
440
|
+
//
|
|
441
|
+
// CFG-47 — the original travels as `cause`. Re-raising by message alone made the
|
|
442
|
+
// {@link ConfigExtendsError} (and any stack under it) unrecoverable from the wrapper, so a
|
|
443
|
+
// consumer that wanted the underlying failure had only the rendered string. The in-repo sibling
|
|
444
|
+
// ({@link MissingProviderKeyError} in {@link tryJsonConfig}) already carries it.
|
|
445
|
+
if (e instanceof ConfigExtendsError) {
|
|
446
|
+
throw new ConfigDiscoveryError(e.message, {}, { cause: e });
|
|
447
|
+
}
|
|
448
|
+
throw e;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* GS2-73 — seed the `extends` chain from the selected profile's own name and run the throwing
|
|
453
|
+
* traversal ({@link resolveExtendsChain}). Shared by BOTH consumers so the walk and its
|
|
454
|
+
* cycle/missing-base checks live in ONE place: the run-path {@link resolveConfigExtends} (which
|
|
455
|
+
* re-raises a {@link ConfigExtendsError} as a {@link ConfigDiscoveryError}) and the read-path
|
|
456
|
+
* {@link validateConfig} (which records it as a not-ok layer). Propagates the typed error to its
|
|
457
|
+
* caller; the caller owns the reporting convention.
|
|
458
|
+
*/
|
|
459
|
+
async function composeExtends(rawConfig, profileLabel) {
|
|
460
|
+
const seed = profileLabel?.trim();
|
|
461
|
+
return resolveExtendsChain(rawConfig, seed ? [seed] : []);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The recursive worker for {@link resolveConfigExtends}. `chain` is the ordered list of profile
|
|
465
|
+
* names already being resolved (the selected profile first, then each `extends` base as it is
|
|
466
|
+
* descended into) — used both for the name-based cycle guard and to render the cycle in the error.
|
|
467
|
+
*
|
|
468
|
+
* GS2-73 — on any hard failure (cycle, over-deep chain, missing base, unreadable base) it RAISES a
|
|
469
|
+
* {@link ConfigExtendsError} rather than printing + `exit`ing inline, so the same traversal serves
|
|
470
|
+
* both the run path and the read-side `validateConfig` (each translates the error its own way).
|
|
471
|
+
*/
|
|
472
|
+
async function resolveExtendsChain(rawConfig, chain) {
|
|
473
|
+
const baseName = typeof rawConfig.extends === 'string' ? rawConfig.extends.trim() : undefined;
|
|
474
|
+
if (!baseName) {
|
|
475
|
+
return rawConfig;
|
|
476
|
+
}
|
|
477
|
+
// Cycle guard (name-based): a base already present in the chain we are resolving loops back on
|
|
478
|
+
// itself. Fail fast, naming the cycle — never recurse without bound. GS2-73 — raise the shared
|
|
479
|
+
// {@link ConfigExtendsError}; the caller (run path vs. `validateConfig`) owns how it is surfaced.
|
|
480
|
+
if (chain.includes(baseName)) {
|
|
481
|
+
throw new ConfigExtendsError(`Profile inheritance cycle detected: ${[...chain, baseName].join(' -> ')}. ` +
|
|
482
|
+
`A profile's "extends" chain must not refer back to itself.`);
|
|
483
|
+
}
|
|
484
|
+
// Depth backstop behind the name guard — a chain this long can only be a misconfiguration.
|
|
485
|
+
if (chain.length >= MAX_EXTENDS_CHAIN_DEPTH) {
|
|
486
|
+
throw new ConfigExtendsError(`Profile inheritance chain exceeds the maximum depth of ${MAX_EXTENDS_CHAIN_DEPTH}: ` +
|
|
487
|
+
`${[...chain, baseName].join(' -> ')}. This is almost certainly a misconfiguration.`);
|
|
488
|
+
}
|
|
489
|
+
const basePath = resolveIdentityProfileConfigPath(baseName);
|
|
490
|
+
if (!basePath) {
|
|
491
|
+
const from = chain.length > 0 ? ` (referenced from "${chain[chain.length - 1]}")` : '';
|
|
492
|
+
throw new ConfigExtendsError(`Profile "${baseName}" referenced by "extends"${from} was not found: no config file in ` +
|
|
493
|
+
`${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${baseName}/ (checked ${PROJECT_CONFIG_FORMATS.join(', ')}).`);
|
|
494
|
+
}
|
|
495
|
+
let baseRaw;
|
|
496
|
+
try {
|
|
497
|
+
baseRaw = await readRawConfigAtPath(basePath);
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
displayDebug(e instanceof Error ? e : String(e));
|
|
501
|
+
throw new ConfigExtendsError(`Failed to read base profile "${baseName}" from ${basePath}.`);
|
|
502
|
+
}
|
|
503
|
+
// Validate the base layer exactly as the project layer is validated (deprecated-shape reject,
|
|
504
|
+
// unknown-key warn, type-mismatch fail) so a broken base surfaces loudly rather than silently.
|
|
505
|
+
const validatedBase = validateRawConfigLayer(baseRaw, `${baseName} (extends base)`);
|
|
506
|
+
// Resolve the base's OWN extends first (base-of-base first), THEN merge this profile's delta on
|
|
507
|
+
// top via the existing GS2-1 deep-merge (child = source, so it wins; additive-array fields at the
|
|
508
|
+
// config root accumulate).
|
|
509
|
+
const resolvedBase = await resolveExtendsChain(validatedBase, [...chain, baseName]);
|
|
510
|
+
// Consume `extends` so it never leaks into the composed output, then merge child over base.
|
|
511
|
+
const childDelta = { ...rawConfig };
|
|
512
|
+
delete childDelta.extends;
|
|
513
|
+
return deepMerge(resolvedBase, childDelta);
|
|
514
|
+
}
|
|
201
515
|
/**
|
|
202
516
|
* ORDERING INVARIANT (GS2-11): detection ({@link hasProjectConfig}/{@link hasAnyConfig}) MUST run
|
|
203
517
|
* before {@link initConfig} in a given process. Both resolve cwd-level candidates via
|
|
@@ -209,7 +523,7 @@ async function applyGlobalConfigBase(projectRawConfig) {
|
|
|
209
523
|
* order is ever introduced, decouple discovery's cwd-branch from `getProjectDir()`.
|
|
210
524
|
*/
|
|
211
525
|
/**
|
|
212
|
-
* Returns true when a project-level config file (json/js/mjs) exists for the given
|
|
526
|
+
* Returns true when a project-level config file (json/jsonc/js/mjs) exists for the given
|
|
213
527
|
* overrides. Honours `customConfigPath` and the active identity profile so the check
|
|
214
528
|
* matches exactly what {@link initConfig} would attempt to load.
|
|
215
529
|
*
|
|
@@ -221,7 +535,7 @@ export function hasProjectConfig(commandLineConfigOverrides) {
|
|
|
221
535
|
}
|
|
222
536
|
/**
|
|
223
537
|
* CFG-10 — true when ANY usable configuration is present, either a project config file
|
|
224
|
-
* (json/js/mjs) or a standalone global config (`~/.gsloth/.gsloth.config.*`). When this
|
|
538
|
+
* (json/jsonc/js/mjs) or a standalone global config (`~/.gsloth/.gsloth.config.*`). When this
|
|
225
539
|
* returns false the caller should run the first-run dialog instead of erroring.
|
|
226
540
|
*
|
|
227
541
|
* Reuses CFG-8's project + global detection so the two paths can never disagree.
|
|
@@ -232,6 +546,77 @@ export async function hasAnyConfig(commandLineConfigOverrides) {
|
|
|
232
546
|
}
|
|
233
547
|
return (await loadGlobalRawConfig()) !== undefined;
|
|
234
548
|
}
|
|
549
|
+
/**
|
|
550
|
+
* CFG-37 — the layered value of the top-level `tui` config key, read BEFORE a session picks its
|
|
551
|
+
* surface. `chat`/`code` choose between the Ink TUI and the readline session in the app's
|
|
552
|
+
* `startSession` dispatcher, and each surface then loads its own config via {@link initConfig} — so
|
|
553
|
+
* at the moment of the choice there is no resolved {@link GthConfig} to consult, and this is the
|
|
554
|
+
* seam that supplies the one key the choice needs.
|
|
555
|
+
*
|
|
556
|
+
* Layering matches a run: the discovered PROJECT layer wins over the GLOBAL one, and a layer that
|
|
557
|
+
* does not set `tui` defers to the next rather than overriding it with `undefined`. A scalar needs
|
|
558
|
+
* no deep merge, so this reads the two layers and picks — it does not fork the merge engine.
|
|
559
|
+
*
|
|
560
|
+
* QUIET and fail-soft for the layers it reads ITSELF: it does not validate them and does not
|
|
561
|
+
* `exit` on a malformed one, because the caller runs moments before {@link initConfig}, which
|
|
562
|
+
* validates every layer and reports the very same problem — warning twice about one file is worse
|
|
563
|
+
* than not warning here. A failed read resolves to `undefined`, i.e. "nobody set it", and the
|
|
564
|
+
* surface auto-detects exactly as it does for a run with no config.
|
|
565
|
+
*
|
|
566
|
+
* ONE EXCEPTION, and it is deliberate: a `tui` may be INHERITED through a GS2-41 profile `extends`
|
|
567
|
+
* chain, so this walks that chain via the SHARED {@link resolveConfigExtends} rather than forking
|
|
568
|
+
* it — and that traversal owns its own reporting. It validates each base layer (so a base's own
|
|
569
|
+
* unknown-key warning can appear here as well as from `initConfig`) and hard-`exit`s on a cycle, a
|
|
570
|
+
* missing base or a malformed base. What the user sees is unchanged — `initConfig` exits on the
|
|
571
|
+
* same chain with the same message a moment later — but it now happens EARLIER, at surface
|
|
572
|
+
* selection rather than at config load. Forking the walk to silence it would mean a second
|
|
573
|
+
* inheritance engine; ignoring `extends` would mean silently dropping an inherited `tui`.
|
|
574
|
+
*
|
|
575
|
+
* Ordering: this is DETECTION, so per the GS2-11 invariant above it must run before any
|
|
576
|
+
* {@link initConfig} in the process — as it does, alongside {@link hasAnyConfig} in `startSession`.
|
|
577
|
+
*/
|
|
578
|
+
export async function loadConfiguredTui(commandLineConfigOverrides) {
|
|
579
|
+
const projectTui = await readProjectConfiguredTui(commandLineConfigOverrides);
|
|
580
|
+
if (projectTui !== undefined) {
|
|
581
|
+
return projectTui;
|
|
582
|
+
}
|
|
583
|
+
const globalRaw = await loadGlobalRawConfigUnvalidated();
|
|
584
|
+
const globalTui = globalRaw?.raw.tui;
|
|
585
|
+
return typeof globalTui === 'boolean' ? globalTui : undefined;
|
|
586
|
+
}
|
|
587
|
+
/** The PROJECT layer's `tui`, or undefined when there is no project config or it does not set it. */
|
|
588
|
+
async function readProjectConfiguredTui(commandLineConfigOverrides) {
|
|
589
|
+
const discovered = findProjectConfigPath(commandLineConfigOverrides);
|
|
590
|
+
if (!discovered) {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
let raw;
|
|
594
|
+
try {
|
|
595
|
+
raw = await readRawConfigAtPath(discovered.path);
|
|
596
|
+
}
|
|
597
|
+
catch (e) {
|
|
598
|
+
// Quiet by design — see the note on loadConfiguredTui. initConfig reports this same file.
|
|
599
|
+
displayDebug(e instanceof Error ? e : String(e));
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
// GS2-41 — a named profile may inherit `tui` from the profile it extends, so compose the chain
|
|
603
|
+
// the way a run does, for EVERY config format. `initConfig` resolves `extends` on both of its
|
|
604
|
+
// branches (the JSON one below and {@link tryModuleConfig}), and `validateConfig` walks it with
|
|
605
|
+
// no format gate at all — so a format gate here would answer `undefined` for a `.js`/`.mjs`
|
|
606
|
+
// profile whose `tui` is inherited while the run composes it, which is precisely the
|
|
607
|
+
// reader-vs-run divergence this seam exists to prevent.
|
|
608
|
+
//
|
|
609
|
+
// Deliberately NOT inside the try above: a cycle, a missing base and a MALFORMED base all raise a
|
|
610
|
+
// {@link ConfigDiscoveryError} that must reach the top level to be reported. The try above exists
|
|
611
|
+
// to treat an unreadable config as "no configured tui"; extending it over these would swallow a
|
|
612
|
+
// hard configuration error and let the surface be selected from a config the run itself refuses
|
|
613
|
+
// to load. This reader runs BEFORE the TUI/readline choice, so the error surfaces here rather than
|
|
614
|
+
// through `createTuiSession` — either way it is reported once, by the CLI's top-level guard.
|
|
615
|
+
if (typeof raw.extends === 'string') {
|
|
616
|
+
raw = await resolveConfigExtends(raw, commandLineConfigOverrides.identityProfile);
|
|
617
|
+
}
|
|
618
|
+
return typeof raw.tui === 'boolean' ? raw.tui : undefined;
|
|
619
|
+
}
|
|
235
620
|
/**
|
|
236
621
|
* Initialize configuration by loading from available config files
|
|
237
622
|
* @returns The loaded GthConfig
|
|
@@ -239,7 +624,13 @@ export async function hasAnyConfig(commandLineConfigOverrides) {
|
|
|
239
624
|
export async function initConfig(commandLineConfigOverrides) {
|
|
240
625
|
if (commandLineConfigOverrides.customConfigPath &&
|
|
241
626
|
!existsSync(commandLineConfigOverrides.customConfigPath)) {
|
|
242
|
-
|
|
627
|
+
// CFG-47 — a {@link ConfigDiscoveryError}, not a plain `Error`. An explicitly named `-c` path
|
|
628
|
+
// that is not there is the same "this configuration cannot be used" failure as a named profile
|
|
629
|
+
// with no config, and it reaches the same two consumers: the CLI's top-level guard (prints the
|
|
630
|
+
// message, exits 1) and `gth eval` (harness error, exit 2). As a plain Error it was invisible to
|
|
631
|
+
// BOTH — it fell through the guard to the crash handler, so `gth -c <missing path> code` printed
|
|
632
|
+
// a false "TUI unavailable … falling back to the readline session" and then a crash snapshot.
|
|
633
|
+
throw new ConfigDiscoveryError(`Provided manual config "${commandLineConfigOverrides.customConfigPath}" does not exist`, { sourceLabel: commandLineConfigOverrides.customConfigPath });
|
|
243
634
|
}
|
|
244
635
|
// Clear the project root BEFORE discovery. Discovery and detection must resolve against cwd,
|
|
245
636
|
// and the up-tree walk itself goes through getGslothConfigReadPath -> getProjectDir(); clearing
|
|
@@ -251,6 +642,26 @@ export async function initConfig(commandLineConfigOverrides) {
|
|
|
251
642
|
// from cwd to the stop boundary (see findProjectConfigPath). Detection and loading share this
|
|
252
643
|
// resolver, and the discovered dir becomes the base for the per-format cascade below.
|
|
253
644
|
const discovered = findProjectConfigPath(commandLineConfigOverrides);
|
|
645
|
+
// GS2-62 / CFG-36 — an EXPLICITLY named identity profile (`-i <name>` / eval `--judge <name>`)
|
|
646
|
+
// must resolve to its OWN config; it must never silently run under some OTHER config. That is a
|
|
647
|
+
// false-green trap: `gth -i typo …` would run under the wrong model while appearing to use the
|
|
648
|
+
// named profile — in an authorization/eval context, hiding a real misconfiguration.
|
|
649
|
+
//
|
|
650
|
+
// Checked with the STRICT resolver, NOT with `discovered`. `findProjectConfigPath` deliberately
|
|
651
|
+
// falls back to a plain `<dir>/<config>` when the named profile has no config of its own (see its
|
|
652
|
+
// note, and the "Case C" spec in config.uptree.spec.ts), so gating on `!discovered` only catches
|
|
653
|
+
// the case where NO config exists anywhere — a project that has a plain config would sail past it
|
|
654
|
+
// and load that instead. `resolveIdentityProfileConfigPath` never falls through, which is what
|
|
655
|
+
// makes this check see the case the discovery gate cannot.
|
|
656
|
+
//
|
|
657
|
+
// The rule itself lives in findUnresolvedExplicitProfile, shared with `validateConfig` so the
|
|
658
|
+
// validator can never green-light a profile a run refuses (GS2-29).
|
|
659
|
+
const explicitProfile = findUnresolvedExplicitProfile(commandLineConfigOverrides);
|
|
660
|
+
if (explicitProfile) {
|
|
661
|
+
throw new ConfigDiscoveryError(identityProfileNotFoundMessage(explicitProfile), {
|
|
662
|
+
identityProfile: explicitProfile,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
254
665
|
const baseDir = discovered?.dir ?? getCurrentWorkDir();
|
|
255
666
|
// Set the project root for post-config, project-relative artifact resolution (guidelines,
|
|
256
667
|
// prompts, .gsloth-settings, outputs). up-tree and --config both set it here; a global-only /
|
|
@@ -258,12 +669,22 @@ export async function initConfig(commandLineConfigOverrides) {
|
|
|
258
669
|
// Safe for the in-function load below: when discovered.dir === cwd getProjectDir() is unchanged,
|
|
259
670
|
// and when it is an ancestor resolveConfigPath takes its explicit-dir branch (never getProjectDir).
|
|
260
671
|
setProjectDir(discovered?.dir);
|
|
672
|
+
// GS2-69 — prefer the DISCOVERED path when it is a JSON-family file, so a discovered
|
|
673
|
+
// `.gsloth.config.jsonc` reaches the parseJsonc branch below (re-resolving only the `.json`
|
|
674
|
+
// name here would miss it). When discovery found a module config (or nothing), fall back to
|
|
675
|
+
// resolving the `.json` name — it won't exist, so the module fall-through below is unchanged.
|
|
676
|
+
const discoveredJsonConfigPath = discovered && isJsonConfigPath(discovered.path) ? discovered.path : undefined;
|
|
261
677
|
const jsonConfigPath = commandLineConfigOverrides.customConfigPath ??
|
|
678
|
+
discoveredJsonConfigPath ??
|
|
262
679
|
resolveConfigPath(baseDir, USER_PROJECT_CONFIG_JSON, commandLineConfigOverrides.identityProfile);
|
|
263
680
|
// CFG-8 — when no project config file of any format exists (anywhere up-tree), fall back to a
|
|
264
681
|
// standalone global config (loaded alone) before erroring. Project config still takes
|
|
265
682
|
// precedence: this branch only runs when there is no project file to apply the global under.
|
|
266
683
|
if (!discovered) {
|
|
684
|
+
// The explicitly-named-profile guard that used to sit here now runs BEFORE discovery is
|
|
685
|
+
// consulted at all (see above), because gating it on `!discovered` missed the case where a
|
|
686
|
+
// plain project config exists. A run with no profile reaches the global fallback below exactly
|
|
687
|
+
// as before.
|
|
267
688
|
const globalRawConfig = await loadGlobalRawConfig();
|
|
268
689
|
if (globalRawConfig) {
|
|
269
690
|
if (globalRawConfig.llm &&
|
|
@@ -272,37 +693,61 @@ export async function initConfig(commandLineConfigOverrides) {
|
|
|
272
693
|
// Route the global config through the same path the project JSON uses.
|
|
273
694
|
return await tryJsonConfig(globalRawConfig, commandLineConfigOverrides);
|
|
274
695
|
}
|
|
275
|
-
|
|
276
|
-
exit
|
|
277
|
-
//
|
|
278
|
-
|
|
696
|
+
// CFG-47 — a global config that read fine and does not define `llm.type` is "config present
|
|
697
|
+
// and unusable", the same class CFG-36 converted: raise it, let the caller choose the exit
|
|
698
|
+
// code. The message is the one this branch has always printed, so the CLI's top-level guard
|
|
699
|
+
// reproduces the previous output exactly.
|
|
700
|
+
throw new ConfigDiscoveryError('Global configuration found but it is not in valid format. Should at least define llm.type', { sourceLabel: `${USER_PROJECT_CONFIG_JSON} (global)` });
|
|
279
701
|
}
|
|
280
702
|
}
|
|
281
|
-
// Try loading the JSON config file first
|
|
282
|
-
|
|
703
|
+
// Try loading the JSON/JSONC config file first (GS2-69 — an explicit `-c foo.jsonc` takes
|
|
704
|
+
// this branch too instead of falling into the `configure()`-module importer).
|
|
705
|
+
if (isJsonConfigPath(jsonConfigPath) && existsSync(jsonConfigPath)) {
|
|
706
|
+
const jsonConfigName = jsonConfigPath.endsWith('.jsonc')
|
|
707
|
+
? USER_PROJECT_CONFIG_JSONC
|
|
708
|
+
: USER_PROJECT_CONFIG_JSON;
|
|
283
709
|
try {
|
|
284
710
|
// Validate the project config layer against the Zod schema (single source of
|
|
285
711
|
// truth): pre-map deprecated names, warn on unknown top-level keys, and fail
|
|
286
712
|
// with a friendly, path-scoped message on a genuine type mismatch.
|
|
287
|
-
const projectJsonConfig = validateRawConfigLayer(
|
|
713
|
+
const projectJsonConfig = validateRawConfigLayer(parseJsonc(readFileSync(jsonConfigPath, 'utf8'), jsonConfigName), jsonConfigName);
|
|
714
|
+
// GS2-41 — compose profile inheritance (`extends`) WITHIN the profile-dir layer BEFORE the
|
|
715
|
+
// global base underlays it, so precedence stays CLI > profile(base+child) > global > defaults.
|
|
716
|
+
// No-op (returns the config unchanged) when there is no `extends`.
|
|
717
|
+
const composedProjectConfig = (await resolveConfigExtends(projectJsonConfig, commandLineConfigOverrides.identityProfile));
|
|
288
718
|
// Apply global config as the base layer (project config wins on conflicts).
|
|
289
|
-
const jsonConfig = (await applyGlobalConfigBase(
|
|
719
|
+
const jsonConfig = (await applyGlobalConfigBase(composedProjectConfig));
|
|
290
720
|
// If the config has an LLM with a type, create the appropriate LLM instance
|
|
291
721
|
if (jsonConfig.llm && typeof jsonConfig.llm === 'object' && 'type' in jsonConfig.llm) {
|
|
292
722
|
return await tryJsonConfig(jsonConfig, commandLineConfigOverrides);
|
|
293
723
|
}
|
|
294
724
|
else {
|
|
295
|
-
|
|
296
|
-
|
|
725
|
+
// CFG-47 — same class, same treatment. Deliberately a ConfigDiscoveryError and not a plain
|
|
726
|
+
// one: the catch below re-raises this class and swallows everything else into the next
|
|
727
|
+
// FORMAT, so a plain throw here would silently fall through to the module loader and end in
|
|
728
|
+
// the terminal "No configuration file found" — hiding the real, clearly-worded problem.
|
|
297
729
|
// noinspection ExceptionCaughtLocallyJS
|
|
298
|
-
|
|
299
|
-
// noinspection ExceptionCaughtLocallyJS
|
|
300
|
-
throw new Error('Unexpected error occurred.');
|
|
730
|
+
throw new ConfigDiscoveryError(`${jsonConfigPath} is not in valid format. Should at least define llm.type`, { sourceLabel: jsonConfigName });
|
|
301
731
|
}
|
|
302
732
|
}
|
|
303
733
|
catch (e) {
|
|
734
|
+
// CFG-35 — the format fall-through must not swallow a resolvable-config-but-no-API-key
|
|
735
|
+
// failure. This catch exists to move on to the next config FORMAT when the JSON layer could
|
|
736
|
+
// not be read; a config that read fine and named a provider we have no key for is not a
|
|
737
|
+
// read failure, and falling through would end in the terminal "No configuration file found"
|
|
738
|
+
// failure — which blames a missing config for what is actually a missing key, burying the
|
|
739
|
+
// clearly-worded message this branch already has.
|
|
740
|
+
if (isMissingProviderKeyError(e)) {
|
|
741
|
+
throw e;
|
|
742
|
+
}
|
|
743
|
+
// CFG-36 — same reasoning for a MALFORMED config: the config read fine and is invalid, which
|
|
744
|
+
// is a hard error the user must see. Falling through to the next FORMAT would end in the
|
|
745
|
+
// terminal "No configuration file found" failure, hiding the real (clearly-worded) problem.
|
|
746
|
+
if (isConfigDiscoveryError(e)) {
|
|
747
|
+
throw e;
|
|
748
|
+
}
|
|
304
749
|
displayDebug(e instanceof Error ? e : String(e));
|
|
305
|
-
displayError(`Failed to read config from ${
|
|
750
|
+
displayError(`Failed to read config from ${jsonConfigName}, will try other formats.`);
|
|
306
751
|
// Continue to try other formats
|
|
307
752
|
return await tryModuleConfig('js', commandLineConfigOverrides, baseDir);
|
|
308
753
|
}
|
|
@@ -334,7 +779,8 @@ const MODULE_CONFIG_EXT = {
|
|
|
334
779
|
* `tryJsConfig`/`tryMjsConfig` helpers into one format-parameterized loader.
|
|
335
780
|
*
|
|
336
781
|
* NOTE: the terminal "No configuration file found" message intentionally advertises only
|
|
337
|
-
* json/js/mjs (the historical, asserted wording) — `.ts` is a quiet lowest-precedence fallback
|
|
782
|
+
* json/js/mjs (the historical, asserted wording) — `.ts` is a quiet lowest-precedence fallback,
|
|
783
|
+
* and `.jsonc` (GS2-69) is a quiet spelling variant of the advertised `.json`.
|
|
338
784
|
*/
|
|
339
785
|
async function tryModuleConfig(format, commandLineConfigOverrides, baseDir) {
|
|
340
786
|
const filename = MODULE_CONFIG_FILENAME[format];
|
|
@@ -346,19 +792,31 @@ async function tryModuleConfig(format, commandLineConfigOverrides, baseDir) {
|
|
|
346
792
|
try {
|
|
347
793
|
const i = await importExternalFile(configPath);
|
|
348
794
|
const customConfig = validateRawConfigLayer((await i.configure()), filename);
|
|
349
|
-
|
|
795
|
+
// GS2-41 — compose profile inheritance (`extends`) before the global base underlays it
|
|
796
|
+
// (parity with the JSON branch); no-op when there is no `extends`.
|
|
797
|
+
const composedConfig = await resolveConfigExtends(customConfig, commandLineConfigOverrides.identityProfile);
|
|
798
|
+
const mergedWithGlobal = await applyGlobalConfigBase(composedConfig);
|
|
350
799
|
return await mergeConfig(mergedWithGlobal, commandLineConfigOverrides);
|
|
351
800
|
}
|
|
352
801
|
catch (e) {
|
|
802
|
+
// CFG-36 — a config that read fine and is MALFORMED (or names an unresolvable profile) is a
|
|
803
|
+
// hard error, not a reason to try the next format. Re-raise before the fall-through, exactly
|
|
804
|
+
// as the JSON branch in initConfig does.
|
|
805
|
+
if (isConfigDiscoveryError(e)) {
|
|
806
|
+
throw e;
|
|
807
|
+
}
|
|
353
808
|
displayDebug(e instanceof Error ? e : String(e));
|
|
354
809
|
if (nextFormat) {
|
|
355
810
|
displayError(`Failed to read config from ${filename}, will try other formats.`);
|
|
356
811
|
// Continue to try other formats
|
|
357
812
|
return await tryModuleConfig(nextFormat, commandLineConfigOverrides, baseDir);
|
|
358
813
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
814
|
+
// CFG-47 — the last format in the chain failed to read. The config IS present and cannot be
|
|
815
|
+
// used, so this raises like the rest of the class. Both lines are kept in the one message:
|
|
816
|
+
// the first names the file that failed, the second is the advice, and the guard prints them
|
|
817
|
+
// together exactly as the two `displayError` calls did.
|
|
818
|
+
throw new ConfigDiscoveryError(`Failed to read config from ${filename}.\n` +
|
|
819
|
+
`No valid configuration found. Please create a valid configuration file.`, { sourceLabel: filename });
|
|
362
820
|
}
|
|
363
821
|
}
|
|
364
822
|
else if (nextFormat) {
|
|
@@ -366,14 +824,19 @@ async function tryModuleConfig(format, commandLineConfigOverrides, baseDir) {
|
|
|
366
824
|
return await tryModuleConfig(nextFormat, commandLineConfigOverrides, baseDir);
|
|
367
825
|
}
|
|
368
826
|
else {
|
|
369
|
-
// No config files found
|
|
370
|
-
|
|
827
|
+
// No config files found.
|
|
828
|
+
//
|
|
829
|
+
// CFG-47 — the terminal "nothing to load" exit, raised rather than exited. It is the same class
|
|
830
|
+
// for the same reason as the rest: the caller must classify it. `gth eval` in particular needs
|
|
831
|
+
// "the harness has no config" to be a harness error (exit 2), not the exit 1 that means the SUT
|
|
832
|
+
// ran and failed. The CLI's top-level guard prints this message and exits 1, so a person at a
|
|
833
|
+
// terminal sees exactly what they saw before — and `startSession` still runs the first-run
|
|
834
|
+
// dialog ahead of this on an interactive TTY with no config anywhere (CFG-10), so the ordinary
|
|
835
|
+
// unconfigured path never reaches here.
|
|
836
|
+
throw new ConfigDiscoveryError('No configuration file found. Please create one of: ' +
|
|
371
837
|
`${USER_PROJECT_CONFIG_JSON}, ${USER_PROJECT_CONFIG_JS}, or ${USER_PROJECT_CONFIG_MJS} ` +
|
|
372
838
|
'in your project directory.');
|
|
373
|
-
exit(1);
|
|
374
839
|
}
|
|
375
|
-
// This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
|
|
376
|
-
throw new Error('Unexpected error occurred.');
|
|
377
840
|
}
|
|
378
841
|
/**
|
|
379
842
|
* Process JSON LLM config by creating the appropriate LLM instance
|
|
@@ -387,8 +850,12 @@ export async function tryJsonConfig(jsonConfig, commandLineConfigOverrides) {
|
|
|
387
850
|
// Get the type of LLM (e.g. 'vertexai', 'anthropic') - this should exist
|
|
388
851
|
const llmType = jsonConfig.llm.type;
|
|
389
852
|
if (!llmType) {
|
|
390
|
-
|
|
391
|
-
|
|
853
|
+
// CFG-47 — same class, raised rather than exited. Note this throw is caught by THIS
|
|
854
|
+
// function's own catch below, which re-raises the class before its "Error processing LLM
|
|
855
|
+
// config" wrapper; without that re-raise the clear message here would be re-worded and then
|
|
856
|
+
// exited on anyway.
|
|
857
|
+
// noinspection ExceptionCaughtLocallyJS
|
|
858
|
+
throw new ConfigDiscoveryError('LLM type not specified in config.');
|
|
392
859
|
}
|
|
393
860
|
// Get the configuration for the specific LLM type
|
|
394
861
|
const llmConfig = jsonConfig.llm;
|
|
@@ -396,6 +863,12 @@ export async function tryJsonConfig(jsonConfig, commandLineConfigOverrides) {
|
|
|
396
863
|
// Necessary to avoid https://github.com/langchain-ai/langchainjs/issues/8705
|
|
397
864
|
llmConfig.verbose = commandLineConfigOverrides.verbose;
|
|
398
865
|
}
|
|
866
|
+
if (commandLineConfigOverrides.model) {
|
|
867
|
+
// BATCH-1 fix — see CommandLineConfigOverrides.model: retarget the raw LLM *spec* before
|
|
868
|
+
// the provider builds an instance from it, rather than cloning/mutating an already-built
|
|
869
|
+
// instance. `gth batch --models` is the only current caller.
|
|
870
|
+
llmConfig.model = commandLineConfigOverrides.model;
|
|
871
|
+
}
|
|
399
872
|
// Import the appropriate config module
|
|
400
873
|
const configModule = await import(`#src/providers/${llmType}.js`);
|
|
401
874
|
if (configModule.processJsonConfig) {
|
|
@@ -409,26 +882,63 @@ export async function tryJsonConfig(jsonConfig, commandLineConfigOverrides) {
|
|
|
409
882
|
}
|
|
410
883
|
}
|
|
411
884
|
else {
|
|
412
|
-
|
|
413
|
-
|
|
885
|
+
// CFG-47 — the node called this site the one that is "genuinely a different case". Measured,
|
|
886
|
+
// it is not: `#src/providers/<type>.js` resolves against the SAME directory that holds five
|
|
887
|
+
// real modules with no `processJsonConfig` export (modelCatalog, modelDiscovery,
|
|
888
|
+
// geminiThinking, geminiSchemaSanitizer, configurationPassthrough), so `llm.type:
|
|
889
|
+
// "modelCatalog"` reaches here from an ordinary config file — verified by resolving that
|
|
890
|
+
// specifier at runtime. It is therefore user-reachable "config present and unusable" like
|
|
891
|
+
// the rest, and is converted with them. Kept on the warning wording it has always had.
|
|
892
|
+
// noinspection ExceptionCaughtLocallyJS
|
|
893
|
+
throw new ConfigDiscoveryError(`Config module for ${llmType} does not have processJsonConfig function.`);
|
|
414
894
|
}
|
|
415
895
|
}
|
|
416
896
|
else {
|
|
417
|
-
|
|
418
|
-
|
|
897
|
+
// noinspection ExceptionCaughtLocallyJS
|
|
898
|
+
throw new ConfigDiscoveryError('No LLM configuration found in config.');
|
|
419
899
|
}
|
|
420
900
|
}
|
|
421
901
|
catch (e) {
|
|
902
|
+
// CFG-47 — the four sites above raise INTO this catch, so it must re-raise the class before its
|
|
903
|
+
// own wrapper reaches them. Without this, a clear "LLM type not specified in config." would be
|
|
904
|
+
// re-worded as "Error processing LLM config: …" and exited on anyway — the local catch would
|
|
905
|
+
// undo the conversion three lines after it happened. Same shape as the re-raises in
|
|
906
|
+
// `initConfig` and `tryModuleConfig`.
|
|
907
|
+
if (isConfigDiscoveryError(e)) {
|
|
908
|
+
throw e;
|
|
909
|
+
}
|
|
422
910
|
if (e instanceof Error && e.message.includes('Cannot find module')) {
|
|
423
|
-
|
|
911
|
+
// CFG-47 — a configured `llm.type` we have no provider module for is a config error, not a
|
|
912
|
+
// process exit. `{ cause: e }` keeps the resolver's own failure reachable.
|
|
913
|
+
throw new ConfigDiscoveryError(`LLM type '${jsonConfig.llm.type}' not supported.`, {}, { cause: e });
|
|
424
914
|
}
|
|
425
915
|
else {
|
|
426
|
-
|
|
916
|
+
const message = `Error processing LLM config: ${e instanceof Error ? e.message : String(e)}`;
|
|
917
|
+
// CFG-35 — a provider that could not be built because NO API key is resolvable for it is a
|
|
918
|
+
// CATCHABLE error, not a process exit. Several provider SDKs validate the key in their
|
|
919
|
+
// constructor (groq, anthropic, xai, deepseek, openrouter) and two of our factories check it
|
|
920
|
+
// themselves (openrouter, huggingface); every one of those throws landed here and killed the
|
|
921
|
+
// process, so a multi-identity `gth eval` run — the case that is DESIGNED for partial
|
|
922
|
+
// secrets — lost every other identity's result and wrote no artifacts at all.
|
|
923
|
+
//
|
|
924
|
+
// Whether this is a missing key is decided from the environment, never from the SDK's
|
|
925
|
+
// wording (see findMissingProviderKey). Imported dynamically because it is an error-path
|
|
926
|
+
// question and a static import of the provider layer would put a cycle through the config
|
|
927
|
+
// barrel. The message is exactly the string this branch has always printed, so a caller that
|
|
928
|
+
// reports `error.message` produces the same output; the provider and variable names ride as
|
|
929
|
+
// FIELDS, which is what a report consumer needs to tell a missing key from an outage.
|
|
930
|
+
const { findMissingProviderKey } = await import('#src/providers/modelDiscovery.js');
|
|
931
|
+
const missingKey = findMissingProviderKey(jsonConfig.llm?.type, jsonConfig.llm);
|
|
932
|
+
if (missingKey) {
|
|
933
|
+
throw new MissingProviderKeyError(message, missingKey, { cause: e });
|
|
934
|
+
}
|
|
935
|
+
// CFG-47 — and when it is NOT a missing key, raise too. This site's immediate neighbour three
|
|
936
|
+
// lines up already threw, so the file disagreed with itself about whether a provider that
|
|
937
|
+
// could not be built terminates the process; it does not. The message is unchanged and
|
|
938
|
+
// `{ cause: e }` keeps the provider's own error reachable, as the missing-key branch does.
|
|
939
|
+
throw new ConfigDiscoveryError(message, {}, { cause: e });
|
|
427
940
|
}
|
|
428
|
-
exit(1);
|
|
429
941
|
}
|
|
430
|
-
// This throw is unreachable due to exit(1) above, but satisfies TS type analysis and prevents tests from exiting
|
|
431
|
-
throw new Error('Unexpected error occurred.');
|
|
432
942
|
}
|
|
433
943
|
/**
|
|
434
944
|
* Config array fields whose values ADD UP across merge layers (global → project): the
|
|
@@ -440,33 +950,96 @@ export async function tryJsonConfig(jsonConfig, commandLineConfigOverrides) {
|
|
|
440
950
|
* REPLACE semantics, because those express "this is THE set" and silently unioning them across
|
|
441
951
|
* global + project would surprise users.
|
|
442
952
|
*
|
|
443
|
-
* | array field
|
|
444
|
-
* |
|
|
445
|
-
* | `allowDirs`
|
|
446
|
-
* | `aiignore.patterns`
|
|
447
|
-
* | `
|
|
448
|
-
* | `
|
|
449
|
-
* | `
|
|
450
|
-
* | `
|
|
451
|
-
* | `
|
|
452
|
-
* |
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
953
|
+
* | array field | policy | rationale |
|
|
954
|
+
* | ------------------------------- | -------- | --------------------------------------------- |
|
|
955
|
+
* | `allowDirs` | ADDITIVE | extra sandbox roots accumulate across layers |
|
|
956
|
+
* | `aiignore.patterns` | ADDITIVE | ignore patterns accumulate across layers |
|
|
957
|
+
* | `approvals.deny`/`escalate` | ADDITIVE | see {@link isAdditiveArrayField} |
|
|
958
|
+
* | `approvals.allow` | replace | a PERMISSIVE list; see the same doc |
|
|
959
|
+
* | `allowedTools` | replace | the explicit allow-list IS the set |
|
|
960
|
+
* | `builtInTools` | replace | the explicit tool selection IS the set |
|
|
961
|
+
* | `tools` | replace | live tool instances; union would be surprising|
|
|
962
|
+
* | `middleware` | replace | ordered pipeline; union would reorder/duplicate|
|
|
963
|
+
* | `binaryFormats` | replace | the declared format policy IS the set |
|
|
964
|
+
* | (every other array) | replace | default; preserves historical behaviour |
|
|
965
|
+
*
|
|
966
|
+
* NOTE: these keys only live at the config ROOT, so they are triggered only by the `deepMerge`
|
|
967
|
+
* calls that START at the config root (path === ''). There are TWO such sites: the
|
|
968
|
+
* `applyGlobalConfigBase(global, project)` merge, and — as of GS2-41 — the root-level `deepMerge`
|
|
969
|
+
* inside {@link resolveConfigExtends} that composes an `extends` base with its child profile. The
|
|
970
|
+
* per-command `deepMerge` calls start at command scope and never reach these paths.
|
|
457
971
|
*
|
|
458
972
|
* NAMESPACE CAVEAT: these keys are config-ROOT-relative, but the per-command
|
|
459
973
|
* `deepMerge(DEFAULT_CONFIG.commands.X, …)` calls also start at `path === ''`. No command
|
|
460
974
|
* default carries `allowDirs`/`aiignore`, so there is no collision today — but do NOT add a
|
|
461
|
-
* key
|
|
462
|
-
* inside command merges too.
|
|
975
|
+
* key to THIS SET that could also appear as a per-command field, or it would silently become
|
|
976
|
+
* additive inside command merges too. The approvals RESTRICTIVE lists are the deliberate exception
|
|
977
|
+
* and are therefore matched by path SHAPE ({@link isAdditiveArrayField}) rather than listed here:
|
|
978
|
+
* for them, additive at every scope is the rule and not an accident.
|
|
463
979
|
*/
|
|
464
980
|
const ADDITIVE_ARRAY_FIELDS = new Set(['allowDirs', 'aiignore.patterns']);
|
|
981
|
+
/**
|
|
982
|
+
* §9.1/§11.1f — the approvals **restrictive** lists, wherever they appear. `deny` and `escalate`
|
|
983
|
+
* are PROHIBITIONS, and a prohibition another config layer can quietly delete is not a hardline:
|
|
984
|
+
* §3's "an appended entry can never perturb an existing outcome" has to hold across layers or it
|
|
985
|
+
* holds nowhere. So they add up rather than replace, and no layer can narrow another's.
|
|
986
|
+
*
|
|
987
|
+
* Matched by path SHAPE rather than by an exact root-relative path, because `approvals` is settable
|
|
988
|
+
* at the root AND per command — `commands.code.approvals.deny` has to add up across the global and
|
|
989
|
+
* project layers for exactly the same reason `approvals.deny` does, and listing only the root path
|
|
990
|
+
* would leave the identical silent loss one keystroke away.
|
|
991
|
+
*
|
|
992
|
+
* **`allow` is deliberately NOT here, and adding it would be a security regression.** It is the
|
|
993
|
+
* PERMISSIVE list, so it keeps the default REPLACE policy: a layer that states its own `allow`
|
|
994
|
+
* replaces the layer below, and one that says nothing inherits. §3.1's cost asymmetry is why the
|
|
995
|
+
* three lists do not share a policy — a missed allow entry escalates and a missed deny entry
|
|
996
|
+
* reaches the rater, but a too-broad allow entry runs, unrated and unprompted. Unioning the
|
|
997
|
+
* restrictive lists fails toward a prompt; unioning the permissive one fails toward an execution.
|
|
998
|
+
*/
|
|
999
|
+
const APPROVAL_RESTRICTIVE_LIST_PATH = /(^|\.)approvals\.(deny|escalate)$/;
|
|
1000
|
+
/** Whether the array at this dotted path accumulates across layers instead of being replaced. */
|
|
1001
|
+
function isAdditiveArrayField(fieldPath) {
|
|
1002
|
+
return ADDITIVE_ARRAY_FIELDS.has(fieldPath) || APPROVAL_RESTRICTIVE_LIST_PATH.test(fieldPath);
|
|
1003
|
+
}
|
|
1004
|
+
/** The `approvals` block itself, at the root or on any command. */
|
|
1005
|
+
const APPROVALS_BLOCK_PATH = /(^|\.)approvals$/;
|
|
1006
|
+
/**
|
|
1007
|
+
* §9.1 — the `approvals` value is a union, and **the scalar rung is exactly sugar for
|
|
1008
|
+
* `{ mode: <rung> }`**. Expanded at merge time so a layer that spells its rung the friendly way
|
|
1009
|
+
* still merges field-wise: left as a string, a project config's `"approvals": "bypass"` simply
|
|
1010
|
+
* overwrites the global's object and discards that layer's rule lists — the same silent loss
|
|
1011
|
+
* {@link APPROVAL_RESTRICTIVE_LIST_PATH} exists to prevent, reached by a different route. It
|
|
1012
|
+
* matters for the permissive list too, in the other direction: without the expansion a bare rung
|
|
1013
|
+
* would silently drop the lower layer's `allow` rather than inheriting it.
|
|
1014
|
+
*/
|
|
1015
|
+
function expandApprovalsScalar(value) {
|
|
1016
|
+
return typeof value === 'string' ? { mode: value } : value;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Whether two layers' `approvals` values have to be merged FIELD-WISE rather than one replacing the
|
|
1020
|
+
* other. True only when both layers set the key AND at least one of them is the object form — i.e.
|
|
1021
|
+
* only when there is a field that replacement would lose.
|
|
1022
|
+
*
|
|
1023
|
+
* Deliberately narrow, because expanding the scalar changes the value's SHAPE: a config that is the
|
|
1024
|
+
* only one to declare `approvals`, and two layers that both spell it as a bare rung, keep the exact
|
|
1025
|
+
* value the user wrote, so `gth config print` and the `/config` panel do not churn for the
|
|
1026
|
+
* overwhelmingly common cases.
|
|
1027
|
+
*/
|
|
1028
|
+
function approvalsNeedFieldWiseMerge(sourceValue, targetValue) {
|
|
1029
|
+
if (sourceValue === undefined || targetValue === undefined)
|
|
1030
|
+
return false;
|
|
1031
|
+
return typeof sourceValue === 'object' || typeof targetValue === 'object';
|
|
1032
|
+
}
|
|
465
1033
|
/**
|
|
466
1034
|
* Deep merge two objects, with source overriding target properties.
|
|
467
1035
|
* Objects are merged recursively. Arrays REPLACE by default; arrays at an
|
|
468
|
-
* {@link
|
|
1036
|
+
* {@link isAdditiveArrayField} path are concatenated (target-first) then de-duplicated by
|
|
469
1037
|
* value. Every other non-plain-object value is replaced by the source value.
|
|
1038
|
+
*
|
|
1039
|
+
* The de-duplication is by VALUE identity (a `Set`), so it removes repeated primitives and leaves
|
|
1040
|
+
* structurally-equal objects — an approvals rule entry written in two layers — both in place. That
|
|
1041
|
+
* is correct rather than merely tolerable: a duplicate entry cannot change a most-restrictive-wins
|
|
1042
|
+
* outcome, and a deep-equality pass could.
|
|
470
1043
|
* @param target - The target object with default values (lower-precedence layer)
|
|
471
1044
|
* @param source - The source object with user overrides (higher-precedence layer)
|
|
472
1045
|
* @param path - Dotted path from the config root, used to look up the array merge policy.
|
|
@@ -478,9 +1051,13 @@ function deepMerge(target, source, path = '') {
|
|
|
478
1051
|
return source;
|
|
479
1052
|
const result = { ...target };
|
|
480
1053
|
for (const key in source) {
|
|
481
|
-
const sourceValue = source[key];
|
|
482
|
-
const targetValue = target[key];
|
|
483
1054
|
const fieldPath = path ? `${path}.${key}` : key;
|
|
1055
|
+
// §9.1 — normalize the `approvals` scalar/object union to its object form when (and only when)
|
|
1056
|
+
// a field would otherwise be lost, so the merge below is field-wise rather than a bare string
|
|
1057
|
+
// clobbering a block that carries the user's rule lists.
|
|
1058
|
+
const mergeApprovalsFieldWise = APPROVALS_BLOCK_PATH.test(fieldPath) && approvalsNeedFieldWiseMerge(source[key], target[key]);
|
|
1059
|
+
const sourceValue = mergeApprovalsFieldWise ? expandApprovalsScalar(source[key]) : source[key];
|
|
1060
|
+
const targetValue = mergeApprovalsFieldWise ? expandApprovalsScalar(target[key]) : target[key];
|
|
484
1061
|
if (sourceValue &&
|
|
485
1062
|
typeof sourceValue === 'object' &&
|
|
486
1063
|
!Array.isArray(sourceValue) &&
|
|
@@ -492,7 +1069,7 @@ function deepMerge(target, source, path = '') {
|
|
|
492
1069
|
}
|
|
493
1070
|
else if (Array.isArray(sourceValue) &&
|
|
494
1071
|
Array.isArray(targetValue) &&
|
|
495
|
-
|
|
1072
|
+
isAdditiveArrayField(fieldPath)) {
|
|
496
1073
|
// Additive list: concat both layers (target/lower-precedence first), de-dupe by value.
|
|
497
1074
|
result[key] = [...new Set([...targetValue, ...sourceValue])];
|
|
498
1075
|
}
|
|
@@ -503,16 +1080,80 @@ function deepMerge(target, source, path = '') {
|
|
|
503
1080
|
}
|
|
504
1081
|
return result;
|
|
505
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Command-scoped fields whose effective value is picked by PRECEDENCE — one layer wins wholesale
|
|
1085
|
+
* (a per-command value REPLACES rather than extends the top-level one; none is in
|
|
1086
|
+
* {@link ADDITIVE_ARRAY_FIELDS}). {@link resolvePrecedencePickedField} bakes the correct value into
|
|
1087
|
+
* every command inside {@link resolveConfig}, so `getEffectiveConfig`'s later per-command-vs-root
|
|
1088
|
+
* ternary reads an already-resolved value.
|
|
1089
|
+
*
|
|
1090
|
+
* GS2-60 — historically these were resolved ONLY at agent-build time (`getEffectiveConfig`), across
|
|
1091
|
+
* two layers (per-command value vs top-level value) AFTER `DEFAULT_CONFIG` had been merged in. That
|
|
1092
|
+
* made an explicit top-level value lose to a per-command DEFAULT: once merged, a command's
|
|
1093
|
+
* `filesystem` is always defined (from its `'read'`/`'all'` default), so the ternary always took it
|
|
1094
|
+
* and never fell through to the user's explicit top-level `filesystem`. Resolving here against the
|
|
1095
|
+
* RAW (pre-default) config — where "user set it" is still distinguishable from "it's a default" —
|
|
1096
|
+
* is the single correct site. Only `filesystem` is actually affected today (the sole field with
|
|
1097
|
+
* per-command defaults); the other three are baked in identically for principled future-proofing.
|
|
1098
|
+
*/
|
|
1099
|
+
const PRECEDENCE_PICKED_COMMAND_FIELDS = [
|
|
1100
|
+
'filesystem',
|
|
1101
|
+
'builtInTools',
|
|
1102
|
+
'allowedTools',
|
|
1103
|
+
'binaryFormats',
|
|
1104
|
+
];
|
|
1105
|
+
/**
|
|
1106
|
+
* Resolve one {@link PRECEDENCE_PICKED_COMMAND_FIELDS} field for one command against the RAW
|
|
1107
|
+
* (pre-{@link DEFAULT_CONFIG}) config, highest precedence first:
|
|
1108
|
+
*
|
|
1109
|
+
* 1. per-command explicit : `rawConfig.commands[command][field]`
|
|
1110
|
+
* 2. top-level explicit : `rawConfig[field]`
|
|
1111
|
+
* 3. per-command default : `DEFAULT_CONFIG.commands[command][field]`
|
|
1112
|
+
* 4. top-level default : `DEFAULT_CONFIG[field]`
|
|
1113
|
+
*
|
|
1114
|
+
* `!== undefined` at every layer so a falsy-but-EXPLICIT value (`'none'`, `[]`, `false`, `0`) is
|
|
1115
|
+
* honoured and never mistaken for a "missing" layer. Returns `undefined` only when no layer sets
|
|
1116
|
+
* the field (e.g. `allowedTools`/`binaryFormats` with neither a user value nor any default), in
|
|
1117
|
+
* which case the caller leaves the command key absent — matching prior behaviour.
|
|
1118
|
+
*/
|
|
1119
|
+
function resolvePrecedencePickedField(rawConfig, command, field) {
|
|
1120
|
+
const rawCommands = rawConfig.commands;
|
|
1121
|
+
const perCommandExplicit = rawCommands?.[command]?.[field];
|
|
1122
|
+
if (perCommandExplicit !== undefined)
|
|
1123
|
+
return perCommandExplicit;
|
|
1124
|
+
const topLevelExplicit = rawConfig[field];
|
|
1125
|
+
if (topLevelExplicit !== undefined)
|
|
1126
|
+
return topLevelExplicit;
|
|
1127
|
+
const perCommandDefault = DEFAULT_CONFIG.commands[command]?.[field];
|
|
1128
|
+
if (perCommandDefault !== undefined)
|
|
1129
|
+
return perCommandDefault;
|
|
1130
|
+
return DEFAULT_CONFIG[field];
|
|
1131
|
+
}
|
|
506
1132
|
/**
|
|
507
1133
|
* Resolve a fully-merged {@link GthConfig} from a partial config + CLI overrides WITHOUT
|
|
508
|
-
* any global side effects
|
|
509
|
-
*
|
|
510
|
-
*
|
|
1134
|
+
* any global side effects. It deep-merges defaults, applies CLI overrides, resolves the numeric
|
|
1135
|
+
* `consoleLevel` (warning + defaulting to INFO on an invalid value), and computes
|
|
1136
|
+
* `canInterruptInferenceWithEsc` and `useColour`. The process-global setters (`setUseColour` /
|
|
511
1137
|
* `setConsoleLevel`) are applied separately by {@link mergeConfig}, so this function can be
|
|
512
1138
|
* reasoned about and reused without touching global state.
|
|
1139
|
+
*
|
|
1140
|
+
* It WRITES nothing globally, but it does READ the environment: `canInterruptInferenceWithEsc`
|
|
1141
|
+
* consults stdin's TTY status, and (CFG-30) `useColour` consults `FORCE_COLOR`, `NO_COLOR` and
|
|
1142
|
+
* stdout's TTY status. So it is deterministic for a given environment rather than a pure function
|
|
1143
|
+
* of its arguments — a test that pins a resolved config should declare the terminal and the colour
|
|
1144
|
+
* environment it expects in its setup. The ladder itself is a pure helper
|
|
1145
|
+
* ({@link resolveUseColour}) so it can be tested rung by rung without process globals.
|
|
513
1146
|
*/
|
|
514
1147
|
export function resolveConfig(partialConfig, commandLineConfigOverrides) {
|
|
515
1148
|
const config = partialConfig;
|
|
1149
|
+
// CFG-30 — capture whether the user set `useColour` AT ALL, while the config is still raw.
|
|
1150
|
+
// `DEFAULT_CONFIG.useColour` is merged in below, after which an explicit `true` and the default
|
|
1151
|
+
// `true` are the same value and rung 3 can no longer be told from rung 4. Read it here or lose it.
|
|
1152
|
+
const explicitUseColour = config?.useColour;
|
|
1153
|
+
// TUI-C37 — the same pre-merge capture for `useMouse`, and for the same reason: once
|
|
1154
|
+
// `DEFAULT_CONFIG.useMouse` is merged in, "the user asked for it" and "nobody said" are the same
|
|
1155
|
+
// value and the ladder's config rung can no longer be distinguished from its default rung.
|
|
1156
|
+
const explicitUseMouse = config?.useMouse;
|
|
516
1157
|
// Deep merge command configs while preserving defaults
|
|
517
1158
|
// Type complexity from DEFAULT_CONFIG.commands 'as const' requires any cast for deep merge result
|
|
518
1159
|
const mergedCommands = {
|
|
@@ -524,6 +1165,28 @@ export function resolveConfig(partialConfig, commandLineConfigOverrides) {
|
|
|
524
1165
|
chat: deepMerge(DEFAULT_CONFIG.commands.chat, config?.commands?.chat), // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
525
1166
|
api: deepMerge(DEFAULT_CONFIG.commands.api, config?.commands?.api), // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
526
1167
|
};
|
|
1168
|
+
// GS2-60 — bake the correct 4-layer precedence for the precedence-picked fields
|
|
1169
|
+
// (filesystem/builtInTools/allowedTools/binaryFormats) into each command, resolved against the
|
|
1170
|
+
// RAW `config` (still pre-DEFAULT_CONFIG here). The per-command `deepMerge` above only ranks
|
|
1171
|
+
// per-command explicit vs per-command DEFAULT and cannot see the user's explicit top-level value
|
|
1172
|
+
// — so without this an explicit top-level `filesystem` was silently lost to a command's default.
|
|
1173
|
+
// A fresh `{ ...base, ...overrides }` per command is REQUIRED: `deepMerge` returns the live
|
|
1174
|
+
// `DEFAULT_CONFIG.commands[cmd]` reference verbatim when the user configured nothing for that
|
|
1175
|
+
// command, so in-place mutation would corrupt the shared default across every subsequent call.
|
|
1176
|
+
const commandsRecord = mergedCommands;
|
|
1177
|
+
for (const command of Object.keys(commandsRecord)) {
|
|
1178
|
+
const base = commandsRecord[command];
|
|
1179
|
+
const overrides = {};
|
|
1180
|
+
for (const field of PRECEDENCE_PICKED_COMMAND_FIELDS) {
|
|
1181
|
+
const resolved = resolvePrecedencePickedField(config, command, field);
|
|
1182
|
+
// Leave the key absent when no layer set it (preserves prior behaviour for e.g. a command
|
|
1183
|
+
// with no allowedTools anywhere), rather than writing an explicit `undefined`.
|
|
1184
|
+
if (resolved !== undefined) {
|
|
1185
|
+
overrides[field] = resolved;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
commandsRecord[command] = { ...base, ...overrides };
|
|
1189
|
+
}
|
|
527
1190
|
const mergedConfig = {
|
|
528
1191
|
...DEFAULT_CONFIG,
|
|
529
1192
|
...config,
|
|
@@ -551,6 +1214,25 @@ export function resolveConfig(partialConfig, commandLineConfigOverrides) {
|
|
|
551
1214
|
}
|
|
552
1215
|
}
|
|
553
1216
|
mergedConfig.canInterruptInferenceWithEsc = mergedConfig.canInterruptInferenceWithEsc && isTTY();
|
|
1217
|
+
// CFG-30 — resolve colour through the four-rung ladder (FORCE_COLOR > NO_COLOR > explicit config
|
|
1218
|
+
// > stdout auto-detect). `useColour` is top-level only (it is not one of the
|
|
1219
|
+
// PRECEDENCE_PICKED_COMMAND_FIELDS), so there is no per-command variant to reconcile.
|
|
1220
|
+
mergedConfig.useColour = resolveUseColour({
|
|
1221
|
+
forceColor: env.FORCE_COLOR,
|
|
1222
|
+
noColor: env.NO_COLOR,
|
|
1223
|
+
explicitUseColour,
|
|
1224
|
+
stdoutIsTTY: isStdoutTTY(),
|
|
1225
|
+
});
|
|
1226
|
+
// TUI-C37 — resolve mouse through its own ladder (GTH_NO_MOUSE > explicit config > TERM >
|
|
1227
|
+
// TTY auto-detect). Resolved here rather than in the TUI so the answer is one field on the
|
|
1228
|
+
// config, the way `useColour` is, instead of a decision each surface re-takes.
|
|
1229
|
+
mergedConfig.useMouse = resolveUseMouse({
|
|
1230
|
+
noMouse: env.GTH_NO_MOUSE,
|
|
1231
|
+
explicitUseMouse,
|
|
1232
|
+
term: env.TERM,
|
|
1233
|
+
stdoutIsTTY: isStdoutTTY(),
|
|
1234
|
+
stdinIsTTY: isTTY(),
|
|
1235
|
+
});
|
|
554
1236
|
return mergedConfig;
|
|
555
1237
|
}
|
|
556
1238
|
/**
|
|
@@ -594,11 +1276,183 @@ function resolveConsoleLevel(level) {
|
|
|
594
1276
|
}
|
|
595
1277
|
return undefined;
|
|
596
1278
|
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Read a raw config object from an on-disk path WITHOUT validating, building an LLM, or
|
|
1281
|
+
* merging defaults. JSON/JSONC files are parsed leniently ({@link parseJsonc}); module
|
|
1282
|
+
* formats (`.js`/`.mjs`/`.ts`) are imported and their `configure()` invoked. Used by the
|
|
1283
|
+
* read-side {@link validateConfig}; may throw on a parse/module error (surfaced by the caller).
|
|
1284
|
+
*/
|
|
1285
|
+
async function readRawConfigAtPath(path) {
|
|
1286
|
+
if (path.endsWith('.json') || path.endsWith('.jsonc')) {
|
|
1287
|
+
return parseJsonc(readFileSync(path, 'utf8'), path);
|
|
1288
|
+
}
|
|
1289
|
+
const imported = await importExternalFile(path);
|
|
1290
|
+
return (await imported.configure());
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Global config read for the read-side {@link validateConfig}: mirrors
|
|
1294
|
+
* {@link loadGlobalRawConfig}'s lookup order (JSON → JSONC → JS → MJS) but does NOT validate or
|
|
1295
|
+
* `exit` — it just returns the raw object + a source label so the validator owns the verdict.
|
|
1296
|
+
*
|
|
1297
|
+
* Ignore-on-error, exactly like {@link loadGlobalRawConfig}: a parse/module failure of the
|
|
1298
|
+
* global file is treated as an ABSENT global (returns `undefined`), NOT a hard error — a real
|
|
1299
|
+
* run does the same, so the diagnostic must too, else a clean project + an unparseable global
|
|
1300
|
+
* would fail `gth config validate` while the run keeps going (the inverse of the GS2-29 bug).
|
|
1301
|
+
*
|
|
1302
|
+
* The failure is BOTH debug-logged AND surfaced as a user-facing `displayWarning` with the same
|
|
1303
|
+
* message `loadGlobalRawConfig` emits (`Failed to read global config from <path>, ignoring it.`).
|
|
1304
|
+
* A run warns the user while ignoring the broken global's VALUE; matching that message is what
|
|
1305
|
+
* keeps `gth config validate` a faithful mirror of the run rather than staying silent about a
|
|
1306
|
+
* problem the run flags.
|
|
1307
|
+
*/
|
|
1308
|
+
async function loadGlobalRawConfigUnvalidated() {
|
|
1309
|
+
for (const filename of JSON_CONFIG_FILENAMES) {
|
|
1310
|
+
const jsonPath = getGlobalGslothConfigReadPath(filename);
|
|
1311
|
+
if (existsSync(jsonPath)) {
|
|
1312
|
+
const label = `${filename} (global)`;
|
|
1313
|
+
try {
|
|
1314
|
+
return {
|
|
1315
|
+
raw: parseJsonc(readFileSync(jsonPath, 'utf8'), label),
|
|
1316
|
+
label,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
catch (e) {
|
|
1320
|
+
displayDebug(e instanceof Error ? e : String(e));
|
|
1321
|
+
displayWarning(`Failed to read global config from ${jsonPath}, ignoring it.`);
|
|
1322
|
+
return undefined;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
for (const filename of [USER_PROJECT_CONFIG_JS, USER_PROJECT_CONFIG_MJS]) {
|
|
1327
|
+
const modulePath = getGlobalGslothConfigReadPath(filename);
|
|
1328
|
+
if (existsSync(modulePath)) {
|
|
1329
|
+
try {
|
|
1330
|
+
const imported = await importExternalFile(modulePath);
|
|
1331
|
+
return {
|
|
1332
|
+
raw: (await imported.configure()),
|
|
1333
|
+
label: `${filename} (global)`,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
catch (e) {
|
|
1337
|
+
displayDebug(e instanceof Error ? e : String(e));
|
|
1338
|
+
displayWarning(`Failed to read global config from ${modulePath}, ignoring it.`);
|
|
1339
|
+
return undefined;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return undefined;
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Locate and validate the effective raw config against the schema WITHOUT building the LLM
|
|
1347
|
+
* or merging defaults (the read-side of GS2-1). Honours `--config`, up-tree discovery, and the
|
|
1348
|
+
* identity profile via {@link findProjectConfigPath}.
|
|
1349
|
+
*
|
|
1350
|
+
* GS2-29 — validates the SAME layer set a real run does: the discovered PROJECT layer (if any)
|
|
1351
|
+
* AND the GLOBAL layer (if any), mirroring `initConfig`'s `validateRawConfigLayer(project)` +
|
|
1352
|
+
* `applyGlobalConfigBase` → `loadGlobalRawConfig(global)`. Each present layer is validated
|
|
1353
|
+
* independently ({@link validateRawGthConfig}) and its outcome recorded in {@link
|
|
1354
|
+
* ConfigValidationReport.layers}, so a removed shape in EITHER file is reported (labelled with
|
|
1355
|
+
* its source) rather than under-reported.
|
|
1356
|
+
*
|
|
1357
|
+
* A PROJECT-layer JSONC/module parse failure is thrown to the caller (surfaced as a clear
|
|
1358
|
+
* "invalid config" error + non-zero exit). A GLOBAL-layer parse failure is treated as an absent
|
|
1359
|
+
* global (no layer added) but is surfaced with a `displayWarning` — exactly as a run does (it
|
|
1360
|
+
* warns the user while ignoring the broken global's value) — see {@link
|
|
1361
|
+
* loadGlobalRawConfigUnvalidated}.
|
|
1362
|
+
*
|
|
1363
|
+
* GS2-73 — for the PROJECT layer it also walks the GS2-41 profile `extends` chain (via the SAME
|
|
1364
|
+
* {@link composeExtends}/{@link resolveExtendsChain} the run path uses), so a cycle or a missing
|
|
1365
|
+
* base — which fail a real run — is reported here as a not-ok layer instead of passing OK and only
|
|
1366
|
+
* failing at run time. The GLOBAL layer is NOT walked, mirroring the run (`resolveConfigExtends`
|
|
1367
|
+
* runs on the project/profile layer only).
|
|
1368
|
+
*/
|
|
1369
|
+
export async function validateConfig(commandLineConfigOverrides) {
|
|
1370
|
+
const layers = [];
|
|
1371
|
+
// CFG-36 — mirror the run's STRICT named-profile rule before anything is read. `initConfig`
|
|
1372
|
+
// refuses outright when an explicitly-named profile has no config of its own, so a validator that
|
|
1373
|
+
// walked on would report OK for a config the run rejects — and it would do so in the ordinary
|
|
1374
|
+
// case, because discovery falls back to a plain project config for an unresolved profile. That is
|
|
1375
|
+
// the GS2-29 divergence in its purest form: `gth config validate -i typo` green-lighting a run
|
|
1376
|
+
// that cannot start. Reported as a not-ok layer rather than thrown, because the read side
|
|
1377
|
+
// COLLECTS and never terminates; returned immediately because a run gets no further either.
|
|
1378
|
+
const unresolvedProfile = findUnresolvedExplicitProfile(commandLineConfigOverrides);
|
|
1379
|
+
if (unresolvedProfile) {
|
|
1380
|
+
return {
|
|
1381
|
+
// `found: true` so the caller renders THIS message. `found: false` means "nothing to
|
|
1382
|
+
// validate, run `gth init`", which would both discard the real diagnosis and misdirect a user
|
|
1383
|
+
// whose actual problem is a mistyped `-i`.
|
|
1384
|
+
found: true,
|
|
1385
|
+
ok: false,
|
|
1386
|
+
layers: [
|
|
1387
|
+
{
|
|
1388
|
+
sourceLabel: `${GSLOTH_DIR}/${GSLOTH_SETTINGS_DIR}/${unresolvedProfile}/`,
|
|
1389
|
+
ok: false,
|
|
1390
|
+
warnings: [],
|
|
1391
|
+
errorMessage: identityProfileNotFoundMessage(unresolvedProfile),
|
|
1392
|
+
},
|
|
1393
|
+
],
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
// Project layer first (run order): initConfig validates the discovered project config before
|
|
1397
|
+
// applying the global base. A parse failure here propagates (surfaced by the caller).
|
|
1398
|
+
const discovered = findProjectConfigPath(commandLineConfigOverrides);
|
|
1399
|
+
if (discovered) {
|
|
1400
|
+
const raw = await readRawConfigAtPath(discovered.path);
|
|
1401
|
+
const layer = {
|
|
1402
|
+
sourceLabel: discovered.path,
|
|
1403
|
+
...validateRawGthConfig(raw, RAW_CONFIG_VALIDATION_OPTIONS),
|
|
1404
|
+
};
|
|
1405
|
+
// GS2-73 — mirror the run's `extends` resolution. GS2-41's `resolveConfigExtends` walks the
|
|
1406
|
+
// profile inheritance chain and hard-fails a real run on a cycle or a missing base; the read
|
|
1407
|
+
// side must surface the SAME failures, else a profile whose `extends` names a missing base (or
|
|
1408
|
+
// forms a cycle) reports OK here yet dies at run time — the exact GS2-29 "validate mirrors the
|
|
1409
|
+
// layer set a run loads" divergence. Reuse the SAME traversal (via {@link composeExtends}) —
|
|
1410
|
+
// no forked walk — and record its typed failure as a not-ok layer instead of exiting. Gated on
|
|
1411
|
+
// `layer.ok` and on a string `extends`, mirroring run order (a run validates the raw shape and
|
|
1412
|
+
// only THEN resolves `extends`) and skipping the read + walk for the common no-`extends` config.
|
|
1413
|
+
if (layer.ok && typeof raw.extends === 'string') {
|
|
1414
|
+
try {
|
|
1415
|
+
await composeExtends(raw, commandLineConfigOverrides.identityProfile);
|
|
1416
|
+
}
|
|
1417
|
+
catch (e) {
|
|
1418
|
+
// CFG-36 — a MALFORMED base layer now raises a ConfigDiscoveryError from
|
|
1419
|
+
// `validateRawConfigLayer` instead of exiting the process. Record it as a not-ok layer
|
|
1420
|
+
// alongside the traversal's own failures: the read side collects, it never terminates, so
|
|
1421
|
+
// `gth config validate` reports the broken base rather than dying on it.
|
|
1422
|
+
if (e instanceof ConfigExtendsError || isConfigDiscoveryError(e)) {
|
|
1423
|
+
layer.ok = false;
|
|
1424
|
+
layer.errorMessage = e.message;
|
|
1425
|
+
}
|
|
1426
|
+
else {
|
|
1427
|
+
throw e;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
layers.push(layer);
|
|
1432
|
+
}
|
|
1433
|
+
// Global layer next: a run ALWAYS applies it (applyGlobalConfigBase in the project path,
|
|
1434
|
+
// loadGlobalRawConfig in the no-project path), so the diagnostic must validate it too — this is
|
|
1435
|
+
// the layer the previous single-layer validateConfig skipped whenever a project config existed.
|
|
1436
|
+
const globalRaw = await loadGlobalRawConfigUnvalidated();
|
|
1437
|
+
if (globalRaw) {
|
|
1438
|
+
layers.push({
|
|
1439
|
+
sourceLabel: globalRaw.label,
|
|
1440
|
+
...validateRawGthConfig(globalRaw.raw, RAW_CONFIG_VALIDATION_OPTIONS),
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
// Vacuous-truth guard: `every` is true on an empty array, so gate `ok` on a config existing.
|
|
1444
|
+
return { found: layers.length > 0, ok: layers.length > 0 && layers.every((l) => l.ok), layers };
|
|
1445
|
+
}
|
|
597
1446
|
/**
|
|
598
1447
|
* Merge raw with default config
|
|
599
1448
|
*/
|
|
600
1449
|
async function mergeRawConfig(config, llm, commandLineConfigOverrides) {
|
|
601
1450
|
const modelDisplayName = config.llm?.model;
|
|
602
|
-
|
|
1451
|
+
// GS2-53 — stash the raw provider `type` (openrouter/deepseek/xai/…) BEFORE the built `llm`
|
|
1452
|
+
// replaces the raw `llm` spec below: it is the true configured provider and the only place the
|
|
1453
|
+
// OpenAI-compatible shims' real identity survives (their `_llmType()` reports `openai`).
|
|
1454
|
+
// `resolveModelIdentity` prefers it over `_llmType()`. INTERNAL field (not in the config schema).
|
|
1455
|
+
const modelProviderType = config.llm?.type;
|
|
1456
|
+
return await mergeConfig({ ...config, llm, modelDisplayName, modelProviderType }, commandLineConfigOverrides);
|
|
603
1457
|
}
|
|
604
1458
|
//# sourceMappingURL=loader.js.map
|