PR #69 (issue #51) adds structured
logging across the CLI but mixes two patterns: an injected App.Logger threaded
through pkgtemplate.Get, hooks.Load, etc., and direct slog.Default() calls in
pkg/registry and the hook fallback path. The branch sets slog.SetDefault(app.Logger)
at the end of PersistentPreRunE (pkg/cmd/root.go:69), so both pipes exist
side-by-side — the worst of both worlds.
A code review also flagged ten concrete problems (swallowed CheckRemote errors,
mixed attribute keys, no logging in pkg/util/git, weak registry.Upgrade() coverage,
inconsistent LoadStatus failure logging, prompt-source semantics, missing tests,
remaining , _ := audit, docs not matching emitted attributes).
Issue #67 (rename pkg/ →
internal/) removes the only meaningful argument for keeping a logger-injection
API — there are no external consumers to consider. The CLI is a single-process
binary; log/slog is built around the default-logger model.
This plan does the architectural cleanup and the ten review fixes in a single
amend on the current branch (51-improve-observability-via-structured-logging).
After the migration, no production function takes a *slog.Logger parameter and no
struct stores one. Every package emits logs via the package-level slog.Debug/Info/...
functions, which route through the global default. The CLI entrypoint configures the
default logger once at startup and swaps it when --debug --output=json flips the
handler. Tests silence logs with a TestMain per package that calls
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))).
The LevelVar and HandlerFactory machinery on App is retained because runtime
level changes from --debug still need to mutate the live handler.
pkg/cmd/app.go (pkg/cmd/app.go:49-99)
- Remove the
Logger *slog.Loggerfield fromApp. NewApp()no longer stores a logger pointer; it builds the default handler and callsslog.SetDefault(slog.New(handler))directly. TheLevelVarstays onAppsoWithDebugand thePersistentPreRunEdebug toggle still work.WithHandlerrebuilds the handler from the factory and callsslog.SetDefault(...)instead of assigning toa.Logger.- Drop the
templateGethelper — callpkgtemplate.Get(root, cfg)directly.
pkg/cmd/root.go (pkg/cmd/root.go:33-66)
- In
PersistentPreRunE, when--debug --output=jsonswitches the handler, callslog.SetDefault(slog.New(slog.NewJSONHandler(cmd.ErrOrStderr(), ...)))instead of mutatingapp.Logger. Drop the trailing standaloneslog.SetDefault(it becomes redundant).
pkg/template/template.go (pkg/template/template.go:65-145, 165-237)
Get(templateRoot, cfg)— drop thelogger *slog.Loggerparameter.- Remove the
loggerfield fromTemplatestruct. - Replace every
logger.X(...)/t.logger.X(...)withslog.X(...).
pkg/template/context.go (pkg/template/context.go:58-102)
ApplyComputed(ctx, defs, funcMap, delims)— drop the logger parameter.- Use
slog.Debug(...)directly.
pkg/template/functions.go (the sprout WithLogger call site)
- Pass
slog.Default()tosprout.WithLogger()(sprout's API expects a*slog.Logger).
pkg/hooks/hooks.go (pkg/hooks/hooks.go:18-115)
Load(templateRoot, projectConfig, envPrefix)— drop the logger parameter.- Remove the
logger *slog.Loggerfield fromHooks. - Remove the
if logger == nil { logger = slog.Default() }fallback inRun. - All
logger.Debug(...)→slog.Debug(...).
pkg/cmd/template_use.go (pkg/cmd/template_use.go:71-141, 200-340)
- Remove
a.Loggerarguments frompkgtemplate.Get,hooks.Load,pkgtemplate.ApplyComputed,promptContext,runPromptPass. promptContextandrunPromptPasslose theirlogger *slog.Loggerparameters; useslog.Debug(...)directly.
pkg/cmd/template_list.go, template_update.go, template_download.go,
use.go
app.Logger.X(...)→slog.X(...).
pkg/registry/registry.go (pkg/registry/registry.go:40-104)
- Already uses
slog.Default().X(...). Replace with package-levelslog.X(...)for consistency with the rest of the codebase.
pkg/util/git/git.go — instrument the three core functions:
Clone(pkg/util/git/git.go:35):slog.Debug("git clone start", "repo", url, "dest", dir, "branch", opts.Branch)and a success log on exit.Describe(pkg/util/git/git.go:102):slog.Debug("git describe", "dest", dir, "commit", result.Commit, "version", result.Version); debug log on error.CheckRemoteContext(pkg/util/git/git.go:290): debug log on entry withrepo,branch,dest; debug log on exit withup_to_date,latest_version,error_kind.
Once instrumented, remove the duplicated logs at the call sites:
pkg/cmd/template_download.go:53-63(clone + describe)pkg/cmd/use.go(clone)pkg/cmd/template_list.go:78-86(checking remote status+remote status result)pkg/cmd/template_update.go:50-59(same)pkg/registry/registry.go—CheckRemote/Clone/Describecalls no longer need per-callsite duplication.
CheckRemoteContext returns (RemoteCheckResult, error) but the implementation
returns nil for the error in every path — failures are encoded in
result.ErrorKind and exposed via result.Err(). Drop the error from the
signature so result, _ := ... patterns disappear and the contract matches reality.
pkg/util/git/git.go:290, 327: change signatures to return onlyRemoteCheckResult.- Update all callers:
pkg/cmd/app.go:59(checkRemoteFn typedef),template_list.go:78,template_update.go:50,registry.go:75.
pkg/registry/registry.go:65-104:
- Log entry:
slog.Debug("upgrade start", "template", name). - Log the resolved
targetRefafterCheckRemote:slog.Debug("upgrade target", "template", name, "repo", meta.Repository, "branch", meta.Branch, "target_ref", targetRef, "latest_version", result.LatestVersion). - Log the status file removal:
if err := os.Remove(...); err != nil && !os.IsNotExist(err) { slog.Debug("removing stale status file failed", "template", name, "error", err) }. - Log completion:
slog.Debug("upgrade complete", "template", name, "target_ref", targetRef). - Clone/Describe logs come from the
pkg/util/gitinstrumentation in §2 — no duplication needed here.
Standardize on this final key set:
| Key | Meaning |
|---|---|
template |
Registered template name (e.g. "minimal") — primary user identifier |
path |
Template-relative source path of a file (e.g. "src/foo.go") |
dest |
Absolute destination path on the filesystem |
repo |
Remote repository URL |
branch |
Git branch or tag ref |
commit |
Full git commit SHA |
version |
git-describe-style version string |
trigger |
Hook trigger name (pre-use, post-use) |
key |
Context variable name |
source |
How a context value was provided (default/prompt/values_file/arg_flag/computed) |
action |
File decision (render/verbatim/skip) |
error |
Underlying error (formatted as %v) |
Replace mismatched uses:
pkg/template/template.go:92, 117, 130, 163—"root", templateRoot→"template", templateRoot.pkg/cmd/template_list.go:48, 53, 77, 80-86, 94, 101—"name"→"template".pkg/cmd/template_update.go:43, 51, 53-58, 96—"name"→"template".pkg/registry/registry.go:48, 53, 73—"name"→"template".
Current behavior in template_use.go:
- Line 96 logs
source="values_file"for every key in the values file. - Line 108 logs
source="arg_flag"for--argkeys. - Line 119 logs
source="default"for non-provided keys (when--use-defaults). - Lines 254, 259 log
source="prompt"when the user is prompted. context.go:100logssource="computed"afterApplyComputed.
Problem: a key can pass through values_file and arg_flag (override), or be
prompted after a values_file entry was discarded. Today both logs fire.
Fix: defer the resolution log until after the final value is established for each key.
- Replace the per-step logs with a single "resolved sources" emission immediately
before
ApplyComputed. Build amap[string]string(key → final source) as values move through values_file → arg_flag → prompt → default, overwriting previous entries. Then emit oneslog.Debug("context key resolved", "key", k, "source", finalSource)per key in alphabetical order. ApplyComputedcontinues to logsource="computed"for its keys (those never overlap with user-input keys perextractComputed's conflict check).
Audit every LoadMetadata/LoadStatus caller and ensure every error path logs at
debug:
pkg/cmd/template_list.go:45, 50✓ already logspkg/cmd/template_update.go:43, 95✓ already logspkg/registry/registry.go:43, 50, 73✓ already logspkg/template/template.go:120(LoadMetadata insideGet) ✓ already logs- Verify no other callers exist via
Grep "LoadStatus\|LoadMetadata".
Also instrument the two currently-swallowed file mutations:
pkg/cmd/template_list.go:101:_ = pkgtemplate.SaveStatus(...)→ log on error.pkg/cmd/template_update.go:64: same.
After the targeted fixes above, run a sweep:
Grep -n ", _ :=" pkgandGrep -n "_ = " pkg(Go files).- For each hit, decide: (a) intentional discard, leave alone; (b) diagnostic value,
add
slog.Debug(...); (c) actual bug, propagate the error. - Document any intentional discards with a one-line comment where the reason is non-obvious.
TestMain pattern — add helpers_test.go files (or extend existing ones) in
every package whose tests trigger logging code paths. Each contains:
func TestMain(m *testing.M) {
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
os.Exit(m.Run())
}Packages: pkg/template, pkg/hooks, pkg/cmd, pkg/registry, pkg/util/git.
Remove discardLogger() from pkg/template/helpers_test.go and
pkg/hooks/hooks_test.go — no longer needed after the signature change. Update
all Load(...) / Get(...) callers in tests.
New tests:
pkg/template/metadata_test.go— verify malformed__metadata.jsonproduces a parse error fromLoadMetadata(already partly tested; ensure the error path is asserted).pkg/cmd/template_list_test.go— table-driven test for thetemplate listremote-check path: with a fakecheckRemoteFnreturning eachErrorKind, assert the row's status label and that logging attributes are emitted (capture via a per-testslog.SetDefaultswap withslog.NewTextHandler(&buf, ...)).pkg/cmd/root_test.go— assert--debug --output=jsonkeeps logs on stderr (JSON handler) while command data goes to stdout. Run atemplate listagainst a registry with one local template; parse stderr as NDJSON and stdout as JSON table; assert disjoint streams.pkg/cmd/template_use_test.go— verify the prompt-source dedup: pass--valuesplus--argfor the same key, assert the final log hassource="arg_flag"notsource="values_file".pkg/util/git— light coverage for the new git-layer log entries via a capture handler; assertrepo,dest,branchare present inCloneentry.
docs/content/docs/architecture/overview.md (the "Logging" section that PR #69
adds, around the end of the file):
- Replace the Flags description: the slog default is set in
NewApp()/PersistentPreRunE; remove the obsolete "stored inApp.Logger" sentence. - Update the Log points table to match the actually-emitted attributes.
Notably:
pkg/templateGetnow usestemplate(notroot); addpkg/util/gitrows forClone,Describe,CheckRemoteContext; addpkg/registryrow forUpgrade. - Replace the Consistent attribute keys table with the final set from §5.
- Note (under the example) that
slog.SetDefaultis called byNewApp()and re-set inPersistentPreRunEwhen--debug --output=jsonswaps the handler.
README.md — verify nothing user-facing changed. --debug and --output=json
flag semantics are unchanged; no update expected, but spot-check to confirm.
pkg/cmd/app.go
pkg/cmd/root.go
pkg/cmd/template_use.go
pkg/cmd/template_list.go
pkg/cmd/template_update.go
pkg/cmd/template_download.go
pkg/cmd/use.go
pkg/hooks/hooks.go
pkg/hooks/hooks_test.go
pkg/template/template.go
pkg/template/context.go
pkg/template/context_test.go
pkg/template/functions.go
pkg/template/metadata.go
pkg/template/helpers_test.go
pkg/registry/registry.go
pkg/util/git/git.go
docs/content/docs/architecture/overview.md
Plus new helpers_test.go / TestMain files in any package not already covered,
and new test files listed in §9.
Nothing new needs inventing; everything is rewiring existing functions:
slogpackage-levelDebug/Info/Warn/Error(Go stdlib) replaces every injected-logger call.slog.SetDefaultreplacesApp.Loggermutation.RemoteCheckResult.Err()(pkg/util/git/git.go:269) already exposes typed errors — keeps callers that want typed errors well-served after the signature change in §3.output.Writer(warnings/errors to humans) stays untouched — it serves a different purpose fromslog(user-facing UX vs. diagnostic stream).
- Compile & unit tests:
task test(per.github/instructions/executing-commands.md). - Static analysis:
task lintor whatever is configured under.github/workflows/(run via theci-green-checkskill at the end). - CLI smoke:
specs --debug template ls 2>&1 1>/dev/null | head -20— should show structured debug lines with stable attribute keys; noroot=should appear.specs --debug --output=json template ls 2>logs.ndjson 1>data.json— verifylogs.ndjsonparses line-by-line as JSON with the expected keys;data.jsonis the table data only.specs --debug template download <repo> testtpl 2>&1 | grep "git clone"— verify the git-layer log entry fires withrepo,dest,branch.specs --debug template use testtpl ./out --arg Name=foo --values vals.jsonwherevals.jsoncontains{"Name":"bar"}— verify only one log line forkey=Nameand it hassource="arg_flag".specs --debug template upgrade testtpl— verify the newupgrade *log points fire.
- Test coverage for log-sensitive paths: confirm the new tests in §9 all pass, including the capture-handler assertions.
- Docs check: grep
docs/content/docs/architecture/overview.mdfor any attribute key not in the §5 table; should be none.