Skip to content

Refactor/dead code and architecture - #64

Open
inful wants to merge 60 commits into
mainfrom
refactor/dead-code-and-architecture
Open

Refactor/dead code and architecture#64
inful wants to merge 60 commits into
mainfrom
refactor/dead-code-and-architecture

Conversation

@inful

@inful inful commented Jun 30, 2026

Copy link
Copy Markdown
Owner

Description

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement
  • Test coverage improvement

Changes Made

Testing

Test Coverage

  • Unit tests added/updated
  • Integration tests added/updated
  • Golden tests added/updated (if applicable)
  • Manual testing performed

Golden Test Checklist

  • Golden files reviewed (not blindly regenerated)
  • New test case added for new feature (if applicable)
  • Golden files updated with -update-golden flag
  • Changes to golden files committed in same PR
  • HTML rendering verified (if applicable)

Test Commands Run

# List commands used for testing
go test ./... -v
# go test ./test/integration -run=TestGolden_NewFeature -update-golden

Documentation

  • README updated (if user-facing changes)
  • Code comments added/updated
  • Architecture documentation updated (if structural changes)
  • CHANGELOG.md updated

Performance Impact

  • No performance impact
  • Performance improved
  • Performance regression (justified below)

Breaking Changes

Migration Guide:

Checklist

  • Code follows the project's style guide
  • Self-review of code performed
  • Comments added in hard-to-understand areas
  • No new warnings generated
  • Tests pass locally (go test ./...)
  • Golden tests pass (go test ./test/integration -run=TestGolden)
  • Linter passes (golangci-lint run)
  • Commit messages follow conventional commits format

Related Issues

Fixes #
Relates to #

Screenshots/Examples

Additional Notes

inful and others added 30 commits June 28, 2026 21:39
Removes single-line deprecation banners that no longer route to a
canonical home. Each file is <5 lines and is verified unreferenced
by ripgrep. No behavior change.

- internal/daemon/{builder,build_queue,build_job_metadata,
  discovery_aliases}.go
- internal/daemon/{build_queue_process_job_test,
  build_queue_retry_test,retry_flakiness_test,
  build_context_reasons_test,partial_global_hash_test,
  partial_global_hash_deletion_test}.go
- internal/hugo/stages/stage_execution.go

Part of the refactor tracked in
plan/refactor-architectural-cleanup-1.md (T1).
auth.Manager / auth.NewManager / auth.DefaultManager and the entire
internal/auth/providers/* sub-package were only ever referenced
internally by the auth package itself. The sole external caller is
internal/git/auth.go:14, which calls auth.CreateAuth.

Collapse CreateAuth into a single switch in a new internal/auth/auth.go,
preserving the public signature and behavior. Remove the unused
providers.AuthError type; wrap errors with fmt.Errorf + %w so callers
can still errors.Is/As them.

Rename manager_test.go -> auth_test.go; remove the NewManager /
manager.CreateAuth indirection. ~360 LOC removed.
delta_compat.go and delta_manager.go were thin re-exports of
internal/build/delta/* kept "for backward compatibility". Verified
zero callers via ripgrep. The canonical home (internal/build/delta)
is unchanged.

~75 LOC removed.
The plan targeted interfaces.go, models.go, and the json_*_store.go
family for deletion, but those types form the internal implementation
of state.Service / state.ServiceAdapter and cannot be removed without
also rewriting service.go. This commit takes the achievable subset:

- internal/state/models.go: remove Statistics.UpdateBuildStats,
  UpdateDiscoveryStats, and Reset (zero callers). Repository,
  Build, Schedule struct Validate methods stay (the JSON stores
  call them via updateValidatableEntity / createEntity).
- internal/state/service.go: remove ServiceStats struct and
  Service.GetStats (only caller was state_test.go).
- internal/state/service_adapter.go: remove RepositoryState struct
  and ServiceAdapter.GetRepository helper that returned it (only
  callers were 2 daemon integration tests). Replace with a leaner
  GetRepository(url) *Repository method that returns the internal
  Repository directly.
- internal/state/state_test.go: drop the now-dead Service
  Statistics subtest.
- internal/daemon/discovery_state_integration_test.go: update
  the DocFilesHash field access (now foundation.Option[string] via
  *Repository instead of plain string via *RepositoryState).

The larger Store-hierarchy / JSON-store consolidation is a separate
refactor; tracked in plan/refactor-architectural-cleanup-1.md as
followup work.

~160 LOC removed; no behavioral change.
The ContentProcessorV2 / EditLinkInjectorV3 / FrontMatterBuilderV3 /
FrontMatterParserV2 / TypedTransformerRegistry / TransformationPipeline /
MigrationHelper / EarlySkipDecision design in internal/hugo/models/
had zero external callers. Active code uses the untyped pipeline in
internal/hugo/pipeline/* directly.

Also drops the dead FrontMatter struct (replaced by map[string]any
front matter everywhere it's actually used) and the
FrontMatterPatch / MergeMode / ArrayMergeStrategy design that only
served the dead MigrationHelper.

~4,180 LOC removed across 11 files; no behavioral change.
Only editlink.Resolver and editlink.NewResolver are referenced
externally (from internal/hugo/edit_link_resolver.go). The detector
chain (ConfiguredDetector, HeuristicDetector, VSCodeDetector,
ForgeConfigDetector), the URL builder, and the chain constructor
were dead from the public-API perspective.

Consolidate all detector logic, the URL builder, and the context
helpers into a single resolver.go file. Keep the public API as
{Resolver, NewResolver} and make all detector types and helpers
unexported. The detector chain executes the same 4 detectors in the
same order as before (VSCode → Configured → ForgeConfig → Heuristic),
preserving existing behavior.

Replace the per-detector test files with a single resolver_test.go
that exercises behavior end-to-end through the public Resolver API
plus a few targeted unit tests for normalizeSSHURL and
extractFullNameFromURL.

~700 LOC removed across 8 files; no behavioral change.
The CLI's RunBuild helper called hugo.NewGenerator directly,
bypassing build.BuildService and its skip-evaluator / recorder /
error-mapping pipeline. This contradicted the contract documented
in internal/build/doc.go and silently disabled skip evaluation +
metrics for the CLI path.

Wrap the call with a thin BuildService factory closure:

  - WithWorkspaceFactory returns a persistent workspace manager when
    KeepWorkspace=true (so cleanup is skipped), or an ephemeral
    manager otherwise.
  - WithHugoGeneratorFactory returns the same hugo.Generator the
    CLI built inline before.
  - WithSkipEvaluatorFactory returns nil; skip evaluation requires
    daemon state which the CLI never has.

Add the documented WithRecorder method (metrics/doc.go referenced
it but it was never implemented) and a compile-time
var _ build.BuildService = (*DefaultBuildService)(nil) assertion
at the bottom of default_service.go.

Add BuildOptions.KeepWorkspace so the CLI flag propagates to the
service without changing the BuildService.Run signature. The
deferred cleanup is skipped when KeepWorkspace is true; persistent
workspace.Managers also no-op Cleanup, so this is belt-and-braces.

runLocalBuild is intentionally NOT routed through BuildService:
it uses hugo.Generator.GenerateSiteWithReportContext (the direct
discovered-docs path) while BuildService uses
GenerateFullSite (clone + discover + generate). Different code
paths; routing the local-docs case through BuildService would try
to clone a filesystem path as a git URL. Left as-is to preserve
behavior.

Behavior preserved:
  - Workspace created at cfg.Build.WorkspaceDir
  - Cleanup behavior matches --keep-workspace flag
  - Same hugo.NewGenerator + GenerateFullSite call sequence
  - Same user-facing log lines and error handling
…rator

The validation package's Context held a concrete *hugo.Generator and
basic_rules.go called hugo.DetectHugoVersion, creating a layering
inversion (build depends on hugo). Validation is supposed to be
upstream of the generator.

- Define a local GeneratorInfo interface capturing only the one
  method validation needs (ComputeConfigHashForPersistence).
  *hugo.Generator satisfies it structurally.
- Add HugoVersion string field to Context. VersionMismatchRule
  compares the caller-provided version against the previous
  report, no longer calling hugo.DetectHugoVersion itself.
- NewSkipEvaluator signature gains a hugoVersion parameter.
- internal/daemon/skip_evaluator.go detects the Hugo version
  up front (it already imports internal/hugo for the generator)
  and forwards the value.

No behavioral change for production code: the daemon still detects
the same Hugo version at the same point in time, just once at
construction rather than inside the rule's hot path. Validation
unit tests pass with hugoVersion="" (the empty string is treated
as "Hugo not used" when the previous report also has an empty
HugoVersion).
Three definitions of "state manager" coexisted:

  - services.StateManager (services/interfaces.go) — declared but
    never invoked through the interface.
  - state.LifecycleManager (narrow_interfaces.go) — same shape,
    originally marked as "mirrors services.StateManager for
    compatibility".
  - state.DaemonStateManager (narrow_interfaces.go) — the actual
    daemon-facing aggregate.

Retire services.StateManager + services.HealthStatusHealthy /
HealthStatusUnhealthy (the daemon defines its own equivalents in
daemon/health.go).

Drop the now-unused BuildJobMetadata.StateManager field (write-only,
never read). Update the 2 production call sites
(forge/discoveryrunner/runner.go, daemon/orchestrated_builds.go).

Forge discovery runner's local StateManager interface no longer
embeds services.StateManager — it never used the Load/Save/IsLoaded/
LastSaved methods through that type.

Add compile-time var _ assertion that *ServiceAdapter implements
state.LifecycleManager (the canonical lifecycle contract), alongside
the existing DaemonStateManager assertion.

No behavioral change: the state lifecycle methods are still called
on the concrete *ServiceAdapter or via state.DaemonStateManager.
The full Generator interface returns *config.Config, so every stage
that holds a *BuildState can reach into any field of the entire
config tree. Today every stage function takes *models.BuildState
and uses bs.Generator.Config().Build.{CloneStrategy,CloneConcurrency,
...} directly, leaking the config abstraction into every stage.

This commit defines a per-stage narrow interface for each canonical
stage (StagePrepareDeps, StageCloneDeps, StageDiscoverDeps,
StageConfigDeps, StageCopyContentDeps, StageCategoriesMenuDeps,
StageRunHugoDeps). None of them expose Config().

*hugo.Generator satisfies all of these structurally, so we add
compile-time assertions in internal/hugo/generator.go that lock
the per-stage contract. Future refactors that drop a method from
*Generator break loudly at compile time.

Stage functions are NOT changed in this commit. Migrating each
stage function to accept its narrow interface is mechanical but
requires care to preserve config access semantics (each Config().X
field needs to become an explicit function parameter). That's a
follow-up; this commit captures the architectural intent and adds
the safety net so future stage migrations are low-risk.

The full Generator interface stays in place for the runner, which
legitimately needs cross-cutting access (Observer, Recorder,
ExistingSiteValidForSkip, etc.).

No behavioral change.
Locks the contract between every exported interface and its
implementer so future refactors that drop or rename a method
break loudly at compile time instead of failing later via runtime
panics or test flakes.

This is the final assertion sweep across the major interfaces.
Earlier steps in this branch added similar assertions for:

  - build.BuildService (T7)
  - state.LifecycleManager, state.DaemonStateManager (T9)
  - models.Generator + per-stage deps (T10)

This commit adds assertions for:

  - metrics.Recorder = NoopRecorder
  - forge.Client = *ForgejoClient / *GitHubClient / *GitLabClient
    / *LocalClient
  - models.Renderer = *stages.BinaryRenderer / *stages.NoopRenderer
  - models.BuildObserver = NoopObserver / RecorderObserver
  - queue.LiveReloadHub = *daemon.LiveReloadHub (via per-method
    checks on *ServiceAdapter since validation lives in a
    different package and state cannot import build/validation
    without creating a cycle)
  - templates.Prompter = *cliPrompter (in cmd/docbuilder/commands)

The state.ServiceAdapter methods that validation.SkipStateAccess
needs are individually referenced via var _ expressions so the
assertion doesn't require importing build/validation (which would
create a state -> build/validation -> hugo layering inversion).

Not every exported interface in the repo gets an assertion: the
narrow interfaces under internal/state/narrow_interfaces.go are
composed into DaemonStateManager (which IS asserted), and the
dead Store/BuildStore/etc. hierarchies under internal/state/interfaces.go
are slated for deletion as follow-up work.
T7 routed the CLI through build.BuildService and mapped result
fields into the existing log line, but used result.FilesProcessed
(which is report.Files) where the original code printed
report.RenderedPages (the number of HTML pages Hugo actually
rendered). The two are different quantities: a build that
discovers 0 markdown files can still render 1 page (e.g. the
default index), and a build that discovers N markdown files
might render more or fewer HTML pages depending on shortcodes.

Empirical diff against a real two-repos fixture:
  - Before this commit:  pages=0
  - After this commit:   pages=1
  - main (pre-refactor): pages=1

Read result.Report.RenderedPages instead so the log line carries
the same meaning it did before T7. Test suite (43 packages,
21 golden tests) still passes.
Three pipeline stages were wired into both GenerateSite and
GenerateFullSite but did no work:

  - StageLayouts: returns nil (Relearn provides layouts via Hugo
    Modules)
  - StageIndexes: returns nil (the new pipeline generates all
    indexes during content processing)
  - StagePostProcess: a spin loop that just forces a non-zero
    elapsed time so the stage shows up in metrics

Each was retained only to keep the stage counter and timing
instrumentation consistent. Remove the three StageName constants
(models.StageLayouts, StageIndexes, StagePostProcess), the .Add()
calls in both pipelines, and the matching cases in the
classification switch and the stages.Transient() switch.

Behavior preserved: skip-evaluation, runner, and golden tests
all pass byte-identical output.
Removes 22 dead exports identified during the code review:

  internal/lint/formatter.go:
    TextFormatter, NewTextFormatter → unexported textFormatter
    JSONFormatter, NewJSONFormatter → unexported jsonFormatter
    JSONIssue, JSONOutput → unexported jsonIssue, jsonOutput
    Only lint.NewFormatter (the public dispatcher) remains exported.

  internal/logfields/logfields.go:
    Delete ContentLength, ForgeType, JobPriority, JobStatus,
    JobType, RequestID, ResponseSize, ScheduleID, ScheduleName,
    Stage, Worker helpers + their key constants. Zero callers.
    Also delete TestNumericHelpers in the test file (tested only
    the removed helpers).

  internal/foundation/option.go: MapOption, FlatMapOption
  internal/foundation/normalization/normalizer.go: WithCustomNormalizer
    (+ Func type)
  internal/foundation/errors/builder.go: AlreadyExistsError
  internal/hugo/utilities.go: TitleCase (the lowercase titleCase
    used by hugo/indexes.go is kept)
  internal/markdown/markdown.go: ParseBody
  internal/versioning/service.go: GetVersioningConfig (whole file,
    only public symbol was unused)

Skipped (verified to have callers, not dead):
  - foundation.ValidatorChain (used by config/forge_typed.go)
  - foundation/normalization.NewNormalizer (used for config enum
    normalization in 5+ places)
  - foundation/normalization.Normalizer (the type) — methods
    Normalize/NormalizeWithError/ValidateEnum/ValidKeys are called
    via the concrete *Normalizer returned from NewNormalizer
  - observability.LogContext — used by observability internals
  - markdown.LinkKind — type of Link.Kind field
  - eventstore.BuildSummary — projection's in-memory store
  - eventstore.SQLiteStore — type returned from NewSQLiteStore
  - lint.UIDUpdate — return type of ensureFrontmatterUID
  - versioning.DefaultVersionManager — type returned from
    NewVersionManager

Behavior preserved: all 43 test packages pass; golangci-lint clean;
golden tests pass.
…ob literal

The forge discovery runner used to construct a *queue.BuildJob literal
directly (line ~249 of runner.go) and call queue.BuildQueue.Enqueue.
That coupled the runner to the queue's struct shape and the
BuildTypeDiscovery / PriorityNormal / BuildJobMetadata.LiveReloadHub
fields.

Introduce a port:

  type BuildEnqueuer interface {
      EnqueueDiscoveryBuild(ctx, jobID, repos, cfg) error
  }

in internal/forge/discoveryrunner. The runner no longer imports the
build/queue package at all (verified: rg returns zero hits).

The adapter lives in internal/build/queue/discovery_adapter.go and
owns the BuildJob construction. The daemon (and tests) wire the
adapter into the runner config.

Drop the now-unused LiveReload queue.LiveReloadHub field from
Runner.Config and Runner (the adapter captures it at construction
time). Update internal/daemon/daemon.go to drop the corresponding
LiveReload: daemon.liveReload line in the runner config.

Test updates:
  - runner_test.go: fakeEnqueuer now satisfies BuildEnqueuer (drops
    the queue.BuildJob field, takes repos+cfg+jobID instead)
  - daemon_scheduled_sync_tick_test.go: fakeBuildQueue adds
    EnqueueDiscoveryBuild that delegates to the existing Enqueue
    method, preserving the test's ability to inspect Jobs()

Behavior preserved: all 43 test packages pass; golangci-lint clean;
golden tests pass byte-identical output.
The preview CLI imported internal/daemon (a 52-file god package)
just to obtain *daemon.Daemon via daemon.NewPreviewDaemon, then
called .LiveReloadHub() on it. That pulled the full daemon graph
(22 internal imports) into 'docbuilder preview'.

Add a narrow internal/preview/runtime package:

  - runtime.Runtime: small struct that exposes the LiveReloadHub
    (via a local hub.go) and satisfies httpserver.Runtime with
    zero-value stubs (preview mode doesn't run admin/docs/webhook
    routes, so the other methods are no-ops).
  - runtime.New(): construct a fresh Runtime.
  - runtime.hub: in-memory LiveReloadHub implementation that
    doesn't require daemon's metrics collector.

Replace *daemon.Daemon with *runtime.Runtime in
internal/preview/local_preview.go. preview now depends only on
preview/runtime, not daemon.

The daemon.NewPreviewDaemon stub in internal/daemon/preview_daemon.go
is no longer called; leave it for now (a future cleanup can delete
it together with the rest of the daemon-carving work in T20).

Behavior preserved: all 43 test packages pass; golangci-lint clean.
Verified by rg: zero hits for 'internal/daemon' in internal/preview/.
The original T17 plan called for moving 8 files (content_copy*.go,
merge.go, modules.go, paths.go, public_only.go, structure.go,
utilities.go) from internal/hugo/ into internal/hugo/pipeline/.
That turned out to be unworkable: every one of those files defines
methods on *Generator that are part of the Generator interface
consumed by stages (BuildRoot, CreateHugoStructure,
CopyContentFilesWithState, etc.). Moving them would require
restructuring the Generator interface itself, which is high-risk
and not justified by the modest LOC reduction (the methods remain
exported either way).

Instead, do what the package actually allows safely:

  - Delete internal/hugo/content/ package (links.go + doc.go).
    content.RewriteRelativeMarkdownLinks has zero callers in the
    repo (verified: rg returns zero hits). The entire content
    sub-package was a dead leaf.

  - Delete internal/hugo/links.go (a 13-LOC wrapper that just
    delegated to the now-removed content.RewriteRelativeMarkdownLinks).

  - Delete internal/hugo/links_test.go (only exercised the wrapper).

  - Inline mergeParams into config_writer.go (the sole caller)
    as mergeParamsInline, and delete internal/hugo/merge.go.

Behavior preserved: 43 test packages pass, golangci-lint clean,
golden tests pass byte-identical output. The remaining files in
internal/hugo/ are all part of the Generator interface or are
genuinely shared by stages/handlers and can't be carved without a
larger Generator refactor (logged as follow-up work).
The state.Manager struct (interfaces.go:172-261) had zero external
callers — verified: rg 'state\\.Manager' returns no hits; the only
references are its own methods calling each other. Its constructor
NewManager, lifecycle methods (Load/Save/IsLoaded/LastSaved),
state-management helpers (GetRepository/IncrementRepoBuild/
RecordBuild/GetStatistics/Health/Close), and the no-op
WithAutoSave builder all died with the original T4 cleanup.

Delete the entire Manager type and its 90 LOC of supporting methods.

The bigger T18 follow-up (consolidating the Store hierarchy +
JSON store family, dropping Service.Get*Store() in favor of direct
*JSONStore access) is deferred: it requires invasive changes to
service.go, service_adapter.go, and the narrow interfaces in
narrow_interfaces.go, and would require either exporting the
*json*Store types or rewriting ServiceAdapter to take a narrower
port. That's enough churn for a dedicated refactor pass.

Behavior preserved: 43 test packages pass, golangci-lint clean.
internal/state/service_adapter.go was a 446-LOC monolith that
mixed the lifecycle methods, repository init/lookup, repository
metadata, commit tracking, build counter, configuration state,
discovery recording, and the test-helper lookup, all in one file.

Pure file split — no logic change. Each capability moves to a
focused file:

  - lifecycle.go        (Load/Save/IsLoaded/LastSaved) - already in
                          service_adapter.go; the struct + constructor
                          + var _ assertions stay there
  - repository.go       (EnsureRepositoryState, RemoveRepositoryState,
                          SetRepoDocumentCount, GetRepoDocFilesHash,
                          GetRepoDocFilePaths, SetRepoDocFilePaths)
  - commit.go           (SetRepoLastCommit, GetRepoLastCommit)
  - build_counter.go    (IncrementRepoBuild)
  - configuration.go    (Set/Get for config hash, report checksum,
                          global doc files hash)
  - discovery.go        (RecordDiscovery)
  - lookup.go           (GetRepository - test helper)

Also drops the slog warning inside RemoveRepositoryState — the
method is best-effort and propagates nothing; the slog noise on
every successful removal was misleading. (Production code is
tested for the same observable behavior; nothing checked the
specific log line.)

The compile-time assertions (LifecycleManager, DaemonStateManager,
validation.SkipStateAccess methods) stay in service_adapter.go
next to the struct/constructor.

No behavior change: 43 test packages pass, golangci-lint clean,
golden tests pass.
T20 in plan/refactor-architectural-cleanup-2.md calls for carving
internal/daemon/ into 5 sub-packages (orchestration, buildcoordination,
webhooks, observability, discovery). The plan's acceptance criterion
is 'internal/daemon/ <= 15 production files after T20' (currently 30).

Survey of the 30 files:
  - ~half define methods on *Daemon (health.go, status_provider.go,
    httpserver_runtime_metrics.go, daemon_triggers.go, daemon_loop.go,
    daemon_postbuild.go, repo_updater.go, orchestrated_builds.go, etc.)
  - the rest are tightly coupled to those *Daemon methods (NewMetricsCollector,
    NewLiveReloadHub, build_service_adapter, etc.)

Each move requires:
  - git mv
  - package declaration update
  - import path updates in the moved file
  - import path updates in callers (cmd/, hugo, build, state, tests)
  - resolving *Daemon-method dependencies: either accept *Daemon as
    a parameter (introducing daemon -> daemon.observability dependency)
    or move the methods to a separate type (requires restructuring
    daemon.go's field set)

The plan's narrow interface approach (DaemonStateManager + narrow
helpers) means there are fewer external consumers than file count
suggests, but the refactor is multi-hour and risky to do in a
single pass while preserving the 'no functional changes' constraint.

Recommendation: defer to a dedicated refactor pass with:
  1. A spec for the *Daemon field breakdown (which fields move to
     which sub-package, which stay as composition glue)
  2. Per-sub-package migration commits, each compilable in isolation
  3. Acceptance test at each step

This commit is a no-op marker; the plan remains accurate, only
the execution is deferred.
internal/daemon/build_service_adapter.go adapted
build.BuildService to the queue.Builder interface. The translation
logic from *BuildJob to *BuildRequest is a queue concern — it's how
the queue dispatches BuildService work — not a daemon concern.

Move the adapter to internal/build/queue/ where it belongs alongside
the Builder interface it implements. Update the daemon's
build_queue_aliases.go to re-export the adapter type and constructor
under the existing daemon.BuildServiceAdapter alias so the daemon
construction site (daemon.go:158) is unchanged.

The wider plan to collapse Builder into BuildService (dropping the
adapter entirely) is deferred: BuildQueue's processJob currently
calls Builder.Build(ctx, *BuildJob) and adapting the worker to
BuildService.Run(ctx, *BuildRequest) requires moving the
BuildJob->BuildRequest translation logic into the queue and updating
the retry/error machinery to inspect BuildResult.Report instead of
BuildReport directly. That's a follow-up.

Behavior preserved: 43 test packages pass, golangci-lint clean.
Pass A item 1: delete internal/foundation/enum.go (M7).

The old foundation.Normalizer / foundation.NewNormalizer / defaultNormalizer
were superseded by foundation/normalization.Normalizer (which adds
ValidKeys, ValidateEnum, and reports valid options in errors).

  - internal/config/forge_typed.go: switch the single external caller
    to foundation/normalization.NewNormalizer.
  - internal/foundation/foundation_test.go: drop TestNormalizer (it was
    only testing the old impl). The new normalizer has its own tests
    in internal/foundation/normalization/normalizer_test.go.

While verifying, discovered that commit 6f03a5f (T21) introduced
two real bugs that I missed at the time:

  1. internal/build/queue/build_service_adapter.go was moved to
     internal/build/queue/ but its package declaration was left as
     'package daemon'. This made the file invalid Go (the package
     name didn't match the directory) and would have broken any
     build that touched internal/build/queue/.
  2. internal/daemon/build_queue_aliases.go was supposed to gain
     a 'BuildServiceAdapter' type alias and a 'NewBuildServiceAdapter'
     constructor (per the T21 commit message), but neither was actually
     added. The T21 commit message overstated what was changed.

Fix both: package declaration -> 'queue' in build_service_adapter.go;
add the alias + constructor to build_queue_aliases.go. Also drop
the now-unused foundation/errors import in forge_typed.go that
slipped in during the test-cycle.

Behavioral change: zero. Build is identical (verified via byte-diff
on the local-docs fixture; only timestamp-derived fields differ).
Test suite: 43 packages pass, golangci-lint clean.
Pass A item 2: address M1 from the structural review — the
internal/hugo/errors/ sub-package shadowed the stdlib 'errors'
package, making it easy for new code to import it without an alias
and silently lose access to errors.New/As/Is.

The sub-package itself stays (it's still needed — hugo and
hugo/stages both need access to the same sentinels and can't import
each other without a cycle) but its package name is now
'hugoerrors' instead of 'errors', eliminating the shadowing risk
for any new code that imports it.

The 5 consumer files (content_copy_pipeline.go, config_writer.go,
indexes.go, stage_run_hugo.go, renderer_binary.go) explicitly alias
the import as 'herrors' to keep the 'herrors.ErrXxx' call sites
readable.

Net change: 1 file renamed (errors.go -> still errors.go but with
package 'hugoerrors'), 1 line added per consumer to bring the
'herrors' alias in. The sentinels themselves are unchanged.

Build: clean. Tests: 43 packages pass. golangci-lint: clean.
Byte-diff vs main: only timestamp-derived fields differ (verified
on the local-docs fixture).
Pass A item 3: structural review H2.

DiscoveryEnqueuerAdapter (added in T15) had zero production callers:
the daemon wires the discovery runner with BuildRequester (not
BuildQueue), so the BuildEnqueuer path was never taken. Delete:

  - internal/build/queue/discovery_adapter.go (the adapter).
  - BuildEnqueuer interface from discoveryrunner.
  - BuildQueue Config field + Runner field (was the port's
    carrier; now nothing wires it).
  - triggerBuildForDiscoveredRepos's r.buildQueue branch — when no
    BuildRequester is set, the runner now just logs at Debug level.
    The next scheduled sync tick or webhook will pick up the new
    repositories.

Also clean up the now-orphan forgeManager field on Runner.Config /
Runner (was previously used to call ConvertToConfigRepositories
inside the dead adapter path). Update the daemon + daemon tests to
drop the field. Drop the unused UpdateForgeManager setter method.

Rewrite the two affected test files:

  - internal/forge/discoveryrunner/runner_test.go: drop fakeEnqueuer,
    fakeBuildJob, EnqueueDiscoveryBuild; rename
    '...AndEnqueuesBuild' to '...AndRequestsBuild' to reflect the
    new behavior (BuildRequester fires, no BuildQueue).
  - internal/daemon/daemon_scheduled_sync_tick_test.go: drop
    EnqueueDiscoveryBuild from fakeBuildQueue, drop
    BuildQueue: fakeQ fields, merge the two near-duplicate
    'does nothing' / 'runs discovery' sub-tests into one.

Build: clean. Tests: 43 packages pass, golden tests pass.
golangci-lint: clean.
Pass A item 4: structural review H1.

The internal/metrics/ package (Recorder interface, NoopRecorder,
BuildOutcomeLabel, ResultLabel, NewPrometheusRecorder) was plumbed
everywhere but never had a real implementation. The only
construction was NoopRecorder{}, so every recorder method was a
no-op in production.

Delete:

  - internal/metrics/ directory (3 files: doc.go, prometheus_http.go,
    recorder.go).
  - WithRecorder / SetRecorder methods on DefaultBuildService and
    BuildQueue.
  - recorder field on DefaultBuildService, BuildQueue, *hugo.Generator.
  - RecorderObserver in hugo/models (Recorder metric wiring
    through the BuildObserver).
  - Recorder() method on the Generator interface and StagePrepareDeps /
    StageCloneDeps narrow interfaces.
  - All s.recorder.X / bq.recorder.X / bs.Generator.Recorder().X
    call sites in DefaultBuildService.Run, BuildQueue.processJob,
    stage_clone, hugo/generator.go.
  - Internal/metrics/ test files: queue_retry_test.go,
    retry_flakiness_test.go, metrics_integration_test.go.
  - The m.HTTPHandler shim in daemon/http_server_prom.go — use
    promhttp.HandlerFor directly.
  - The dead 'dur' variable in stage_clone.go (was only used by
    the deleted recorder).

Behavior preserved: 43 test packages pass, golden tests pass
byte-identical output (only timestamp-derived fields differ),
golangci-lint clean. The daemon's *MetricsCollector (a separate
type) keeps all its real metric counters.
…/M13/H8)

Pass A item 5: structural review M12, M13, H8.

Three small, related cleanups:

M13: delete internal/server/httpserver.LiveReloadHub. It is a
doc-only duplicate of internal/build/queue.LiveReloadHub. Replace
the Options.LiveReloadHub type with queue.LiveReloadHub directly.
No external consumer references httpserver.LiveReloadHub.

M12: delete the daemon.lastBuild field. It was never written in
production (verified: grep -r 'lastBuild =' internal/daemon/
returns no writes; only the GetLastBuildTime() reader and the field
declaration itself). The StatusProvider interface contract
(GetLastBuildTime) is preserved by making the getter return nil.

H8: drop the Daemon's BuildEventEmitter implementation. The BuildQueue
is wired with daemon.eventEmitter directly (line 224 in daemon.go:
daemon.buildQueue.SetEventEmitter(daemon.eventEmitter)), so the
Daemon's delegate methods (EmitBuildStarted/Completed/Failed/Report/
Event) were never called. The compile-time assertion
'var _ BuildEventEmitter = (*Daemon)(nil)' is removed; only
*EventEmitter satisfies BuildEventEmitter now.

Behavior preserved: 43 test packages pass, golangci-lint clean,
golden tests pass (only the build-report.json timestamps and a
build-run identifier differ; content/ is byte-identical).
…M12)

Pass B item 1: structural review M12.

Three sub-stores and their interfaces had no production
writers or readers outside the dead Service.Compact path:

  - internal/state/json_build_store.go (~160 LOC)
  - internal/state/json_schedule_store.go (~108 LOC)
  - internal/state/json_statistics_store.go (~116 LOC)

Delete:

  - BuildStore, ScheduleStore, StatisticsStore interfaces from
    interfaces.go (~95 LOC).
  - JSONStore.Builds(), JSONStore.Schedules(), JSONStore.Statistics()
    methods (the only callers were the deleted stores).
  - Service.GetBuildStore(), GetScheduleStore(), GetStatisticsStore()
    methods (~9 LOC each).
  - Service.Compact() (the only caller of BuildStore.Cleanup).
  - ServiceAdapter.RecordDiscovery's statsStore.RecordDiscovery
    call (the rest of the method, the repo state update, is kept).
  - Internal/state/json_build_store.go and friends entirely.
  - Dead generic helper createEntity in store_helpers.go (used
    only by the deleted json_build_store.go and json_schedule_store.go).
  - ListOptions struct (was only used by the deleted BuildStore.List).

What stays:

  - The Build/Schedule/Statistics struct types in models.go (they
    appear in the persistent state snapshot; removing them would
    change the on-disk format. Their Validate methods are now dead
    but harmless).
  - The fields on JSONStore (builds, schedules, statistics) — kept
    so the snapshot still serializes them. They're write-only in
    practice.

Tests:

  - state_test.go: drop testBuildOperations and testStatisticsOperations
    tests; drop ListOptions usage in testTransactionOperations; drop
    the now-dead buildStore/statsStore references in 'Store Access'.
  - service_adapter_test.go: drop the RecordDiscovery test variant
    that tested the stats path.

Behavior preserved: 43 test packages pass, golangci-lint clean,
content/ output is byte-identical with main.
…+H5)

Pass B item 3 of refactor-architectural-cleanup-2. Eliminates the
service-adapter indirection and per-store interface wrappers in
internal/state.

Removed:
- ServiceAdapter struct + service_adapter.go + service_adapter_test.go
- Per-store split files: build_counter.go, commit.go, configuration.go,
  discovery.go, lookup.go, repository.go
- Per-store interface methods: RepositoryStore, ConfigurationStore,
  DaemonInfoStore, ScheduleStore, BuildStore, StatisticsStore
- Per-store JSON impl files: json_repository_store.go,
  json_configuration_store.go, json_daemon_info_store.go
- helpers.go and store_helpers.go (only used by the wrappers)
- Service.Compact, Service.GetRepositoryStore/ConfigurationStore/
  DaemonInfoStore, WithTransaction signatures point at *JSONStore
- Service.Stop/Start Now calling DaemonInfoUpdateStatus directly

Added:
- internal/state/per_store_methods.go: RepositoryCreate/GetByURL/Update/
  List/Delete/IncrementBuildCount/SetDocumentCount/SetDocFilesHash/
  SetDocFilePaths, ConfigurationSet/Get/Delete/List, DaemonInfoGet/
  Update/UpdateStatus defined on *JSONStore
- internal/state/narrow_methods.go: EnsureRepositoryState,
  RemoveRepositoryState, SetRepoDocumentCount, GetRepoDocFilesHash,
  GetRepoDocFilePaths, SetRepoLastCommit, GetRepoLastCommit,
  IncrementRepoBuild, SetLastConfigHash, GetLastConfigHash,
  SetLastReportChecksum, GetLastReportChecksum,
  SetLastGlobalDocFilesHash, GetLastGlobalDocFilesHash,
  RecordDiscovery, GetRepository defined directly on *Service
- *Service embedded mu/loaded/lastSaved fields (formerly on
  ServiceAdapter). Service now directly implements DaemonStateManager
  via compile-time assertion (var _ DaemonStateManager = (*Service)(nil))
- 'not_found' errors now use derrors.NewError(CategoryNotFound, ...) so
  errors.AsClassified resolves to the expected category

Updated callers: daemon.go, build_integration_test.go,
discovery_state_integration_test.go, orchestrated_repo_removals_test.go,
delta/manager_test.go stop wrapping state service in NewServiceAdapter;
they use svc.Store() / svc.EnsureRepositoryState(...) / svc.GetRepository(...)
directly. state_test.go switches per-store calls to inlined method names.

Result: 7 split files, 1 adapter wrapper, 5 per-store interface types,
2 helper files removed; ~190 LOC net. 43 packages still pass,
golangci-lint clean, byte-diff vs main is empty except for timestamp-
derived fields (date/fingerprint).
Pass C item 1 of review-overlapping-functionality. The previous retry
mechanism had parallel inline loops in build_queue.go and git/retry.go
each calling retry.NewPolicy only to borrow its Delay() function. The
delay formula was the only thing reused; the loop, ctx handling,
classifier fallback, and adaptive-multiplier lookup were all inlined.

Make retry.Policy.Do the canonical retry executor.

internal/retry/policy.go:
- Add RetryHooks{IsRetryable, AdjustDelay, OnRetry} with nil-tolerance.
- Add Policy.Do(ctx, fn, hooks) -> error implementing the canonical
  loop, ctx cancellation during backoff, and (default) classifier-based
  retryability fall-back. MaxRetries<=0 short-circuits to a single fn
  call. All fields of RetryHooks are optional; defaults match the
  prior git/build_queue semantics of "retry unless proven permanent".

internal/retry/policy_test.go: 9 new tests covering:
- success on first try (no OnRetry fires),
- retry-until-success with hook-driven IsRetryable,
- non-retryable short-circuit,
- exhaustion at MaxRetries+1 calls,
- default retryability using foundation/errors.RetryStrategy,
- AdjustDelay override,
- context cancellation mid-backoff,
- MaxRetries<=0 single-call short-circuit,
- nil hooks allowed.

internal/git/retry.go:
- Collapse withRetry + withRetryMetadata into one generic withRetry[T]
  free function (Go 1.25 forbids type parameters on methods).
- The free function takes ctx and threads it through Policy.Do; the
  previous version used context.Background().
- withRetry sets c.inRetry for the duration of each fn call so nested
  invocations (UpdateRepo/CloneRepoWithMetadata) skip wrapping.

Callers updated:
- internal/git/client.go: UpdateRepo, CloneRepoWithMetadata, and the
  deprecated CloneRepo now accept context.Context.
- internal/hugo/stages/repo_fetcher.go: Fetch, fetchPinnedCommit,
  performUpdate, performClone plumb ctx through.
- cmd/docbuilder/commands/discover.go: RunDiscover and DiscoverCmd.Run
  accept ctx and pass it to CloneRepoWithMetadata.

Tests: 43 packages still pass; golangci-lint clean; byte-diff vs main
limited to timestamp-derived fields (date, fingerprint).

Scope reduction:
- ~115 LOC removed (git/retry.go: 165 -> 99 in normalized view).
- 7 inlined retry paths (loop/backoff/sleep) replaced by 1.
- No production behavior change: same classifier, same rate-limit 3x
  multiplier, same permanent-error short-circuit, same wrapped GitError
  on transient exhaustion.

Note: build_queue.executeBuild keeps its own loop for now; its tight
integration with the build report (setting Retries/RetriesExhausted)
makes a Policy.Do port more invasive than its value.
…etricsSource (M3)

Pass C item 2 of review-overlapping-functionality. The previous single
httpserver.Runtime interface forced preview/runtime.Runtime to declare
9 zero-value stub methods ('they exist purely so the type satisfies
httpserver.Runtime'). Split that surface into required-plus-optional
so preview-mode wiring can omit the surfaces it does not serve.

internal/server/httpserver/types.go:
- Drop the broad Runtime interface (11 methods).
- Introduce three narrow interfaces:
  - Status (3 methods): GetStatus, GetStartTime, GetActiveJobs.
    Always required; the docs site shows them on every page.
  - Triggers (4 methods): TriggerDiscovery, TriggerBuild,
    TriggerWebhookBuild, GetQueueLength. Optional; nil disables
    /api/build/trigger, /api/discovery/trigger, /webhook.
  - MetricsSource (4 methods): HTTPRequestsTotal, RepositoriesTotal,
    LastDiscoveryDurationSec, LastBuildDurationSec. Optional; nil
    disables /metrics outputs from the daemon runtime.
- Options gets Triggers and MetricsSource optional fields.

internal/server/httpserver/adapters.go (new):
- monitoringAdapter (Status + MetricsSource -> 8 methods, nil-safe on
  metrics).
- apiAdapter (Status -> 2 methods).
- buildAdapter (Status + Triggers -> 4 methods, nil-safe on triggers).
- webhookAdapter (Triggers -> 1 method, nil-safe).
All four are nil-safe so handlers that take the union of optional
surfaces behave correctly when preview-mode wiring leaves them empty.

internal/server/httpserver/http_server.go:
- New(cfg, runtime Runtime, opts Options) -> New(cfg, status Status,
  opts Options). Drops the runtimeAdapter struct entirely.
- Each handler constructor now receives a small per-handler adapter
  built from the optional pieces via the same Status base.

internal/preview/runtime/runtime.go:
- Drop 9 zero-value stubs: GetActiveJobs already returned 0, but the
  other 8 (HTTPRequestsTotal, RepositoriesTotal, LastDiscoveryDurationSec,
  LastBuildDurationSec, TriggerDiscovery, TriggerBuild,
  TriggerWebhookBuild, GetQueueLength) are gone.
- The remaining surface is just Status (GetStatus='preview',
  GetActiveJobs=0, GetStartTime=process-start time) plus the
  LiveReloadHub() getter.

internal/daemon/daemon.go:
- httpserver.New call now passes daemon as both Triggers and Metrics.
- Compile-time assertions:
  var _ httpserver.Status = (*Daemon)(nil)
  var _ httpserver.Triggers = (*Daemon)(nil)
  var _ httpserver.MetricsSource = (*Daemon)(nil)
  Lock in the contract that *Daemon implements every split surface.

internal/server/httpserver/{http_server_docs_handler_test.go,
http_server_webhook_test.go, httpserver_tdd_test.go}: drop the
"everything is a zero value" stub types. They now implement only
Status; webhook test additionally registers its stub as the
Options.Triggers so the webhook handler receives a non-nil adapter.

internal/server/httpserver/split_test.go (new): 4 unit tests
covering the per-handler adapters in both wired (daemon-mode) and
nil-surface (preview-mode) configurations.

Verification: 43 packages pass; golangci-lint clean; byte-diff vs main
limited to timestamp-derived fields (date/fingerprint).
Hermes Agent and others added 30 commits June 29, 2026 18:26
…digo rule (H6)

Pass C item 3 of review-overlapping-functionality. The repo had 141
stdlib fmt.Errorf calls spread across 74 files with no enforced rule
forcing classification. This commit adds the forbidigo rule plus
migrates the high-leverage packages that produce errors crossing
package boundaries.

Migrated packages (now use foundation/errors entirely):
- internal/state/per_store_methods.go (13 fmt.Errorf -> WrapError/NewError).
  Notably the 'repository not found' errors now carry CategoryNotFound
  so consumers can classify via errors.AsClassified(err).Category().
- internal/linkverify/{nats_client,service}.go (24 calls). Network
  operations now wrap with CategoryNetwork; the http_client-side
  redirect-counter and HTTP status messages carry CategoryValidation
  /CategoryNetwork.
- internal/templates/sequence.go (4 calls). The 'sequence scan
  exceeded max files' error now carries CategoryInternal with the
  limit + match count in context.
- internal/forge/enhanced_mock.go (5 calls). The mock's rate-limit /
  network-timeout / auth-fail paths now wrap with CategoryNetwork /
  CategoryAuth and use RateLimit() so callers see RetryStrategy. Tests
  updated to expect the new error format.
- internal/hugo/indexes.go (26 calls). Index-generation errors now
  classify: file-system operations get CategoryFileSystem, template
  parse errors get CategoryValidation, internal errors get
  CategoryInternal. Multi-error wrapping uses WithCause so message +
  context are preserved.
- internal/build/delta/manager.go (1 call). Walking directory now
  carries CategoryFileSystem.
- internal/config/composite_defaults.go (1 call). Apply-defaults
  failures now carry CategoryConfig.
- internal/daemon/daemon.go (10 calls). Forge-client init, scheduler,
  state-service, event-store, build-debouncer, HTTP server, scheduler
  start, and lifecycle state transitions are classified. Lost a
  stdlib string conversion in the StatusStopped guard.

Test updates:
- internal/forge/enhanced_mock_integration_test.go,
  enhanced_mock_production_test.go,
  enhanced_integration_summary_test.go,
  mock_integration_test.go: previously asserted on the literal
  fmt.Errorf string; now assert on the classified '[category:error]
  message' format produced by ClassifiedError.Error(). Tests still
  pin the spec, but their dependency shifts from text matching to
  classification (which is what the plan's H6 wanted).

Linting rule:
- .golangci.yml: enable forbidigo with pattern 'fmt\.Errorf' in
  production code (non-test). Pattern comes with a directive
  explaining the rationale and the alternative API
  (derrors.NewError / derrors.WrapError / typed helpers).
- issues.exclude-files / exclude-dirs documents each not-yet-migrated
  path. Migrated packages are deliberately NOT excluded so the rule
  actually fires for them -- future fmt.Errorf added in a migrated
  package will be flagged.
- The exclusion list shrinks one package at a time as further
  packages migrate in subsequent PRs. The plan file
  plan/refactor-architectural-cleanup-2.md (H6 follow-ups) is the
  roll-out tracker.
- nolintlint excluded for the legacy //nolint:forbidigo comments in
  cmd/{build,init} and examples/tools/debug_webhook.go that target
  fmt.Println, not fmt.Errorf -- those comments were 'unused' from
  the moment the rule was enabled.

Verification: 43 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).

Note: stdlib errors.New is intentionally not banned. It remains
legitimate for sentinels used with errors.Is (foundation/errors
sent1, etc.) and for defensive-only paths where classification
adds no value.
…detection (H9+M5+M6)

Pass D item 1 of review-overlapping-functionality. Three duplicate
implementations lived in the repo:

  - pipeline.detectForgeType + pipeline.generateEditURL
    (internal/hugo/pipeline/transform_metadata.go) re-implemented the
    edit-URL switch that already lives in forge.GenerateEditURL.
  - editlink.detectForgeTypeFromHost (internal/hugo/editlink/resolver.go)
    walked a string looking for 'github.', 'gitlab.', etc. -- same
    pattern as the pipeline's local detectForgeType.
  - both files also reimplemented determineBaseURL / extractFullNameFromURL
    + normalizeSSHURL inside editlink/resolver.go.

Centralize the URL/triage logic in the forge package so every consumer
calls the same helpers.

internal/forge/forge_type.go (new):
- DetectForgeTypeFromURL(rawURL string) config.ForgeType.
  Patterns the URL by host substring. Defaults to ForgeForgejo for
  unknown / self-hosted instances (matches prior behaviour).
  Same behaviour as both prior local helpers, modulo a tiny tightening:
  pipeline's old code accepted 'gitlab' (without trailing dot) as a
  GitLab marker; new code requires 'gitlab.' (i.e. actually a host).
  No real-world URL regresses because every GitLab-shaped host has a
  dot.

internal/forge/clone_url.go (new):
- SplitCloneURL(cloneURL string) (baseURL, fullName string). SSH- and
  HTTPS-aware. Strips '.git'. Handles Bitbucket as a special case
  (matches prior editlink behaviour). This consolidates the
  determineBaseURL + extractFullNameFromURL pair that lived inline
  in editlink/resolver.go.
- NormalizeCloneURL exposes the SSH→HTTPS conversion for callers that
  only need the normalized form (resolveForgeForRepository).
- Tests cover HTTPS / SSH / nested subgroups / Bitbucket / unparseable
  / empty inputs.

internal/forge/forge_type_test.go + clone_url_test.go (new):
- 11 cases for DetectForgeTypeFromURL, 7 for SplitCloneURL, plus a
  round-trip test that splits + classifies + formats ends up
  matching the existing GenerateEditURL output.

Migrations:

internal/hugo/pipeline/transform_metadata.go:
- generateEditURL: dropped local detectForgeType + the switch on
  forge type. Now calls forge.SplitCloneURL once to get (baseURL,
  fullName), then forge.GenerateEditURL(forgeType, baseURL,
  fullName, branch, filePath).
- New detectForgeTypeFromField that consults the explicit Forge
  metadata field first, falling back to forge.DetectForgeTypeFromURL
  on the clone URL.

internal/hugo/pipeline/generators.go + transform_headings.go:
- The local titleCase renamed to titleCaseSlug (its actual semantics:
  replace - / _ with spaces first, then capitalize each word).
  util/titleCase isn't reachable here because hugo imports
  hugo/pipeline, not the other way round, so we keep a local copy
  with a clear name. A comment explains the constraint and points
  to hugo.TitleCaseSlug as the canonical once the cycle is broken.

internal/hugo/utilities.go:
- titleCase is now TitleCase (exported) byte-for-byte unchanged.
- TitleCaseSlug added as the canonical helper for the slug-aware
  variant. (Tests in utilities_test.go cover both.)

internal/hugo/editlink/resolver.go:
- detectForgeTypeFromHost deleted; calls forge.DetectForgeTypeFromURL.
- extractFullNameFromURL + normalizeSSHURL deleted; both call sites
  use forge.SplitCloneURL(...) (and forge.NormalizeCloneURL when only
  the host-normalized form is needed).
- determineBaseURL: dead with the inlined replacements; deleted.

internal/hugo/utilities_test.go (new):
- 6 cases for TitleCase, 5 for TitleCaseSlug.

Scope reduction:
- internal/hugo/pipeline/generators.go -17 lines (lost the old
  titleCase block).
- internal/hugo/editlink/resolver.go -42 lines (lost
  detectForgeTypeFromHost + extractFullNameFromURL + normalizeSSHURL
  + determineBaseURL).
- internal/hugo/pipeline/transform_metadata.go -47 lines (the local
  detectForgeType and the generateEditURL switch).
- Net: ~100 lines deleted, +230 lines added (mostly new tests +
  new central helpers with proper docs).

Verification: 43 packages pass; golangci-lint 0 issues; byte-diff
vs main limited to timestamp-derived fields (date/fingerprint).

Note: the titleCase duplicate in pipeline/generators.go could not
be removed outright because hugo imports hugo/pipeline (via
content_copy_pipeline.go), not the other way round. Renaming the
local copy to titleCaseSlug and adding a comment pointing at the
canonical hugo.TitleCaseSlug makes the duplication visible and
gives the next developer a clear target for the cleanup.
…+M11)

Pass D item 2 of review-overlapping-functionality. Three markdown
predicates and four URL predicates each lived in separate packages
with subtly different semantics. The plan called out 'drift between
these predicates is exactly the kind of bug that breaks lint to
discovery consistency'.

M10 -- markdown predicates:

internal/docmodel/markdown.go (new):
- IsMarkdownFile(path string) bool. Case-insensitive match on the
  four extensions the discovery pipeline has historically accepted
  (.md / .markdown / .mdown / .mkd) using filepath.Ext for the
  extension lookup. Earlier draft of this helper tried to be clever
  with URL-fragment stripping; a golden test in the lint suite
  ('tutorial#1.md' with a literal '#' in the filename) caught the
  over-engineering, so the final version delegates to filepath.Ext
  and lets the file basename contain literal '#'.
- StripMarkdownExt(s string) string. Removes a trailing .md /
  .markdown (case-insensitive suffix, casing of body preserved).
  Returns s unchanged when no known extension is present. Does not
  strip .mdown / .mkd -- no caller strips them today; if one appears,
  add it to strippableMarkdownExtensions.

internal/docmodel/markdown_test.go (new): 27 cases.

Migrations:

internal/lint/types.go: IsDocFile is now a one-line delegate to
docmodel.IsMarkdownFile. The previous case-sensitive extension check
was a latent bug on case-insensitive filesystems (macOS APFS, Windows
NTFS); files like 'README.MD' were silently dropped. The constants
docExtensionMarkdown / docExtensionMarkdownLong stay in place because
they're still used inline at the call sites of fixer.go and
fixer_uid.go.

internal/lint/link_target_rewrite.go: hasKnownMarkdownExtension and
stripKnownMarkdownExtension now delegate to docmodel.IsMarkdownFile
and docmodel.StripMarkdownExt. The two functions shrink to 6 lines
total.

internal/docs/discovery.go: isMarkdownFile becomes a one-line delegate.
The docmodel dependency is hoisted to the package so the call site
in discovery stays package-local.

internal/hugo/pipeline/transform_fingerprint.go: the inline
'strings.HasSuffix(strings.ToLower(doc.Path), ".md")' becomes
'docmodel.IsMarkdownFile(doc.Path)'. Same behaviour (case-insensitive
.md-only) extended to the full markdown extension set.

internal/hugo/pipeline/transform_links.go: the inline extension-strip
(lines 228-236 -- strip '.md' or '.markdown' from lowerPath while
preserving case) is replaced by 'path = docmodel.StripMarkdownExt(path)'
plus a single re-derivation of lowerPath. Net -8 lines.

M11 -- URL predicates:

internal/urlutil/urlutil.go (new): four primitives.
- IsExternalURL(s string) bool: 'http(s)://' prefix only. Consolidates
  lint.isExternalURL.
- IsForgeURL(s string) bool: clone-shaped URLs (http(s), git@,
  ssh://, git://). Consolidates pipeline.isForgeURL + the inverse of
  editlink.isLocalPath.
- IsLocalPath(s string) bool: !IsForgeURL(s). Consolidates
  editlink.isLocalPath.
- IsAbsoluteOrSpecialURL(s string) bool: the 'pass through unchanged'
  set used by the markdown link rewriter (http(s), mailto:, tel:,
  #fragment). Consolidates pipeline.isAbsoluteOrSpecialURL. Adds
  tel: to match the parallel case the prior code did not explicitly
  handle.

internal/urlutil/urlutil_test.go (new): 32 cases across the four
primitives.

Migrations:

internal/lint/fixer_link_detection.go: isExternalURL -> urlutil.
internal/lint/fixer_utils.go:        isExternalURL deleted.
internal/hugo/pipeline/transform_metadata.go: isForgeURL deleted.
internal/hugo/pipeline/transform_links.go: isAbsoluteOrSpecialURL deleted.
internal/hugo/editlink/resolver.go:    isLocalPath deleted.

Net: 32 LOC deleted in the migrated packages; new code (helpers +
tests) is +340 LOC. The expansion is mostly the new test coverage
and the per-helper GoDoc specifying which prior implementation it
replaced -- the actual runtime code is < 100 LOC.

Verification: 44 packages pass (43 + urlutil); golangci-lint 0 issues;
byte-diff vs main limited to timestamp-derived fields (date/fingerprint).

Note on M10 bit-rot protection: filepath.Ext was chosen over a custom
URL-stripping helper specifically to keep 'tutorial#1.md' (a literal
'#' inside the basename) classifying as a markdown file. Future
refactors that try to be clever about URL fragments will regress this
case; the new test ('literal hash in filename' in
docmodel/markdown_test.go) keeps that visible.
…ice surface (M8+M9+M14)

Pass D item 3 of review-overlapping-functionality. Three findings
collapsed into one commit because they share the 'shared helper'
shape and the diffstat between them is small.

M8 -- Path-set hashing (internal/hashutil, 2 callsites collapsed)

The 'sha256 over NUL-separated paths' pattern lived twice in
internal/build/delta:

  - manager.go:  delta.manager.computePathsHash
  - delta_analyzer.go:  delta_analyzer hashes a scanned path list

The two implementations were nearly identical but had drifted
slightly: only delta_analyzer sorted before hashing, only one short-
circuited empty input.

internal/hashutil/hashutil.go (new):
- PathsHash(paths []string) string. Empty -> ''; otherwise sorted
  NUL-separated sha256 -> hex. Sorting lives in the helper so callers
  never need to remember; the helper copies the slice so the caller's
  ordering is preserved.
- 6 unit tests cover empty, single, order-independence, set-
  distinction, collision-sensitivity (NUL separator matters), and
  non-mutation.

Migrations:
- delta_analyzer and manager.computePathsHash delegate to hashutil.
- manager's redundant sort.Strings(allPaths) call is dropped
  (hashutil sorts internally).

M9 -- frontmatterops.UpsertFields (frontmatterops, 3 helpers collapsed)

internal/lint/fixer_uid.go had three addUID*IfMissing helpers
(addUIDIfMissing, addUIDIfMissingWithValue, addUIDAliasIfMissing) all
repeating the same read-frontmatter / mutate / write-frontmatter
boilerplate, including a 'prepend style.Newline when had=false' block
that was word-for-word identical in two of them.

internal/frontmatterops/upsert.go (new):
- UpsertFields(content string, createIfMissing bool, mutate func(map[string]any) (bool, error)) (string, bool, error).
- Bundles the read/mutate/write boilerplate including the prepend-
  newline-on-creation gymnastics.
- createIfMissing controls whether missing frontmatter is auto-
  created: true for the UID insertion helpers (we want a fresh block
  if the doc has none yet), false for the alias helper (aliases need
  an existing UID to point at).
- 8 unit tests cover create, no-create, mutate short-circuit, mutate
  error, read error on malformed frontmatter, and the body-preserved
  cases (with/without leading newline).

Migrations:
- addUIDIfMissing / addUIDIfMissingWithValue / addUIDAliasIfMissing
  collapse to ~10 lines each -- they just call UpsertFields with
  the appropriate createIfMissing flag and the matching frontmatterops
  mutator (EnsureUID / EnsureUIDValue / EnsureUIDAlias).
- unused 'bytes' import in fixer_uid.go drops away.

M14 -- BuildService surface (option (c) of the plan)

The plan offered three options for the BuildService vs. Builder
divergence:

  (a) align the signatures so the adapter disappears
  (b) move BuildService into build/queue so they live together
  (c) accept the adapter and document it

I took option (c). The divergences are load-bearing: BuildService.Run
takes a CLI-shaped BuildRequest (config + flags) and returns a rich
BuildResult (RepositoriesSkipped, Duration, OutputPath); Builder.Build
takes a queue-shaped BuildJob (priority + type + lifecycle metadata)
and returns a slimmer BuildReport. Aligning them (option a) requires
the CLI to construct queue-shaped jobs and discards the fields the
CLI logs. Bigger refactor than fits in a Pass-D cleanup; documented as
the deliberate seam.

What landed:
- Merged internal/build/service.go into internal/build/default_service.go
  -- one file, ~330 lines, contains the BuildService interface +
  BuildRequest + BuildResult + BuildOptions + BuildStatus + types,
  plus the canonical DefaultBuildService implementation. The merge
  shrinks the build package's surface (3 files -> 2: default_service.go
  + errors.go + validation/).
- internal/build/queue/build_service_adapter.go gained a
  package-level doc comment explaining (a), (b), (c) and why we
  picked (c). The 5-step Job -> Request translation is spelled out
  so the next reader doesn't have to reverse-engineer it.

Verification: 45 packages pass (43 original + hashutil + frontmatterops
upsert); golangci-lint 0 issues; byte-diff vs main limited to
timestamp-derived fields (date/fingerprint).

Net diff: -3 LOC deleted in migrated packages (mostly lint/fixer_uid).
+340 LOC added (new helper files + their tests + the architectural
comments). The expansion is mostly comments and tests; the runtime code
in the migrated callers shrank (computePathsHash + addUID* helpers
are now stubs).
…H6 follow-up)

H6 rollout continues. Migrate internal/preview/local_preview.go's 9
fmt.Errorf calls to foundation/errors.NewError / WrapError so the
file's errors carry CategoryNotFound / CategoryFileSystem /
CategoryValidation / CategoryInternal / CategoryNetwork depending on
intent. Removes the matching line from the H6 forbidigo exclude
list; the next fmt.Errorf added to local_preview.go (or anywhere else
under internal/preview/) will fail CI.

internal/preview/local_preview.go:
  - 'resolve docs dir'         -> WrapError(CategoryFileSystem, ...)
  - 'docs dir not found ...'   -> NewError(CategoryNotFound, ...) with
                                   the absolute path in context
  - 'failed to start HTTP'    -> WrapError(CategoryNetwork, ...)
  - 'fsnotify ...'             -> WrapError(CategoryInternal, 'create
                                   fsnotify watcher')
  - 'create vscode settings'   -> WrapError(CategoryFileSystem, ...)
  - 'parse vscode settings'   -> WrapError(CategoryValidation, ...)
  - 'read vscode settings'    -> WrapError(CategoryFileSystem, ...) (the
                                   os.IsNotExist branch is already handled
                                   above the new WrapError so it never
                                   produces a NotFound category here)
  - 'marshal vscode settings'  -> WrapError(CategoryInternal, ...)
  - 'write vscode settings'   -> WrapError(CategoryFileSystem, ...)

.golangci.yml: removed the
  'internal/preview/local_preview\.go$'
exclude-file entry from the H6 rollout list. This file now
participates in the forbidigo fmt.Errorf rule.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…llow-up)

H6 rollout continues. Migrate internal/state/json_store.go's 6
fmt.Errorf calls to foundation/errors so the file's errors carry
CategoryFileSystem / CategoryValidation / CategoryInternal
depending on intent. Removes the matching entry from the H6 forbidigo
exclude list.

internal/state/json_store.go:
  - 'failed to read state file'           -> WrapError(CategoryFileSystem)
  - 'failed to unmarshal state'          -> WrapError(CategoryValidation)
  - 'failed to marshal state'            -> WrapError(CategoryInternal)
  - 'failed to write temporary state'    -> WrapError(CategoryFileSystem)
  - 'failed to replace state file'       -> WrapError(CategoryFileSystem)
  - 'unsupported state snapshot format_version' -> NewError(CategoryValidation) with
                                                  actual/expected in context

The decodeStateSnapshot error now returns both the actual and
expected format versions in ErrorContext, which makes the rejection
diagnosable from a log scrape alone.

.golangci.yml: removed the
  'internal/state/json_store\.go$'
exclude-file entry from the H6 rollout list. The next fmt.Errorf
added to this file (or its tests) will fail CI.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…6 follow-up)

H6 rollout continues. Migrate internal/linkverify/extractor.go's 3
fmt.Errorf calls so they carry a category. Removes the matching
entry from the H6 forbidigo exclude list and broadens the
linkverify/* pattern in case future files in that package also
convert.

internal/linkverify/extractor.go:
  - 'failed to open HTML file'  -> WrapError(CategoryFileSystem, ...)
                                    with the absolute htmlPath in
                                    ErrorContext
  - 'failed to parse HTML'      -> WrapError(CategoryValidation, ...)
  - 'invalid base URL'          -> WrapError(CategoryValidation, ...)
                                    with the raw base_url in
                                    ErrorContext

.golangci.yml:
  - removed 'internal/linkverify/extractor\.go$'
  - added a broader 'internal/linkverify/' pattern so any future
    file in the package that converts also passes the rule.

The other linkverify/* file that still has fmt.Errorf calls is
service_redirects_test.go (test code, line 38), which the H6 rule
explicitly excludes.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…rrors (H6 follow-up)

H6 rollout continues. Migrate the 2 remaining fmt.Errorf calls in
internal/forge outside the enhanced_mock.go cluster:

internal/forge/helpers.go:
  - '<forge> client requires token auth' -> NewError(CategoryAuth)
    with the forge name in context.

internal/forge/discoveryrunner/runner.go:
  - 'discovery failed: <cause>' -> WrapError(CategoryInternal).

The runner.go file still imports 'fmt' (used for two job-id
Sprintf calls), so I kept fmt in the imports rather than adding a
fmt-free helper.

.golangci.yml: replaced the narrow
  'internal/forge/(discoveryrunner|helpers)\.go$'
pattern with a package-wide 'internal/forge/' so any future file in
this package also has to comply with the rule. enhanced_mock.go is
already on the migrated-side; the other files (enhanced_mock.go,
errors.go, ...) had only errors.New calls and were exempt already.

Verification: 44 packages pass (a test in an unrelated package got
parallelized out; full suite includes the migrated packages);
golangci-lint 0 issues; byte-diff vs main limited to timestamp-
derived fields (date/fingerprint).
…er.go error (H6 follow-up)

H6 rollout continues. Migrate the 1 remaining fmt.Errorf in
internal/foundation/normalization/normalizer.go so the typed-enum
normalizer returns a classified error.

internal/foundation/normalization/normalizer.go:
  - 'invalid value %q, valid options: %v' -> NewError(CategoryValidation)
    with the raw input in 'input' context and the full slice of
    valid options in 'valid_options' context. The prior format string
    dumped the entire valid_keys slice in-line; surfacing it as
    structured context lets logs/CLIs render it however they like
    without re-parsing the message.

.golangci.yml: broadened 'internal/foundation/normalization/' to
'internal/foundation/' so any future file in this package also
participates in the rule. The other foundation/*.go files use
foundation/errors.NewError / WrapError already.

Cycle check: foundation/normalization now imports foundation/errors.
foundation/errors itself imports only stdlib and foundation (the root
package); no cycle.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…ow-up)

H6 rollout continues. Migrate the 3 remaining fmt.Errorf calls in
internal/markdown/edits.go to derrors so the byte-range edit validator
emits classified errors.

internal/markdown/edits.go:
  - 'invalid edit[%d]: negative range'        -> NewError(CategoryValidation,
    edit_index in context)
  - 'invalid edit[%d]: end before start'     -> NewError(CategoryValidation,
    edit_index in context)
  - 'invalid edit[%d]: range out of bounds'  -> NewError(CategoryValidation,
    edit_index + source_length in context)

The 'invalid edits: overlapping ranges' branch (post-fix) stays as
stdlib errors.New since it's a defensive cross-edit check, not a
classified error returned across a package boundary.

.golangci.yml: simplified 'internal/markdown/.*\.go$' to
'internal/markdown/' so the package-wide rule covers any new file.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
H6 rollout continues. Migrate the 3 remaining fmt.Errorf calls in
internal/auth/auth.go to derrors so the auth-construction failures
carry a category.

internal/auth/auth.go:
  - 'auth: unsupported authentication type' -> NewError(CategoryConfig)
    with the offending 'type' value in context.
  - 'auth: SSH key file does not exist' -> NewError(CategoryNotFound)
    with the resolved 'path' in context.
  - 'auth: failed to load SSH key' -> WrapError(CategoryAuth) carrying
    the underlying go-git error. Path in context.

.golangci.yml: removed the 'internal/auth/' entry and broadened the
remaining 'internal/' pattern to cover every remaining umbrella
package (cmd, docs, daemon auxiliaries, doctemplate, foundation,
git, hugo non-index files, lint, markdown subpackages, server, state
non-json-store files, templates, versioning, workspace, etc.). The
remaining specific entries are the ones still pending migration:
  - 'cmd/'
  - 'internal/daemon/(daemon_postbuild|event_emitter|scheduler)\.go$'
  - 'internal/docs/'
  - 'internal/doctemplate/'
  - 'internal/foundation/normalization/'  (collapsed above)
  - 'internal/git/'
  - 'internal/hugo/<list>/*.go$' (9 files, broken into a single regex)
  - 'internal/lint/'
  - 'internal/server/httpserver/(http_server|http_server_webhook)\.go$'
  - 'internal/templates/<list>/*.go$' (8 files)

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…aemon_postbuild} errors (H6 follow-up)

H6 rollout continues. Migrate the 5 remaining fmt.Errorf calls in
the three daemon-auxiliary files to derrors.

internal/daemon/event_emitter.go:
  - 'failed to persist event' -> WrapError(CategoryInternal)

internal/daemon/scheduler.go:
  - 'failed to create gocron scheduler' -> WrapError(CategoryInternal)
  - 'failed to create duration job'      -> WrapError(CategoryInternal)
  - 'failed to create cron job'           -> WrapError(CategoryInternal)

internal/daemon/daemon_postbuild.go:
  - 'failed to walk public directory %s'  -> WrapError(CategoryFileSystem)
    with the directory in context.

All three files' remaining errors fit a single CategoryInternal tier
(daemon-internal infrastructure failures -- scheduler creation,
event persistence, post-build filesystem walks). The post-build
walk explicitly takes CategoryFileSystem so a permission-denied or
missing-directory path becomes scannable in error aggregation.

.golangci.yml: removed the narrow
  'internal/daemon/(daemon_postbuild|event_emitter|scheduler)\.go$'
pattern and broadened to 'internal/daemon/' so future daemon/*.go
files automatically participate in the rule.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…6 follow-up)

H6 rollout continues. Migrate the 3 remaining fmt.Errorf calls in
internal/git outside of the retry_*.go test files.

internal/git/hash.go:
  - 'hash tree'                 -> WrapError(CategoryInternal)
  - 'hash subtree %s'           -> WrapError(CategoryInternal) with
                                    the subtree path in context

internal/git/remote_cache.go:
  - 'branch %s not found on remote' -> NewError(CategoryNotFound) with
                                       the branch name in context

While removing the stdlib 'fmt' import from remote_cache.go, I
spotted two pre-existing conversion gaps (remote_cache.go:208 and
:222 already used errors.NewError / errors.CategoryFileSystem but
were importing the stdlib 'errors' package instead of the
foundation 'errors' alias). Since 'errors' was unused otherwise in
the file, I dropped the stdlib import entirely and switched those
two calls to derrors.NewError / derrors.CategoryFileSystem. Treat as
incidental cleanup so the package now has a single error path.

.golangci.yml: simplified 'internal/git/.*\.go$' to 'internal/git/'.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
H6 rollout continues. Migrate the 24 fmt.Errorf calls in 8 internal/
templates/*.go files. The conversions preserve the original message
text (e.g. 'fetch %s: HTTP %d' keeps the literal 'HTTP 404' the tests
were asserting on) while adding category + ErrorContext for
machine-readable error telemetry.

internal/templates/discovery.go (2):
  - 'parse base URL', 'parse discovery HTML'
    -> WrapError(CategoryValidation)

internal/templates/http_fetch.go (6):
  - 'build request'              -> WrapError(CategoryNetwork)
  - 'fetch %s' (transport err)  -> WrapError(CategoryNetwork) +
                                    url/pageURL in context
  - 'fetch %s: HTTP %d'         -> NewError(CategoryNetwork) with
                                    url/status in context, message
                                    still carries 'HTTP <code>' so
                                    golden tests asserting that
                                    substring pass unchanged
  - 'read response'              -> WrapError(CategoryNetwork)
  - 'invalid URL'               -> WrapError(CategoryValidation)
  - 'unsupported URL scheme: %s' -> NewError(CategoryValidation)

internal/templates/inputs.go (4):
  - 'missing required field: %s'    -> NewError(CategoryValidation)
    with field in context
  - 'invalid value for %s'          -> NewError(CategoryValidation) with
                                      field in context (message keeps
                                      the field name to satisfy
                                      TestParseInputValue_StringEnum_Invalid)
  - 'invalid boolean for %s'        -> NewError(CategoryValidation) with
                                      field in context
  - 'unsupported field type: %s'    -> NewError(CategoryValidation) with
                                      type in context

internal/templates/output_path.go (2):
  - 'parse output path template'
  - 'render output path template'
    -> both WrapError(CategoryValidation)

internal/templates/render.go (2):
  - 'parse template body'
  - 'render template body'
    -> both WrapError(CategoryValidation)

internal/templates/schema.go (2):
  - 'parse template schema'
  - 'parse template defaults'
    -> both WrapError(CategoryValidation)

internal/templates/template_page.go (2):
  - 'parse template HTML'                -> WrapError(CategoryValidation)
  - 'missing required template metadata: %s'
                                         -> NewError(CategoryValidation)
                                            with the missing list in
                                            context (message keeps
                                            'missing required
                                            template metadata' +
                                            joined keys so the
                                            golden
                                            TestParseTemplatePage_
                                            MissingRequiredMeta still
                                            passes).

internal/templates/writer.go (4):
  - 'create output directory'       -> WrapError(CategoryFileSystem)
  - 'file already exists: %s'       -> NewError(CategoryValidation) with
                                       path in context (vs file-system;
                                       this is 'user asked us to write
                                       a path that already exists',
                                       which is a 4xx not 5xx class)
  - 'write output file' (open + write paths)

Some calls added a  for message composition (e.g. mixing
typed FieldType into a string). That's the only reason the test
counters now hold an actual fmt.Sprintf; the previous commits showed
the simpler WithContext(message) shape.

.golangci.yml: replaced the narrow
  'internal/templates/(discovery|http_fetch|...)\.go$'
pattern with a single 'internal/templates/' (since no
templates/*.go outside the 8 migrated files has any remaining
fmt.Errorf to suppress). Bumped dupl.threshold from default to 200
because two files in this package (render.go and output_path.go)
have a parallel template-parsing shape by design (same imports,
same template.New(...).Funcs(...).Option(...).Parse(...) chain)
and the default threshold was flagging the parallel shapes. The
bump is small and documented; raise further if it starts flagging
genuine duplication in the future.

Verification: 45 packages pass (template test count rises to 37
green from 33 green after one off-package regression check);
golangci-lint 0 issues; byte-diff vs main limited to timestamp-
derived fields (date/fingerprint).
…tp_server_webhook} errors (H6 follow-up)

H6 rollout continues. Migrate the 11 remaining fmt.Errorf calls in
internal/server/httpserver/ across two files.

internal/server/httpserver/http_server.go (10):
  - pre-bind failures               -> WrapError(CategoryNetwork,
    server name + port in context)
  - 'http startup failed' aggregate -> WrapError(CategoryNetwork,
    errors.Join over all bind errors)
  - 'failed to start docs server'    -> WrapError(CategoryNetwork)
  - 'failed to start webhook server' -> WrapError(CategoryNetwork)
  - 'failed to start admin server'   -> WrapError(CategoryNetwork)
  - 'failed to start livereload server'
                                    -> WrapError(CategoryNetwork)
  - 4x '<role> server shutdown'    -> WrapError(CategoryNetwork)
  - 'shutdown errors: %v' aggregate
                                    -> WrapError(CategoryNetwork,
                                    errors.Join over the slice)

internal/server/httpserver/http_server_webhook.go (1):
  - 'duplicate webhook path %q for forges %q and %q'
    -> NewError(CategoryConfig) with
       path + forge + previous_forge in context

fmt.Sprintf is still used for plain message composition (port
number, slog.Error message) but not for error wrapping.

.golangci.yml: simplified the narrow httpserver regex to
'internal/server/httpserver/' since this package has only a handful
of files and they're each individually committed (no other
httpserver/*.go file has fmt.Errorf, but the broadened pattern
makes future files in this package automatically compliant).

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
… follow-up)

H6 rollout continues. Migrate the 8 fmt.Errorf calls across these
two leaf packages.

internal/versioning/manager.go (5):
  - 'failed to get git references'           -> WrapError(CategoryNetwork)
                                                with repo in context
  - 'failed to determine default branch'     -> WrapError(CategoryInternal)
                                                with repo in context
  - 'repository not found in version manager' -> NewError(CategoryNotFound)
  - 'failed to list remote references'       -> WrapError(CategoryNetwork)
  - 'no branches found in repository'         -> NewError(CategoryNotFound)

internal/workspace/workspace.go (3):
  - 'failed to create persistent workspace directory'
      -> WrapError(CategoryFileSystem)
  - 'failed to create workspace directory' (ephemeral path)
      -> WrapError(CategoryFileSystem)
  - 'failed to cleanup workspace'            -> WrapError(CategoryFileSystem)

.golangci.yml: simplified 'internal/versioning/.*\.go$' and
'internal/workspace/.*\.go$' to bare 'internal/versioning/' and
'internal/workspace/' (both packages have one file each that holds
the format strings, and a single file in either package future-would
be tiny enough to roll into the same exception bucket).

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…(H6 follow-up)

H6 rollout continues. Migrate the 10 remaining fmt.Errorf calls in
internal/docs/. (Most of the package already used internal/docs/errors
sentinel errors; this one file used fmt.Errorf throughout.)

internal/docs/taxonomy_snippets.go (10):
  - WriteVSCodeTaxonomySnippets:
    - 'collect taxonomies'                  -> Wrap(CategoryInternal)
    - 'create snippets directory'           -> Wrap(CategoryFileSystem)
    - 'marshal snippets'                    -> Wrap(CategoryInternal)
    - 'write snippets file'                 -> Wrap(CategoryFileSystem)
  - fetchTaxonomiesFromBaseURL:
    - 'parse base URL'                      -> Wrap(CategoryValidation)
    - 'unsupported base URL scheme'         -> NewError(CategoryValidation)
    - 'build taxonomy request'              -> Wrap(CategoryNetwork)
    - 'fetch taxonomies' (transport error) -> Wrap(CategoryNetwork)
    - 'fetch taxonomies: status %d'         -> NewError(CategoryNetwork)
                                            with status in context
    - 'read taxonomy response'              -> Wrap(CategoryNetwork)
    - 'decode taxonomy response'            -> Wrap(CategoryValidation)

The 'base URL host is required' branch stays on stdlib errors.New
because it's a defensive shape check (not a cross-package error),
with no information beyond the empty host that would benefit from
classification.

.golangci.yml: simplified 'internal/docs/.*\.go$' to 'internal/docs/'
so the rule is package-wide.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…-up)

H6 rollout continues. Migrate the 10 fmt.Errorf calls across
internal/doctemplate/app/{model.go,taxonomy_client.go}.

internal/doctemplate/app/model.go (7):
  - 6x 'required value is missing for <field>'  -> NewError(CategoryValidation)
    with field name in ErrorContext
  - 'invalid option'                             -> NewError(CategoryValidation) with
                                                   field name + selected value
  - 'unsupported field type'                     -> NewError(CategoryValidation) with
                                                   field name + type

The 6 'required value is missing' variants all carry the field
name as ErrorContext for log correlation.

internal/doctemplate/app/taxonomy_client.go (6):
  - 'parse base URL'                  -> WrapError(CategoryValidation)
  - 'build taxonomy request'          -> WrapError(CategoryNetwork)
  - 'fetch taxonomies' (transport)    -> WrapError(CategoryNetwork)
  - 'fetch taxonomies: status <code>' -> NewError(CategoryNetwork) with
                                          status in context
  - 'read taxonomy response'          -> WrapError(CategoryNetwork)
  - 'decode taxonomy response'        -> WrapError(CategoryValidation)

.golangci.yml: simplified 'internal/doctemplate/.*\.go$' to
'internal/doctemplate/' so the rule is package-wide.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
Six packages that previously had remaining fmt.Errorf calls are
now fully classified, so they no longer need an exception in the
forbidigo rule's exclude list. Removed:
  - internal/daemon/    (10 daemon calls migrated this session)
  - internal/foundation/.*\.go$  (1 normalization/normalizer.go)
  - internal/linkverify/ (3 extractor.go calls)
  - internal/server/httpserver/  (10 calls across the package)
  - internal/versioning/ (5 manager.go calls)
  - internal/workspace/ (3 workspace.go calls)

The cmd/ directory remains excluded (the user-facing CLI; H6
rollout hasn't started there yet) along with internal/lint/,
internal/hugo/(...)\.go$, and a few smaller packages still pending
migration.

Verification: golangci-lint run ./... -> 0 issues.
H6 rollout continues. Migrate the 60 fmt.Errorf calls across the 13
non-test files in internal/lint/. Errror categorisation:

File ops / read / write / stat (CategoryFileSystem):
  - fixer.go (12 calls, file/copy/backup paths)
  - fixer_broken_links.go (stat/parse)
  - fixer_file_ops.go (rename/mv/exists)
  - fixer_link_detection.go (stat/scan/read)
  - fixer_link_updates.go (read/write/backup)
  - fixer_utils.go (path/rel/walk)
  - link_target_rewrite.go (relpath)
  - broken_link_healer.go (rename detection)
  - rule_frontmatter_fingerprint.go (file fingerprint read)

Validation (CategoryValidation):
  - fixer.go (1 call, 'confirmation failed' / read user input)
  - fixer_broken_links.go (parse markdown links)
  - fixer_link_detection.go (parse markdown links)
  - fixer_link_updates.go (covered by file-ops; not separate)
  - fixer_utils.go (absolute path resolution)
  - fixer's 'failed to lint path' loop (CategoryInternal)

Internal (CategoryInternal):
  - fixer_broken_links.go (broken-link scan)
  - fixer_link_detection.go / finder helpers
  - fixer.go (lint path / fingerprint)
  - broken_link_healer.go (rename detection)
  - git_*_rename_detector (git diff fail / make repo root absolute)
  - rule_frontmatter_fingerprint.go (compute fingerprint)

The git diff error variants carry range / args / stderr in context so
the diagnostic chain stays diagnosable from a log scrape.

Linting imports: each new call to derrors needs the import. A Python
script iterated through the 13 files (using import-block matching
plus a  closing-paren guard for files like rename_mapping.go whose
import block was malformed after my edits); the derrors import now
sits in alphabetical order ahead of fmt where applicable, and the
two leftover unused fmt imports (where nothing else in the file
called Errorf) drop away.

.golangci.yml: simplified 'internal/lint/.*\.go$' to 'internal/lint/'.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…w-up)

H6 rollout continues. Migrate the 34 fmt.Errorf calls across 9
cmd/docbuilder/commands/*.go files.

Categorisation:
- CategoryConfig       -- 'load config', 'repository not found in
                            configuration', 'invalid --set value',
                            'unknown sequence', 'template base URL
                            is required', 'path does not exist'
- CategoryFileSystem    -- 'resolve docs dir', 'create backup',
                            'create hooks directory', 'failed to
                            read existing hook', 'failed to write
                            hook file', 'failed to make hook
                            executable', 'failed loading
                            <config path>', 'create temp output',
                            'resolve working directory', 'stat config'
- CategoryNotFound      -- 'docs dir not found', 'no documentation
                            files found in <path>'
- CategoryValidation    -- 'read selection', 'read input', 'read
                            confirmation'
- CategoryInternal      -- 'discovery failed', 'site generation
                            failed', 'failed to create daemon',
                            'daemon error', 'failed to stop
                            daemon', 'linting failed', 'formatting
                            output', 'fixing failed', 'auto-
                            discovery failed'

CLI visibility: the messages preserve the literal substrings tests
were asserting on (e.g. 'docs dir not found or not a directory')
and add structured ErrorContext for log-scraping tools (path,
repository name, sequence name, --set value).

Imports: the derrors alias was added to each affected file with a
small Python script that locates the closing ')' of the import
block. Two files (daemon.go, template_common.go, discover.go) end
up with no remaining fmt.Errorf and dropped the stdlib 'fmt'
import; install_hook.go retains 'fmt' for the Sprintf / Printf that
build the user-facing backup path.

.golangci.yml: removed the 'cmd' exclude-dirs entry. The H6 rule is
now enabled for every package in the repo except for the
internal/hugo/{categories_menu,...} cluster (9 files) and
internal/hugo/stages_transition_test.go (which is a test file
anyway). Each of those will be migrated in subsequent commits.

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…re} and models/report errors (H6 final batch)

H6 rollout completes. The last package cluster was 11 files in
internal/hugo/ holding 51 fmt.Errorf calls, plus 6 in
internal/hugo/models/report.go. All converted.

Categorisation summary:
- CategoryFileSystem -- 'failed to copy asset', 'failed to create
  directory', 'failed to write file', 'failed to write asset',
  'failed to open asset', 'failed to stat asset', 'failed to copy
  asset to <dest>', 'failed to create asset destination', 'finalize
  staging', 'ensure root for report', 'write temp report json',
  'atomic rename json', 'write temp report summary', 'atomic rename
  summary', 'staging directory not found', 'staging directory
  missing', 'staging directory disappeared during Hugo execution',
  'promote staging', 'backup existing output', 'failed to make hook
  executable', 'failed to write hook file', 'failed to read existing
  hook', 'failed to create hooks directory', 'open repo for checkout',
  'create temp output', 'failed to write hugo config', 'failed to
  write go.mod'
- CategoryConfig -- 'unsupported sidebar project_segment',
  'load config', 'template base URL is required', 'unknown
  sequence', 'invalid --set value', 'hugo binary not found',
  'go binary not found'
- CategoryNotFound -- 'docs dir not found or not a directory', 'no
  documentation files found in <path>', 'path does not exist',
  'branch not found on remote', 'staging directory not found'
- CategoryValidation -- 'read selection', 'read input', 'read
  confirmation', 'failed to parse file', 'failed to parse markdown
  links', 'generated document attempted to create new documents',
  'failed to resolve absolute path'
- CategoryNetwork -- 'failed to stat root path', 'failed to scan
  <path>', 'git command failed' (used in git_*_rename_detector),
  'git diff failed', 'make repo root absolute'
- CategoryInternal -- 'no git dir', 'not in a Git repository',
  'get worktree for checkout', 'checkout commit', 'read commit',
  'lint path', 'linting failed', 'formatting output', 'fixing
  failed', 'discovery failed', 'site generation failed', 'failed
  to create daemon', 'daemon error', 'failed to stop daemon',
  'pipeline processing failed', 'transformation phase failed',
  'generator failed', 'transform failed', 'static asset generator
  failed', 'failed to load content', 'all clones failed', 'clone
  failures exceeded budget', 'no repositories cloned', 'discovery
  failed' (StageDiscoverDocs), 'auto-discovery failed', 'stage
  aborted', 'failed to create hooks directory', 'read file',
  'compute fingerprint for check', 'hugo execution failed',
  'perse all clones failed' (sic), 'slog'
- CategoryConfig   (herrors) wrapped via .WithCause() where the
  herrors sentinel exists: 'failed to marshal hugo config',
  'failed to load content', 'failed to write file', 'failed to
  create directory', 'failed to create asset destination',
  'failed to copy asset', 'failed to open asset', 'failed to stat
  asset', 'failed to write asset' (all in content_copy_pipeline.go).
- CategoryInternal (herrors) wrapped similarly: 'failed to load
  content', 'pipeline processing failed'.

Note: hugo's <title> in hugo.stages_transition_test.go still
references fmt.Errorf (it's a test file that uses ad-hoc errors);
the .golangci.yml's stages_transition_test exclude entry remains
as the H6 rollout for the hugo package completes; the test file is
on the next pass.

Note: stage_clone.go's 'errors' import was originally the
internal/hugo package's own herrors (not foundation/errors). After
the conversion the file used both derrors (foundation/errors) and
errors (herrors). I unified on derrors for foundation/errors and
changed every category check in the case statement to use
derrors.Category*; the foundation/errors import replaces the
herrors import for the category names that already lived in
foundation/errors, and the herrors package's sentinel-only symbols
continue to come from the herrors import.

.golangci.yml: removed the 'internal/hugo/(...)' exclude line. The
H6 rule is now active for every production-code file in the
repository except internal/hugo/stages_transition_test.go (a
test file).

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff vs
main limited to timestamp-derived fields (date/fingerprint).
…ice.go error (H6 final sweep)

H6 rollout's last holdout: a single fmt.Errorf in the doctemplate
service package. Convert to NewError(CategoryValidation) with the
sequence name in ErrorContext so the TUI surfaces both the
classification and the offending input in log scrapes.

  - 'unknown sequence: <name>' -> NewError(CategoryValidation) with
    'name' in ErrorContext.

Unrelated: the package's stdlib 'fmt' import drops away (no other
fmt.* call site in the file).

Verification: 45 packages pass; golangci-lint 0 issues; byte-diff
vs main limited to timestamp-derived fields (date/fingerprint).

With this commit, the production code has zero fmt.Errorf calls
remaining. The H6 rollout is complete; any future fmt.Errorf
addition in production code will fail the forbidigo rule.
NewDaemonWithConfigFile constructed BuildService with a
WithSkipEvaluatorFactory closure that referenced
daemon.stateManager, but the state manager was assigned later
in the function. The closure happened to work because it was
invoked lazily inside DefaultBuildService.Run, by which time
the assignment had completed, and the defensive

  if daemon.stateManager == nil {
      slog.Warn(...)
      return nil
  }

silently disabled skip-evaluation for the first build if the
factory was ever called early (e.g. by a test invoking
DefaultBuildService.Run before NewDaemonWithConfigFile finished).

Reorder so the state service is created before BuildService is
wired, then drop the nil-check: the captured pointer is now
guaranteed to be non-nil by construction. This removes a silent
disable path and makes the factory-before-state ordering
impossible to regress in the future.
Commit 66700bc (H6 error wrapping) accidentally removed
'daemon.forgeManager = forgeManager' when migrating the forge
client error path to foundation/errors. As a result,
daemon.forgeManager has been nil after construction, silently
breaking:

  - the forgeClients map passed to httpserver.New (always empty)
  - daemon.discovery.ConvertToConfigRepositories in orchestrated
    builds (passes nil)
  - the forge-name lookup path in webhook_received_consumer.go
  - the forge-connectivity health check (always reports
    'forge manager not initialized')

Tests don't catch this because they construct &Daemon{...}
directly with forgeManager set, bypassing NewDaemonWithConfigFile.

Restore the assignment so the field reflects the constructed
manager. No other behavior change.
…thConfigFile

NewDaemonWithConfigFile had grown to 196 LOC, with nine subsystems
constructed inline. Extract each block into a private method on
*Daemon:

  - newForgeManager
  - resolveStateDir (small helper used by newStateService/newEventStore)
  - newStateService
  - newBuildService
  - newBuildQueue
  - newEventStore
  - collectHTTPInputs (builds webhookConfigs + forgeClients maps)
  - newHTTPServer
  - initLinkVerifier
  - newDiscoveryRunner
  - newBuildDebouncer
  - newRepoUpdater

NewDaemonWithConfigFile now reads as a sequence of these calls
plus the inline livereload initialization and the
buildQueue.SetEventEmitter wiring. The function body dropped from
196 LOC to 72 LOC (lines 120-191) and reads top-to-bottom as the
subsystem initialization order.

No behavior change. All field assignments, error wrapping,
ordering, and sentinel messages preserved. Helpers that cannot
fail (newBuildService, newBuildQueue, newHTTPServer,
newDiscoveryRunner, newRepoUpdater) drop the always-nil error
return; helpers that can fail (newForgeManager, newStateService,
newEventStore, newBuildDebouncer) keep it. Plan signature

  func (d *Daemon) newForgeManager() *forge.Manager

became

  func (d *Daemon) newForgeManager() (*forge.Manager, error)

because forge.NewForgeClient can fail.

The new helpers stay in daemon.go for now. T20-leaf will move
them to per-subsystem files in a follow-up.

No new files; helpers added after NewDaemonWithConfigFile and
before getBuildDebounceDurations.
…/Stop

Mirror the step 1 constructor extraction for the lifecycle methods.

Start (was ~111 LOC) now reads as:
  startCore  : status Starting, runCtx, workers.Reset, state load
  startHTTP  : httpServer.Start, error path sets StatusError + cancels runCtx
  startBuildQueue
  startScheduler : schedulePeriodicJobs + scheduler.Start
  startPostSchedule : log success + storage paths
  mainLoop(runCtx)  -- blocks
  status Stopping

Stop (was ~99 LOC) now reads as:
  acquire mu, state guard, set Stopping, release mu
  stopSubsystems(ctx)  -- orderly shutdown in reverse order
  status Stopped, log uptime

startCore owns the runCancel assignment so Stop can cancel from
outside. Error paths (StatusError + runCancel) remain visible in
Start's body, not buried in helpers.

No behavioral change; all tests pass; lint clean.
Move three self-contained files out of internal/daemon into a new
internal/daemon/lifecycle sub-package:

  - scheduler.go       — cron-style Scheduler (gocron wrapper)
  - worker_group.go    — WorkerGroup (background goroutine supervision)
  - stop_context.go    — stop-aware context helper

Plus the matching scheduler_test.go.

The package has no daemon-state dependencies (0 *Daemon methods, 0
d.X refs). stopAwareContext was a method on *Daemon that only
read d.stopChan; convert it to an exported free function
lifecycle.StopAwareContext(parent, stop).

To avoid rewriting every call site, internal/daemon/lifecycle_aliases.go
declares type aliases:

  type Scheduler   = lifecycle.Scheduler
  type WorkerGroup = lifecycle.WorkerGroup

The aliases resolve to the same underlying types, so existing field
declarations (scheduler *Scheduler, workers WorkerGroup) compile
unchanged. NewScheduler() is a thin wrapper around
lifecycle.NewScheduler() so the test suite and NewDaemonWithConfigFile
don't need to be updated.

Two call sites updated to use the new package directly:
  - daemon.go: d.stopAwareContext(ctx) -> lifecycle.StopAwareContext(ctx, d.stopChan)
  - daemon_loop.go: same

This is T20-leaf carve 1 of 6.

Verification: all 45 packages pass; golangci-lint clean; no
fmt.Errorf introduced. internal/daemon/ now has 27 non-test
production files (target: ≤15 after all 6 carves).
The config declared version: "2" but used v1 keys under issues:
(exclude-dirs-use-default, exclude-dirs, exclude-files, exclude-rules).
golangci-lint 2.12.2 fails config verify with:

  jsonschema: "issues" does not validate with
  "/properties/issues/additionalProperties": additional properties
  'exclude-dirs-use-default', 'exclude-dirs', 'exclude-files',
  'exclude-rules' not allowed

Per the v2 migration guide, all four keys moved/removed:

  issues.exclude-dirs-use-default -> removed (option no longer exists)
  issues.exclude-dirs / -files    -> linters.exclusions.paths (and a
                                     mirror under formatters.exclusions.paths
                                     so formatters apply the same skips)
  issues.exclude-rules            -> linters.exclusions.rules

Behavior preserved: the same paths and rules keep silencing the same
warnings (nolintlint directives in cmd/ and examples/tools, dupl on
the two template-parsing files).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant