Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .context/LEARNINGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ DO NOT UPDATE FOR:
<!-- INDEX:START -->
| Date | Learning |
|----|--------|
| 2026-05-30 | Capture golden fixtures from the live legacy code path before deleting it |
| 2026-05-30 | tpl package is magic-string-audit-exempt but its call sites are not |
| 2026-05-30 | New exported types must live in types.go or TestTypeFileConvention fails |
| 2026-05-28 | ctx kb: single topic-enumeration site; life-stage count is consumer-side |
| 2026-05-28 | Swap occupancy is not memory pressure — use the kernel's derivative |
Expand Down Expand Up @@ -167,6 +169,26 @@ DO NOT UPDATE FOR:

---

## [2026-05-30-212109] Capture golden fixtures from the live legacy code path before deleting it

**Context**: Behavior-preserving refactors of LoopScript composition and the recall <details>/<table> assembly had fragile whitespace where hand-transcribing the expected output risked silent drift from the original bytes.

**Lesson**: A throwaway test that runs the current (pre-refactor) code and writes its output to testdata/*.golden gives a regression baseline derived from real behavior, not a re-transcription; delete the throwaway, then have the committed test assert the new code is byte-identical to the fixtures.

**Application**: Use for any behavior-preserving refactor of formatting/rendering code: capture goldens from the legacy path before removing it, then assert byte-equality after.

---

## [2026-05-30-212102] tpl package is magic-string-audit-exempt but its call sites are not

**Context**: Migrating tpl_*.go format-string consts to text/template handles; a Render("name",...) sketch and map[string]any{"Key":...} render data would both trip audit/magic_strings_test.go (TestNoMagicStrings).

**Lesson**: internal/assets/tpl is in the magic-strings audit exemptStringPackages, so template-path literals are sanctioned there; but render data passed from non-exempt caller packages must be a typed struct (e.g. tpl.ObsidianData{...}), never a map[string]any with literal keys, which trips the audit at the call site.

**Application**: When adding a template, define a typed data struct in tpl/types.go and pass it at the call site; never pass map literals from caller packages.

---

## [2026-05-30-114436] New exported types must live in types.go or TestTypeFileConvention fails

**Context**: Defined Payload and Provenance structs alongside the Load/OverlayFlags funcs in a new payload.go; make test failed in internal/audit on TestTypeFileConvention with '2 NEW type definitions outside types.go'.
Expand Down
7 changes: 6 additions & 1 deletion .context/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,14 @@ These have priority because other knowledge ingestion projects depend on them.

Important things that agent (or human) yeeted to the future.

- [ ] Migrate Sprintf-based templates (tpl_*.go) to Go text/template or embedded
- [x] Migrate Sprintf-based templates (tpl_*.go) to Go text/template or embedded
template files — ObsidianReadme, LoopScript, and other multi-line format
strings that can't move to YAML #added:2026-03-18-163629
Spec: specs/tpl-text-template-migration.md
DONE 2026-05-30 (branch refactor/tpl-text-template-migration). Tier-1 blocks
+ static Zensical + LoopScript + Tier-2 recall HTML (metaTable/details)
migrated to embedded templates behind handles; Tier-3 single-line format
strings, pure joins, and the RecallListRow meta-format kept as fmt.Sprintf.
- [ ] P0.8.5: Enable webhook notifications in worktrees. Currently `ctx notify`
silently fails because `.context.key` is gitignored and absent in
worktrees. For autonomous runs with opaque worktree agents, notifications
Expand Down
82 changes: 82 additions & 0 deletions internal/assets/tpl/load.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package tpl

import (
"embed"
"text/template"
)

// templatesFS holds the multi-line template bodies migrated out of the
// fmt.Sprintf format-string constants. The embed is local to tpl: tpl
// is a leaf package, and reaching into the parent assets.FS would
// couple it there and invite the import cycle the embed_test split
// fought.
//
//go:embed templates/*.tmpl templates/*.toml
var templatesFS embed.FS

// parseErrs accumulates init-time template parse failures. It is empty
// in any correct build; TestTemplatesParse asserts so, turning a
// malformed embedded template into a CI failure rather than a runtime
// panic (the project forbids panic, and there is no template.Must
// precedent here).
var parseErrs []error

// init parses every embedded template into its exported handle.
func init() {
ObsidianReadme = parseTemplate("templates/obsidian-readme.md.tmpl")
JournalSiteReadme = parseTemplate("templates/journal-site-readme.md.tmpl")
TriggerScript = parseTemplate("templates/trigger-script.sh.tmpl")
Learning = parseTemplate("templates/learning.md.tmpl")
Decision = parseTemplate("templates/decision.md.tmpl")
LoopScript = parseTemplate("templates/loop-script.sh.tmpl")
MetaTable = parseTemplate("templates/meta-table.html.tmpl")
Details = parseTemplate("templates/details.html.tmpl")
ZensicalProject = loadStatic("templates/zensical-project.toml")
ZensicalTheme = loadStatic("templates/zensical-theme.toml")
}

// parseTemplate reads and parses one embedded template. On failure it
// records the cause in parseErrs and returns the non-nil (empty)
// template, so Render never receives a nil handle: the failure path
// stays panic-free while TestTemplatesParse flags it.
//
// Parameters:
// - path: embedded template path under templatesFS
//
// Returns:
// - *template.Template: the parsed template (never nil)
func parseTemplate(path string) *template.Template {
t := template.New(path)
body, readErr := templatesFS.ReadFile(path)
if readErr != nil {
parseErrs = append(parseErrs, readErr)
return t
}
if _, parseErr := t.Parse(string(body)); parseErr != nil {
parseErrs = append(parseErrs, parseErr)
}
return t
}

// loadStatic reads an embedded static (non-interpolated) template body
// as a string, recording any read failure in parseErrs.
//
// Parameters:
// - path: embedded file path under templatesFS
//
// Returns:
// - string: the file contents, or "" on read error
func loadStatic(path string) string {
body, readErr := templatesFS.ReadFile(path)
if readErr != nil {
parseErrs = append(parseErrs, readErr)
return ""
}
return string(body)
}
92 changes: 92 additions & 0 deletions internal/assets/tpl/render.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// / ctx: https://ctx.ist
// ,'`./ do you remember?
// `.,'\
// \ Copyright 2026-present Context contributors.
// SPDX-License-Identifier: Apache-2.0

package tpl

import (
"bytes"
"text/template"

"github.com/ActiveMemory/ctx/internal/config/warn"
logWarn "github.com/ActiveMemory/ctx/internal/log/warn"
)

// ObsidianReadme renders the README for a generated Obsidian vault.
// Data: [ObsidianData]. Call sites render via [Render], passing this
// handle — never a name literal — so non-exempt caller packages stay
// clean under audit/magic_strings.
var ObsidianReadme *template.Template

// JournalSiteReadme renders the README for the journal-site directory.
// Data: [JournalSiteData].
var JournalSiteReadme *template.Template

// TriggerScript renders the scaffold bash script for `ctx trigger add`.
// Data: [TriggerData].
var TriggerScript *template.Template

// Learning renders a learning entry section. Data: [LearningData].
var Learning *template.Template

// Decision renders a decision (ADR) entry section. Data: [DecisionData].
var Decision *template.Template

// LoopScript renders the Ralph-loop bash script. Data: [LoopData].
var LoopScript *template.Template

// MetaTable renders a collapsible session-metadata HTML table.
// Data: [MetaTableData].
var MetaTable *template.Template

// Details renders a collapsible <details> block wrapping a body.
// Data: [DetailsData].
var Details *template.Template

// Render executes a parsed template handle against data.
//
// The handle is always non-nil for a registered template (a parse
// failure still yields a usable empty template, recorded for
// TestTemplatesParse), so this never panics on a nil handle. An
// execution error (e.g. a renamed data field) is returned, not
// panicked; golden tests gate template correctness.
//
// Parameters:
// - t: a parsed template handle (e.g. [ObsidianReadme])
// - data: the template's typed data struct
//
// Returns:
// - string: the rendered output
// - error: non-nil on an execution failure
func Render(t *template.Template, data any) (string, error) {
var buf bytes.Buffer
if execErr := t.Execute(&buf, data); execErr != nil {
return "", execErr
}
return buf.String(), nil
}

// RenderOr renders like [Render] but suits best-effort string builders
// whose callers do not return errors (the recall formatter, the Import
// counter). On the render error it logs a warning and returns fallback
// rather than propagating: the template is parse-gated by
// TestTemplatesParse and fed typed data, so Execute cannot fail in a
// correct build — the fallback is unreachable defense.
//
// Parameters:
// - t: a parsed template handle
// - data: the template's typed data struct
// - fallback: returned on the unreachable error path
//
// Returns:
// - string: the rendered output, or fallback on error
func RenderOr(t *template.Template, data any, fallback string) string {
out, err := Render(t, data)
if err != nil {
logWarn.Warn(warn.TemplateRender, err)
return fallback
}
return out
}
Loading
Loading