Go 1206 lines
// Package config detects the target repository root and project profile
// and resolves the eeco workspace configuration.
//
// The package is deliberately small: it answers "where is the repo
// root", "what kind of project is this", "who owns the workspace",
// "where is the workspace", and "what config.local overrides apply".
// Its only internal dependency is gitx, for the read-only git-identity
// lookup that scopes the workspace under <repo>/<username>/. Workspace
// creation lives alongside in init.go.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/ajhahnde/eeco/internal/gitx"
)
// Profile identifies the kind of project a repository contains. It
// drives the default parse/build gate command and may shape workflow
// behaviour later.
type Profile string
const (
ProfileGo Profile = "go"
ProfileZig Profile = "zig"
ProfileRust Profile = "rust"
ProfileNode Profile = "node"
ProfilePython Profile = "python"
ProfileGeneric Profile = "generic"
)
// DefaultWorkspace is the default engine workspace directory name. It
// is a hidden directory scaffolded inside the per-user workspace dir
// (<repo>/<username>/.eeco).
const DefaultWorkspace = ".eeco"
// UsernameEnv, when set, overrides the username used to scope the
// workspace under <repo>/<username>/. It is the non-interactive
// injection point — CI, tests, and the future `eeco init --username`
// flag — and takes precedence over `git config user.name`.
const UsernameEnv = "EECO_USERNAME"
// FallbackUsername is the last-resort workspace owner directory name,
// used only when no username can be resolved from the environment or
// git. It keeps Load total: the workspace always has a valid path even
// on a machine with no git identity configured.
const FallbackUsername = "eeco-workspace"
// DefaultStaleDays is the default age in days after which a
// reference-type memory fact is considered stale by garbage collection.
const DefaultStaleDays = 30
// Automation selects how much housekeeping eeco performs on its own.
// Raising the level lets eeco prepare more without prompting; it never
// lets eeco act on the tracked tree on its own (the floor invariants in
// PLAN.md hold at every level).
type Automation string
const (
// AutomationManual: no background analysis; new workflows only via
// `eeco new`.
AutomationManual Automation = "manual"
// AutomationPropose (default): analysis only on explicit run / --ai;
// a drafted workflow becomes a queue proposal.
AutomationPropose Automation = "propose"
// AutomationScaffold: as propose, but a drafted workflow is written
// inactive into the workspace and queued "ready to activate".
AutomationScaffold Automation = "scaffold"
// AutomationAuto: analysis may run automatically within the budget
// cap (this setting is the AI consent); workflows as scaffold.
AutomationAuto Automation = "auto"
)
// DefaultAutomation is the conservative default automation level.
const DefaultAutomation = AutomationPropose
// DefaultAIBudget caps the number of gated passes a single eeco
// invocation may spend. A tool-using pass may make several model calls
// but counts as one. One pass is enough for the builtins; 0 disables AI
// entirely (every pass is then parked).
const DefaultAIBudget = 1
// DefaultAIAPIKeyEnv is the environment-variable NAME the native
// provider reads its API key from when `ai_api_key_env` is unset. Only
// the variable name is configured; the secret itself is never stored.
const DefaultAIAPIKeyEnv = "ANTHROPIC_API_KEY"
// DefaultBugReportDir is the workspace-relative directory where
// `eeco report-bug` writes per-invocation Markdown records. Overridable
// via the `bug_report_dir` key in config.local.
const DefaultBugReportDir = "bug-reports"
// DefaultContextPath is the workspace-relative file `eeco go --write`
// renders the project brief into. Overridable via the `context_path`
// key in config.local.
const DefaultContextPath = "context.md"
// DefaultContextBudget is the default byte cap on the file `eeco go
// --write` renders. 0 means no cap — the full brief is written as-is.
// Overridable via the `context_budget` key in config.local.
const DefaultContextBudget = 0
// DefaultBriefIncludeNotes is the default for whether `eeco go` adds a
// "Recent notes" section to the brief. False keeps bare `eeco go`
// byte-identical to the notes-free brief; opt in via the
// `brief_include_notes` key in config.local.
const DefaultBriefIncludeNotes = false
// DefaultSessionStartMailbox is the default filename of the
// repo-root mailbox the bundled session-start hook surfaces when it has
// unprocessed content. Overridable via `session_start_mailbox` in
// config.local; an empty override disables the mailbox block.
const DefaultSessionStartMailbox = "Ideas.md"
// DefaultSessionStartPinnedBodies is the default for whether the
// bundled session-start hook composes a fourth block that emits the
// FULL BODY of every `pin: true` memory fact alongside the existing
// three blocks. False keeps the hook's output byte-identical to the
// three-block behaviour; opt in via the `session_start_pinned_bodies`
// key in config.local OR the `--with-pinned-bodies` flag on
// `eeco hooks session-emit`. Useful when an AI assistant treats hook
// output as a system-reminder so a pinned policy memory (for example
// no-AI-attribution) is in the model's context from session start.
const DefaultSessionStartPinnedBodies = false
// DefaultSessionStartRoadmapGlob is the default glob, relative to the
// repo root, used to discover the live planning surface the bundled
// session-start hook appends to the reading routine. The
// most-recently-modified match wins. Overridable via
// `session_start_roadmap_glob`; an empty override disables roadmap
// discovery.
const DefaultSessionStartRoadmapGlob = "roadmap*.md"
// DefaultPreCommitWorkflows returns the builtin workflow names wired
// into the eeco-managed pre-commit hook when the operator has not
// overridden them via `pre_commit_workflows`. The default is the
// gate-family pair that is safe to run on every commit: `leak-guard`
// (the long-standing default wiring) and `version-sync` (silent on
// projects with no `version_locations`, so opt-in per project).
// `comment-hygiene` is omitted: it is opinionated about prose in the
// tracked tree and would surprise a fresh adopter on first install.
// Callers receive a fresh slice; mutating it does not affect the
// default returned by a subsequent call.
func DefaultPreCommitWorkflows() []string {
return []string{"leak-guard", "version-sync"}
}
// DefaultPostMergeWorkflows returns the builtin workflow names wired
// into the eeco-managed post-merge hook when the operator has not
// overridden them via `post_merge_workflows`. The default is the
// drift-detection pair: `memory-drift` and `doc-drift`. A merge (a
// `git pull` / `git merge`) is the moment another author's changes land
// in the tree, so it is the natural trigger to re-check whether eeco's
// recorded state has drifted from the code. Both are silent no-ops on a
// project that carries no memory `ref:` facts and no CHANGELOG/tags, so
// they are safe to wire by default. `manifest-refresh` joins them: a merge
// can add or remove files in the knowledge dirs, so it is the natural moment
// to rebuild the deterministic .ai.json skeletons; it is a no-op on a repo
// with no knowledge dirs. `cockpit-sync` joins them too: a merge can ship new
// playbook content (an eeco upgrade) that leaves every generated cockpit
// artifact behind its source, so a merge is the natural moment to flag the
// drift; it is a silent no-op on a repo where the cockpit was never generated
// (its empty-ledger gate). Callers receive a fresh slice; mutating it does not
// affect the default returned by a subsequent call.
func DefaultPostMergeWorkflows() []string {
return []string{"memory-drift", "doc-drift", "manifest-refresh", "cockpit-sync"}
}
// normalizeAutomation maps any value to a known level. An unknown or
// future value is tolerated and falls back to the default rather than
// failing (PLAN.md §Automation level).
func normalizeAutomation(v string) Automation {
switch Automation(v) {
case AutomationManual, AutomationPropose, AutomationScaffold, AutomationAuto:
return Automation(v)
default:
return DefaultAutomation
}
}
// ImpliesAIConsent reports whether the level is itself standing consent
// for a gated, budget-capped AI pass. Only `auto` is: every other level
// requires an explicit --ai (PLAN.md §AI providers).
func (a Automation) ImpliesAIConsent() bool { return a == AutomationAuto }
// ScaffoldsWorkflows reports whether a drafted workflow is written
// (inactive) into the workspace rather than only proposed via the queue.
func (a Automation) ScaffoldsWorkflows() bool {
return a == AutomationScaffold || a == AutomationAuto
}
// ReconcilesCockpit reports whether eeco may regenerate a drifted or missing
// cockpit artifact on its own — a deterministic render→write into the
// gitignored workspace tree, performed by the cockpit-sync workflow. Only
// `auto` is. It is a file-write consent, distinct from ImpliesAIConsent (no
// model spend is involved), so it has its own predicate rather than overloading
// that one. Orphan removal stays operator-in-the-loop even at auto (deleting a
// file is destructive).
func (a Automation) ReconcilesCockpit() bool { return a == AutomationAuto }
// WorkspaceHistory selects whether eeco keeps a private, local git
// repository inside its own workspace directory (<repo>/<username>/) to
// version the knowledge layer — memory, queue, decisions, manifests —
// over time, and how often it commits. It is a different axis from
// Automation (which governs background analysis), so it has its own key.
type WorkspaceHistory string
const (
// WorkspaceHistoryOff: no private repo. `eeco init` does not create
// one; `eeco history` reports it is off. The durable opt-out.
WorkspaceHistoryOff WorkspaceHistory = "off"
// WorkspaceHistoryManual (default): the private repo exists, but eeco
// commits to it only on an explicit `eeco history snapshot`.
WorkspaceHistoryManual WorkspaceHistory = "manual"
// WorkspaceHistoryAuto: as manual, plus eeco commits automatically
// after each mutating verb (the cmd layer calls maybeAutoCommit at every
// write site; see cmd/eeco/historygit.go). Still local-only — no remote,
// no push.
WorkspaceHistoryAuto WorkspaceHistory = "auto"
)
// DefaultWorkspaceHistory is the safe-default floor: the repo is created
// at init, but nothing is committed without an explicit snapshot. The
// floor is manual, never auto.
const DefaultWorkspaceHistory = WorkspaceHistoryManual
// normalizeWorkspaceHistory maps any value to a known level. An unknown
// or future value is tolerated and falls back to the default rather than
// failing config load (floor invariant, mirroring normalizeAutomation).
func normalizeWorkspaceHistory(v string) WorkspaceHistory {
switch WorkspaceHistory(v) {
case WorkspaceHistoryOff, WorkspaceHistoryManual, WorkspaceHistoryAuto:
return WorkspaceHistory(v)
default:
return DefaultWorkspaceHistory
}
}
// Enabled reports whether a private workspace-history repo should exist
// (every level except off).
func (h WorkspaceHistory) Enabled() bool { return h != WorkspaceHistoryOff }
// Auto reports whether eeco should commit automatically after each
// mutating verb (only the auto level).
func (h WorkspaceHistory) Auto() bool { return h == WorkspaceHistoryAuto }
// Config is the resolved configuration for an eeco invocation.
type Config struct {
// RepoRoot is the absolute path to the repository root (the directory
// containing .git).
RepoRoot string
// Username is the slugged identity that owns the workspace. It scopes
// the workspace under <RepoRoot>/<Username>/ and is the single
// directory Init adds to .gitignore. Resolved by resolveUsername from
// UsernameEnv, `git config user.name`, $USER, or FallbackUsername; it
// is never empty.
Username string
// UserDir is the absolute path to the per-user workspace parent,
// <RepoRoot>/<Username>/. It holds the engine workspace (the .eeco
// directory) plus the project-type-aware knowledge directories.
UserDir string
// WorkspaceName is the engine workspace directory name, relative to
// UserDir (the leaf component of Workspace, e.g. ".eeco").
WorkspaceName string
// Workspace is the absolute path to the workspace directory.
Workspace string
// Profile is the detected project profile.
Profile Profile
// Gate is the project's parse/build gate: an ordered chain of
// commands (each an argv slice) run in sequence, stopping at the
// first failure. The default is a single-step chain from the
// detected profile; the repeatable `gate` key in config.local
// overrides it — the first occurrence resets the profile default,
// each occurrence appends one step. Empty (a lone `gate=`, or the
// generic profile) means no gate. The `gate` builtin workflow runs
// the chain.
Gate [][]string
// StaleDays controls when reference-type memory facts age out of
// the store. Overridable via the `stale_days` key in config.local.
StaleDays int
// AttributionPatterns holds operator-supplied extra regexes appended
// to the built-in AI-attribution denylist. Populated from repeatable
// `attribution_pattern` keys in config.local; empty by default.
AttributionPatterns []string
// Automation is the resolved automation level. Overridable via the
// `automation` key in config.local; defaults to DefaultAutomation.
Automation Automation
// WorkspaceHistory selects whether eeco keeps a private, local git
// repository inside UserDir to version the knowledge layer over time,
// and how often it commits (off | manual | auto). Overridable via the
// `workspace_history` key in config.local; defaults to
// DefaultWorkspaceHistory (manual). An unknown value falls back to the
// default (floor invariant). The private repo is local-only — no
// remote, no push — and confined to the gitignored workspace; the
// engine never writes to it (all private-repo git lives in the cmd
// layer, cmd/eeco/historygit.go).
WorkspaceHistory WorkspaceHistory
// AICommand is the argv for the wired CLI-based AI provider, taken
// from the `ai_command` key (whitespace-split). Empty means no
// provider is configured: every AI pass is parked, never failed.
AICommand []string
// AIBudget caps gated passes per invocation (a tool-using pass may
// make several model calls but counts as one). From the `ai_budget`
// key; defaults to DefaultAIBudget. 0 disables AI (passes are parked).
AIBudget int
// AIProvider selects the provider implementation. From the
// `ai_provider` key; `cli` selects the CLI provider, and any other
// value (empty/auto, or the legacy `anthropic`) falls back to
// auto-select: an explicit `ai_command` picks the CLI provider, else
// the not-configured stub. An unknown value is tolerated and never an
// error (floor invariant).
AIProvider string
// AIModel is an opaque model identifier carried in config. From the
// `ai_model` key. Inert passthrough: the CLI provider ignores it.
AIModel string
// AIAPIKeyEnv is the NAME of an environment variable for an API key,
// from the `ai_api_key_env` key; defaults to DefaultAIAPIKeyEnv. Inert
// passthrough since the in-binary API provider was retired. The key
// value itself is never read from disk or stored (secret-handling floor).
AIAPIKeyEnv string
// SessionSettingsPath is the absolute path to the AI CLI's
// user-global JSON settings file that the opt-in session-start hook
// edits. From the `session_settings_path` key, or the
// EECO_SESSION_SETTINGS environment variable when the key is unset.
// Empty means session-start wiring is not configured: `eeco hooks
// session-start on` reports that and touches nothing. No brand path
// is baked in (Constraint 4).
SessionSettingsPath string
// BugReportDir is the workspace-relative directory where `eeco
// report-bug` writes per-invocation Markdown records. From the
// `bug_report_dir` key in config.local; defaults to
// DefaultBugReportDir. The value is held to the workspace by the
// write-scope guard; absolute paths and `..` traversal are rejected
// at parse time.
BugReportDir string
// ContextPath is the workspace-relative file `eeco go --write`
// renders the project brief into. From the `context_path` key in
// config.local; defaults to DefaultContextPath. The value is held to
// the workspace by the write-scope guard; absolute paths and `..`
// traversal are rejected at parse time.
ContextPath string
// ContextBudget is the byte cap on the file `eeco go --write`
// renders. From the `context_budget` key in config.local; defaults
// to DefaultContextBudget (0, no cap). When positive, `eeco go
// --write` trims the brief down a deterministic ladder until it
// fits the budget. Negative values are rejected at parse time.
ContextBudget int
// BriefIncludeNotes opts the brief into a "Recent notes" section
// drawn from <workspace>/notes/. From the `brief_include_notes` key
// in config.local; defaults to DefaultBriefIncludeNotes (false), so
// bare `eeco go` stays byte-identical to the notes-free brief. The
// notes section appears in Markdown output only; the JSON brief's
// nine frozen top-level keys are unchanged.
BriefIncludeNotes bool
// SessionStartPinnedBodies opts the bundled session-start hook into
// a fourth block that lists the full body of every `pin: true`
// memory fact, separated by markdown dividers. From the
// `session_start_pinned_bodies` key in config.local; defaults to
// DefaultSessionStartPinnedBodies (false), so the three-block output
// stays byte-identical. The `--with-pinned-bodies` flag on
// `eeco hooks session-emit` sets this for one invocation, taking
// precedence over the config value.
SessionStartPinnedBodies bool
// SessionStartDocs is the explicit reading-routine the bundled
// session-start hook surfaces, in order. Populated from repeatable
// `session_start_docs` keys in config.local (one path per
// occurrence, repo-relative). When empty the hook falls back to
// auto-detection over a list of common docs.
SessionStartDocs []string
// SessionStartMailbox is the repo-relative filename of the mailbox
// the bundled session-start hook checks for unprocessed content.
// From the `session_start_mailbox` key in config.local; defaults to
// DefaultSessionStartMailbox. An empty override disables the
// mailbox block; absolute paths and `..` traversal are rejected at
// parse time.
SessionStartMailbox string
// SessionStartRoadmapGlob is the glob, relative to the repo root,
// used by the bundled session-start hook to discover the live
// planning surface. The most-recently-modified match is appended to
// the reading routine. From `session_start_roadmap_glob`; defaults
// to DefaultSessionStartRoadmapGlob. Empty disables discovery.
SessionStartRoadmapGlob string
// HandoverGlob is an optional glob, relative to the repo root, the
// cockpit's SessionStart orient block uses to find the newest handover /
// resume note (the session's resume point). The most-recently-modified
// match wins, mirroring SessionStartRoadmapGlob. From the `handover_glob`
// key in config.local; empty (the default) falls back to the newest note
// under <workspace>/notes/.
HandoverGlob string
// VersionLocations is the operator-declared list of `path:regex`
// entries the `version-sync` builtin reads to detect drift between
// version strings inside the repository. Each entry is split on the
// first colon; the path is repo-relative and the regex must declare
// at least one capture group (group 1 captures the version string).
// Populated from repeatable `version_locations` keys in
// config.local; empty disables the gate (`version-sync` exits 0).
// Absolute paths and `..` traversal are rejected at parse time.
//
// The reserved value `version_locations=auto` switches `version-sync`
// to auto-detect a fixed set of common version files instead of an
// explicit list. It cannot be mixed with `path:regex` entries; when
// declared, this slice is exactly the single element "auto".
VersionLocations []string
// VersionAnchor selects the source of truth `version-sync` compares
// declared `version_locations` against. Three modes:
//
// "" (unset, default) — consistency-only. The first declared
// location is the anchor; the rest must match it. Slice-1
// behaviour, preserved for backward compatibility.
// "tag" — tag-anchor. The latest semver-shaped reachable git tag
// (via gitx.LatestSemverTag) is the expected version. Declared
// locations must be semver-greater-or-equal to it so a release
// commit can bump declared locations ahead of the tag (the tag
// is pushed after the commit). Backward-drift fails. If no
// semver-shaped tag is reachable yet, falls back to
// consistency-only.
// "<path>:<regex>" — designated-file. The path:regex pair is
// parsed like a `version_locations` entry; the captured value
// is the expected version. Declared locations must strict-equal
// it. A missing path exits 2 (blocked).
//
// Populated from the `version_anchor` key in config.local; empty
// keeps the default. Absolute paths and `..` traversal in the
// designated-file form are rejected at parse time.
VersionAnchor string
// PreCommitWorkflows is the ordered list of builtin workflow names
// the eeco-managed pre-commit hook runs, in declared sequence,
// stopping at the first non-zero exit. Populated from repeatable
// `pre_commit_workflows` keys in config.local; the first occurrence
// in the file resets the default, subsequent occurrences append.
// When config.local declares the key with an empty value the list
// is cleared and `eeco hooks pre-commit on` refuses to install.
// When config.local does not declare the key at all, the default
// from DefaultPreCommitWorkflows is used. Names are not validated
// here (the config package cannot import the workflow registry
// without a cycle); validation happens at hook-install time.
PreCommitWorkflows []string
// PostMergeWorkflows is the ordered list of builtin workflow names
// the eeco-managed post-merge hook runs after a `git merge` /
// `git pull`. Populated from repeatable `post_merge_workflows` keys
// in config.local with the same reset-then-append semantics as
// PreCommitWorkflows: the first occurrence resets the default,
// subsequent occurrences append, an empty value clears the list and
// `eeco hooks post-merge on` refuses to install. When the key is not
// declared the default from DefaultPostMergeWorkflows is used. Names
// are validated at hook-install time, not here (registry cycle).
PostMergeWorkflows []string
// SessionFiles is the operator-declared list of paths where the
// session-start hook maintains a marker block carrying the same
// content `eeco hooks session-emit` prints. Each entry is one
// delivery target — either repo-relative (held inside the repo by
// the same path-traversal guard `session_start_docs` uses) or
// absolute (matching the precedent set by `session_settings_path`).
// Populated from repeatable `session_files` keys in config.local;
// empty by default. Together with the JSON-settings channel keyed
// by `session_settings_path`, this is the brand-free second delivery
// channel for assistants that read a plain text or markdown file
// at session start (e.g. Cursor's `.cursorrules`, OpenAI Codex's
// `AGENTS.md`, a repo-level `CLAUDE.md`). Either channel is enough
// for `eeco hooks session-start on` to succeed; both compose.
SessionFiles []string
// KnowledgeDirs is the project-type-aware set of knowledge
// directories Init scaffolds inside UserDir, alongside the engine
// workspace. It is empty for a Config produced by Load: the dir set
// is resolved by `eeco init` from the project-type detector
// (internal/projecttype) and assigned before the Init call, so Load
// stays a pure, non-interactive configuration read. Names are held
// to a single safe path component by Init.
KnowledgeDirs []string
// InitDetectionThreshold is the deterministic-confidence floor at or
// above which `eeco init` accepts the project-type marker scan
// without an interactive prompt. From the `init_detection_threshold`
// key in config.local; 0 (the default) means "use the detector's
// built-in default". Values are constrained to [0,1] at parse time.
InitDetectionThreshold float64
}
// ErrNotInRepo is returned when no enclosing git repository can be
// located by walking upwards from the start directory.
var ErrNotInRepo = errors.New("not inside a git repository")
// FindRepoRoot walks upwards from start until it finds a directory that
// contains a `.git` entry (a directory in a normal clone, or a file in
// a worktree). The start path may be relative; the returned path is
// always absolute and cleaned.
func FindRepoRoot(start string) (string, error) {
return walkUpForGitRoot(start, func(string) bool { return true })
}
// walkUpForGitRoot walks upwards from start and returns the first directory
// that both contains a `.git` entry and satisfies accept. A `.git` directory
// for which accept returns false is walked past rather than returned, letting
// a caller ignore a specific kind of nested repo. The start path may be
// relative; the returned path is always absolute and cleaned.
func walkUpForGitRoot(start string, accept func(dir string) bool) (string, error) {
abs, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("resolve start path: %w", err)
}
dir := abs
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil && accept(dir) {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("%w: searched up from %s", ErrNotInRepo, abs)
}
dir = parent
}
}
// resolveProjectRoot finds the host project's repo root for cwd, walking past
// eeco's own private workspace-history repo (<username>/.git, created by
// `eeco init`) so that running from inside <username>/ — the directory the
// harness launches from to load the emitted cockpit — resolves the project
// root and not the private repo. If no non-private repo is found above (a
// private repo with no host repo, which `eeco init` never produces), it falls
// back to the nearest git root so behavior is never worse than FindRepoRoot.
func resolveProjectRoot(start string) (string, error) {
root, err := walkUpForGitRoot(start, func(dir string) bool {
return !isPrivateWorkspaceRepo(dir)
})
if errors.Is(err, ErrNotInRepo) {
return FindRepoRoot(start)
}
return root, err
}
// isPrivateWorkspaceRepo reports whether dir is eeco's private
// workspace-history repo rather than a host project root. The private repo
// lives at <userDir>/.git with the engine workspace <userDir>/.eeco beside it;
// a host project root never has the workspace directory directly under it (the
// workspace is always nested under <username>/). The check is a single stat so
// root detection stays cheap on the hot path (Load runs on every command).
func isPrivateWorkspaceRepo(dir string) bool {
info, err := os.Stat(filepath.Join(dir, DefaultWorkspace))
return err == nil && info.IsDir()
}
// DetectProfile inspects marker files at the repository root and
// returns the best-matching profile. When several markers are present,
// the first match in the documented order wins (go, zig, rust, node,
// python). It never returns an error: an unrecognised tree is generic.
func DetectProfile(repoRoot string) Profile {
exists := func(name string) bool {
_, err := os.Stat(filepath.Join(repoRoot, name))
return err == nil
}
hasGlob := func(pattern string) bool {
matches, err := filepath.Glob(filepath.Join(repoRoot, pattern))
return err == nil && len(matches) > 0
}
switch {
case exists("go.mod"):
return ProfileGo
case exists("build.zig"):
return ProfileZig
case exists("Cargo.toml"):
return ProfileRust
case exists("package.json"):
return ProfileNode
case exists("pyproject.toml"), exists(".venv"), hasGlob("requirements*.txt"):
return ProfilePython
default:
return ProfileGeneric
}
}
// resolveUsername picks the directory name that owns the workspace,
// scoping it under <repo>/<username>/. Resolution order: UsernameEnv,
// then `git config user.name`, then $USER / $USERNAME, then
// FallbackUsername. Each candidate is slugged to a safe single path
// component; the first non-empty slug wins. It never returns empty and
// never fails: Load runs on every command and must stay non-interactive
// (the interactive "pick a username" prompt belongs to `eeco init`).
func resolveUsername(root string) string {
candidates := []string{os.Getenv(UsernameEnv)}
if name, err := gitx.UserName(root); err == nil {
candidates = append(candidates, name)
}
candidates = append(candidates, os.Getenv("USER"), os.Getenv("USERNAME"))
for _, c := range candidates {
if s := slugUsername(c); s != "" {
return s
}
}
return FallbackUsername
}
// slugUsername reduces an arbitrary identity string to a safe single
// path component: it keeps ASCII letters, digits, '-', '_', and '.',
// maps spaces to '-', drops everything else, and trims leading/trailing
// dots so the result can never be "." or "..". An empty result signals
// "no usable name" to resolveUsername.
func slugUsername(s string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(s) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.':
b.WriteRune(r)
case r == ' ':
b.WriteRune('-')
}
}
return strings.Trim(b.String(), ".")
}
// GateFor returns the default parse/build gate for a profile as a
// single-step chain. The generic profile has no gate and yields nil.
// The returned value is a fresh copy and safe to mutate.
func GateFor(p Profile) [][]string {
var cmd []string
switch p {
case ProfileGo:
cmd = []string{"go", "vet", "./..."}
case ProfileZig:
cmd = []string{"zig", "build", "--summary", "none"}
case ProfileRust:
cmd = []string{"cargo", "check", "--quiet"}
case ProfileNode:
cmd = []string{"npm", "run", "--if-present", "typecheck"}
case ProfilePython:
cmd = []string{"python3", "-m", "compileall", "-q", "."}
default:
return nil
}
step := make([]string, len(cmd))
copy(step, cmd)
return [][]string{step}
}
// GateSteps renders each step of a gate chain as a single joined
// command string (its argv joined by spaces). The result is a non-nil
// (possibly empty) slice — one element per step — suitable for display
// or, joined by " && ", for a one-line summary of the whole chain.
func GateSteps(chain [][]string) []string {
out := make([]string, 0, len(chain))
for _, step := range chain {
out = append(out, strings.Join(step, " "))
}
return out
}
// Load resolves the configuration for the given working directory and
// workspace name. It detects the repo root, the profile, fills in the
// default gate, then applies any overrides from
// <workspace>/config.local. The workspace itself does not need to
// exist yet — Load is safe to call before `eeco init`.
//
// Pass an empty workspaceName to use DefaultWorkspace.
func Load(cwd, workspaceName string) (*Config, error) {
if workspaceName == "" {
workspaceName = DefaultWorkspace
}
if err := validateWorkspaceName(workspaceName); err != nil {
return nil, err
}
root, err := resolveProjectRoot(cwd)
if err != nil {
return nil, err
}
username := resolveUsername(root)
cfg := &Config{
RepoRoot: root,
Username: username,
UserDir: filepath.Join(root, username),
WorkspaceName: workspaceName,
Workspace: filepath.Join(root, username, workspaceName),
Profile: DetectProfile(root),
StaleDays: DefaultStaleDays,
Automation: DefaultAutomation,
WorkspaceHistory: DefaultWorkspaceHistory,
AIBudget: DefaultAIBudget,
AIAPIKeyEnv: DefaultAIAPIKeyEnv,
BugReportDir: DefaultBugReportDir,
ContextPath: DefaultContextPath,
ContextBudget: DefaultContextBudget,
BriefIncludeNotes: DefaultBriefIncludeNotes,
SessionStartPinnedBodies: DefaultSessionStartPinnedBodies,
SessionStartMailbox: DefaultSessionStartMailbox,
SessionStartRoadmapGlob: DefaultSessionStartRoadmapGlob,
PreCommitWorkflows: DefaultPreCommitWorkflows(),
PostMergeWorkflows: DefaultPostMergeWorkflows(),
// Env is the default; a config.local key overrides it below.
SessionSettingsPath: os.Getenv("EECO_SESSION_SETTINGS"),
}
cfg.Gate = GateFor(cfg.Profile)
// Three-layer resolution: defaults (set above) → user-global
// config → workspace config.local. Each later layer overrides the
// earlier ones.
if err := applyConfigFile(cfg, globalConfigLocalPath()); err != nil {
return nil, fmt.Errorf("read global config: %w", err)
}
if err := applyLocal(cfg); err != nil {
return nil, fmt.Errorf("read config.local: %w", err)
}
return cfg, nil
}
// GlobalConfigEnv overrides the directory eeco reads user-global
// settings from. It takes precedence over XDG_CONFIG_HOME and the
// ~/.config default, and is the hermetic test seam (mirrors
// UsernameEnv) plus a power-user escape hatch.
const GlobalConfigEnv = "EECO_CONFIG_HOME"
// GlobalConfigDir resolves the user-global eeco config directory:
// $EECO_CONFIG_HOME, else $XDG_CONFIG_HOME/eeco, else $HOME/.config/eeco.
// It returns "" only when none can be resolved (no env, no HOME), in
// which case the global layer is simply skipped.
func GlobalConfigDir() string {
if dir := os.Getenv(GlobalConfigEnv); dir != "" {
return dir
}
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
return filepath.Join(xdg, "eeco")
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".config", "eeco")
}
return ""
}
// globalConfigLocalPath is the user-global config.local file, or "" when
// no global config dir can be resolved.
func globalConfigLocalPath() string {
dir := GlobalConfigDir()
if dir == "" {
return ""
}
return filepath.Join(dir, LocalFilename)
}
// validateWorkspaceName rejects names that would escape the repo root
// or otherwise misbehave as a relative path component.
func validateWorkspaceName(name string) error {
if name == "" {
return errors.New("workspace name is empty")
}
if name != filepath.Clean(name) {
return fmt.Errorf("workspace name %q is not a clean path component", name)
}
if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) {
return fmt.Errorf("workspace name %q must be a single path component", name)
}
if name == "." || name == ".." {
return fmt.Errorf("workspace name %q is not allowed", name)
}
return nil
}
// applyLocal applies <workspace>/config.local over cfg when the
// workspace exists. It is the workspace (last-wins) layer of the
// three-layer resolution defaults → global → workspace; see Load and
// applyConfigFile.
func applyLocal(cfg *Config) error {
info, err := os.Stat(cfg.Workspace)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if !info.IsDir() {
// Workspace path exists but is not a directory. Init will
// surface that; don't fail config loading here.
return nil
}
return applyConfigFile(cfg, filepath.Join(cfg.Workspace, "config.local"))
}
// applyConfigFile reads a single config.local-format file at path and
// overrides cfg with the keys it declares. The file is optional — a
// missing file (or an empty path) is a no-op. Format is a flat
// KEY=VALUE list, one entry per line; blank lines and lines starting
// with `#` are ignored. Values may be wrapped in matching single or
// double quotes. Multi-word `gate` is split on whitespace into one
// chain step; the `gate` key is repeatable, each occurrence adding a
// step. Repeatable keys (gate, pre_commit_workflows,
// post_merge_workflows) reset their inherited value on their first
// occurrence in THIS file, so a later layer fully replaces an earlier
// one for that key.
func applyConfigFile(cfg *Config, path string) error {
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// sawPreCommitWorkflows and sawGate track whether the operator has
// declared the `pre_commit_workflows` / `gate` key at least once in
// this file. The first occurrence of each resets the binary or
// profile default so the operator-declared list fully replaces it;
// subsequent occurrences append.
var sawPreCommitWorkflows bool
var sawPostMergeWorkflows bool
var sawGate bool
for lineNo, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("%s:%d: missing '='", path, lineNo+1)
}
key := strings.TrimSpace(k)
val := unquote(strings.TrimSpace(v))
switch key {
case "profile":
cfg.Profile = Profile(val)
cfg.Gate = GateFor(cfg.Profile)
sawGate = false
case "gate":
// Repeatable: the first occurrence resets the profile
// default so the operator-declared chain fully replaces it;
// each occurrence appends one step (a whitespace-split argv
// command). An empty value contributes no step, so a lone
// `gate=` clears the chain and disables the gate.
if !sawGate {
cfg.Gate = nil
sawGate = true
}
if fields := strings.Fields(val); len(fields) > 0 {
cfg.Gate = append(cfg.Gate, fields)
}
case "stale_days":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("%s:%d: stale_days: %w", path, lineNo+1, err)
}
if n < 0 {
return fmt.Errorf("%s:%d: stale_days must be >= 0 (got %d)", path, lineNo+1, n)
}
cfg.StaleDays = n
case "attribution_pattern":
// Repeatable: each occurrence appends one extra regex to the
// attribution denylist. An empty value is ignored so a blank
// override line cannot disable the gate.
if val != "" {
cfg.AttributionPatterns = append(cfg.AttributionPatterns, val)
}
case "automation":
// An unknown or future level is tolerated and falls back to
// the default (floor invariant — never fail on this key).
cfg.Automation = normalizeAutomation(val)
case "workspace_history":
// off | manual | auto. An empty value resets to the default
// (manual); an unknown or future value is tolerated and falls
// back to the default (floor invariant — never fail on this
// key, mirroring `automation`).
if val == "" {
cfg.WorkspaceHistory = DefaultWorkspaceHistory
} else {
cfg.WorkspaceHistory = normalizeWorkspaceHistory(val)
}
case "ai_command":
// Whitespace-split argv for the wired CLI provider. Empty
// leaves the provider unconfigured (passes are parked).
if val == "" {
cfg.AICommand = nil
} else {
cfg.AICommand = strings.Fields(val)
}
case "ai_budget":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("%s:%d: ai_budget: %w", path, lineNo+1, err)
}
if n < 0 {
return fmt.Errorf("%s:%d: ai_budget must be >= 0 (got %d)", path, lineNo+1, n)
}
cfg.AIBudget = n
case "ai_provider":
// Provider selector: `cli` | `anthropic`, or empty for auto.
// An unknown value is tolerated and treated as auto (floor
// invariant — never fail config loading on this key).
cfg.AIProvider = val
case "ai_model":
// Opaque model identifier passed through to the provider.
// Empty resets to the provider's own default.
cfg.AIModel = val
case "ai_api_key_env":
// NAME of the env var holding the API key (never the value).
// Empty resets to the default env-var name.
if val == "" {
cfg.AIAPIKeyEnv = DefaultAIAPIKeyEnv
} else {
cfg.AIAPIKeyEnv = val
}
case "session_settings_path":
// Absolute path to the AI CLI's user-global settings file.
// An empty value clears it (session-start stays unconfigured);
// a relative value is rejected so the hook never edits a path
// resolved against an unexpected working directory.
if val == "" {
cfg.SessionSettingsPath = ""
} else if !filepath.IsAbs(val) {
return fmt.Errorf("%s:%d: session_settings_path must be absolute (got %q)", path, lineNo+1, val)
} else {
cfg.SessionSettingsPath = val
}
case "session_start_docs":
// Repeatable: each occurrence appends one repo-relative path
// to the reading routine, in declared order. Absolute paths
// and `..` traversal are rejected at parse time so the hook
// only reads inside the repo. An empty value is ignored so a
// blank override line does not produce a phantom entry.
if val == "" {
continue
}
if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: session_start_docs must be repo-relative (got %q)", path, lineNo+1, val)
}
cleanDoc := filepath.ToSlash(filepath.Clean(val))
if cleanDoc == ".." || strings.HasPrefix(cleanDoc, "../") {
return fmt.Errorf("%s:%d: session_start_docs escapes the repo (got %q)", path, lineNo+1, val)
}
cfg.SessionStartDocs = append(cfg.SessionStartDocs, cleanDoc)
case "session_files":
// Repeatable: each occurrence appends one delivery target the
// session-start hook writes a marker block to. An entry is
// either repo-relative (held inside the repo by the same
// path-traversal guard `session_start_docs` uses) or absolute
// (mirrors the precedent set by `session_settings_path` for
// the AI CLI's user-global file). An empty value is ignored
// so a blank override line does not produce a phantom entry.
if val == "" {
continue
}
if strings.ContainsAny(val, " \t") {
return fmt.Errorf("%s:%d: session_files: value %q must not contain whitespace", path, lineNo+1, val)
}
if filepath.IsAbs(val) {
cfg.SessionFiles = append(cfg.SessionFiles, val)
} else {
if strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: session_files must be repo-relative or absolute (got %q)", path, lineNo+1, val)
}
cleanRel := filepath.ToSlash(filepath.Clean(val))
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return fmt.Errorf("%s:%d: session_files escapes the repo (got %q)", path, lineNo+1, val)
}
cfg.SessionFiles = append(cfg.SessionFiles, cleanRel)
}
case "session_start_mailbox":
// Repo-relative filename of the mailbox; empty disables the
// block. Absolute paths and `..` traversal are rejected.
if val == "" {
cfg.SessionStartMailbox = ""
} else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: session_start_mailbox must be repo-relative (got %q)", path, lineNo+1, val)
} else {
cleanMb := filepath.ToSlash(filepath.Clean(val))
if cleanMb == ".." || strings.HasPrefix(cleanMb, "../") {
return fmt.Errorf("%s:%d: session_start_mailbox escapes the repo (got %q)", path, lineNo+1, val)
}
cfg.SessionStartMailbox = cleanMb
}
case "session_start_roadmap_glob":
// Glob relative to the repo root for live-planning discovery.
// Empty disables discovery. The glob pattern itself is not
// path-validated here; filepath.Glob will return no matches
// for anything that escapes the repo.
cfg.SessionStartRoadmapGlob = val
case "handover_glob":
// Glob relative to the repo root for the cockpit orient block's
// newest handover note. Empty (the default) falls back to the
// workspace notes dir. Not path-validated here; filepath.Glob
// returns no match for anything that escapes the repo (mirrors
// session_start_roadmap_glob).
cfg.HandoverGlob = val
case "bug_report_dir":
// Workspace-relative directory for `eeco report-bug` records.
// An empty value falls back to the default. Absolute paths
// and `..` traversal are rejected so the write-scope guard
// (Constraint 1) holds at parse time, not just at write time.
// The unix-style-prefix check catches `/abs/path` even on
// Windows, where filepath.IsAbs returns false without a
// drive letter.
if val == "" {
cfg.BugReportDir = DefaultBugReportDir
} else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: bug_report_dir must be relative (got %q)", path, lineNo+1, val)
} else {
clean := filepath.ToSlash(filepath.Clean(val))
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("%s:%d: bug_report_dir escapes the workspace (got %q)", path, lineNo+1, val)
}
cfg.BugReportDir = clean
}
case "pre_commit_workflows":
// Repeatable: the first occurrence in the file resets the
// binary default; each non-empty occurrence appends one
// builtin workflow name. An empty value clears the list so
// `eeco hooks pre-commit on` refuses to install (the
// operator's explicit opt-out). Whitespace inside a value is
// rejected so a stray `name1 name2` line is caught here
// rather than silently producing one workflow with a broken
// name. Workflow-name validity is checked at hook-install
// time so the config package does not depend on the workflow
// registry (cycle).
if !sawPreCommitWorkflows {
cfg.PreCommitWorkflows = nil
sawPreCommitWorkflows = true
}
if val == "" {
continue
}
if strings.ContainsAny(val, " \t") {
return fmt.Errorf("%s:%d: pre_commit_workflows: name %q must not contain whitespace", path, lineNo+1, val)
}
cfg.PreCommitWorkflows = append(cfg.PreCommitWorkflows, val)
case "post_merge_workflows":
// Repeatable, mirroring pre_commit_workflows: the first
// occurrence resets the binary default, each non-empty
// occurrence appends one builtin workflow name, an empty value
// clears the list so `eeco hooks post-merge on` refuses to
// install. Whitespace inside a value is rejected. Workflow-name
// validity is checked at hook-install time (registry cycle).
if !sawPostMergeWorkflows {
cfg.PostMergeWorkflows = nil
sawPostMergeWorkflows = true
}
if val == "" {
continue
}
if strings.ContainsAny(val, " \t") {
return fmt.Errorf("%s:%d: post_merge_workflows: name %q must not contain whitespace", path, lineNo+1, val)
}
cfg.PostMergeWorkflows = append(cfg.PostMergeWorkflows, val)
case "version_locations":
// Repeatable: each occurrence appends one `path:regex` entry
// the `version-sync` builtin reads to detect drift. The path
// is repo-relative; absolute paths and `..` traversal are
// rejected here so the gate never reads outside the repo. The
// regex syntax is RE2 (Go's `regexp`) and must declare at
// least one capture group; the workflow validates that at run
// time. An empty value is ignored so a blank override line
// does not produce a phantom entry. The reserved value `auto`
// switches the gate to auto-detect; it must stand alone —
// mixing it with explicit `path:regex` entries (in either
// order, or declaring it twice) is rejected here.
if val == "" {
continue
}
autoDeclared := len(cfg.VersionLocations) == 1 && cfg.VersionLocations[0] == "auto"
if val == "auto" {
if len(cfg.VersionLocations) > 0 {
return fmt.Errorf("%s:%d: version_locations=auto must be the only version_locations entry", path, lineNo+1)
}
cfg.VersionLocations = []string{"auto"}
continue
}
if autoDeclared {
return fmt.Errorf("%s:%d: version_locations=auto must be the only version_locations entry", path, lineNo+1)
}
relPart, _, hasColon := strings.Cut(val, ":")
if !hasColon || relPart == "" {
return fmt.Errorf("%s:%d: version_locations: expected \"path:regex\" or \"auto\" (got %q)", path, lineNo+1, val)
}
if filepath.IsAbs(relPart) || strings.HasPrefix(relPart, "/") || strings.HasPrefix(relPart, `\`) {
return fmt.Errorf("%s:%d: version_locations path must be repo-relative (got %q)", path, lineNo+1, relPart)
}
cleanRel := filepath.ToSlash(filepath.Clean(relPart))
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return fmt.Errorf("%s:%d: version_locations path escapes the repo (got %q)", path, lineNo+1, relPart)
}
cfg.VersionLocations = append(cfg.VersionLocations, val)
case "version_anchor":
// Single-valued. Three modes: empty (consistency-only,
// default), "tag" (latest semver-shaped reachable git tag is
// the source of truth), or "<path>:<regex>" (designated file).
// The designated-file form is path-validated here (same reject
// table as `version_locations` and the other repo-relative
// keys); regex validity is enforced at workflow run time.
switch val {
case "":
cfg.VersionAnchor = ""
case "tag":
cfg.VersionAnchor = "tag"
default:
relPart, regexPart, hasColon := strings.Cut(val, ":")
if !hasColon || relPart == "" || regexPart == "" {
return fmt.Errorf("%s:%d: version_anchor: expected \"tag\" or \"path:regex\" (got %q)", path, lineNo+1, val)
}
if filepath.IsAbs(relPart) || strings.HasPrefix(relPart, "/") || strings.HasPrefix(relPart, `\`) {
return fmt.Errorf("%s:%d: version_anchor path must be repo-relative (got %q)", path, lineNo+1, relPart)
}
cleanRel := filepath.ToSlash(filepath.Clean(relPart))
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return fmt.Errorf("%s:%d: version_anchor path escapes the repo (got %q)", path, lineNo+1, relPart)
}
cfg.VersionAnchor = val
}
case "context_path":
// Workspace-relative file `eeco go --write` renders the brief
// into. An empty value falls back to the default. Absolute
// paths and `..` traversal are rejected so the write-scope
// guard (Constraint 1) holds at parse time, not just at write
// time. The unix-style-prefix check catches `/abs/path` even
// on Windows, where filepath.IsAbs returns false without a
// drive letter.
if val == "" {
cfg.ContextPath = DefaultContextPath
} else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: context_path must be relative (got %q)", path, lineNo+1, val)
} else {
clean := filepath.ToSlash(filepath.Clean(val))
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("%s:%d: context_path escapes the workspace (got %q)", path, lineNo+1, val)
}
cfg.ContextPath = clean
}
case "context_budget":
// Byte cap on the file `eeco go --write` renders. An empty
// value falls back to the default (0, no cap); a negative
// value is rejected. 0 keeps the full brief.
if val == "" {
cfg.ContextBudget = DefaultContextBudget
} else {
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("%s:%d: context_budget: %w", path, lineNo+1, err)
}
if n < 0 {
return fmt.Errorf("%s:%d: context_budget must be >= 0 (got %d)", path, lineNo+1, n)
}
cfg.ContextBudget = n
}
case "brief_include_notes":
// Opt into a "Recent notes" section in the `eeco go` brief.
// Boolean, default false. An empty value falls back to the
// default; any value strconv.ParseBool does not accept
// ("true"/"false"/"1"/"0"/"t"/"f" in either case) is rejected
// at parse time rather than silently defaulting, so a typo
// surfaces immediately.
if val == "" {
cfg.BriefIncludeNotes = DefaultBriefIncludeNotes
} else {
b, err := strconv.ParseBool(val)
if err != nil {
return fmt.Errorf("%s:%d: brief_include_notes: %w", path, lineNo+1, err)
}
cfg.BriefIncludeNotes = b
}
case "session_start_pinned_bodies":
// Opt into a fourth "pinned memory bodies" block on the
// bundled session-start hook output. Boolean, default false.
// Same parse contract as brief_include_notes — empty falls
// back to the default, typos are loud.
if val == "" {
cfg.SessionStartPinnedBodies = DefaultSessionStartPinnedBodies
} else {
b, err := strconv.ParseBool(val)
if err != nil {
return fmt.Errorf("%s:%d: session_start_pinned_bodies: %w", path, lineNo+1, err)
}
cfg.SessionStartPinnedBodies = b
}
case "init_detection_threshold":
// Confidence floor `eeco init` uses to accept the project-type
// marker scan without prompting. An empty value falls back to
// the default (0, which the detector reads as "use my built-in
// default"). The value must be a fraction in [0,1]; anything
// outside that range is rejected at parse time rather than
// silently clamped, so a typo surfaces immediately.
if val == "" {
cfg.InitDetectionThreshold = 0
} else {
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return fmt.Errorf("%s:%d: init_detection_threshold: %w", path, lineNo+1, err)
}
if f < 0 || f > 1 {
return fmt.Errorf("%s:%d: init_detection_threshold must be in [0,1] (got %s)", path, lineNo+1, val)
}
cfg.InitDetectionThreshold = f
}
default:
// Unknown keys are tolerated for forward-compatibility.
}
}
return nil
}
func unquote(s string) string {
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' || first == '\'') && first == last {
return s[1 : len(s)-1]
}
}
return s
}