Skip to content
Open
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
2 changes: 2 additions & 0 deletions internal/assets/commands/text/errors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ err.disclosure.orphan-theme-file:
short: 'progressive disclosure: a theme file has no matching gist in the root'
err.disclosure.staging-unparsable:
short: 'progressive disclosure: the staging zone could not be parsed into discrete entries'
err.disclosure.malformed-entry-header:
short: 'progressive disclosure: line %d: %q is not a full entry header; give it a full timestamp ("## [YYYY-MM-DD-HHMMSS] Title") so it is not folded into the entry above it'
err.disclosure.apply-not-entry-kind:
short: 'progressive disclosure: the mover digests only LEARNINGS.md and DECISIONS.md; conventions digestion is a later milestone'
err.disclosure.empty-assignment:
Expand Down
4 changes: 4 additions & 0 deletions internal/config/embed/text/disclosure.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const (
// DescKeyErrDisclosureStagingUnparsable: the staging zone could not
// be parsed into discrete entries.
DescKeyErrDisclosureStagingUnparsable = "err.disclosure.staging-unparsable"
// DescKeyErrDisclosureMalformedEntryHeader: format for a "## [" line
// that is not a full timestamped entry header; takes the 1-based line
// number and the heading.
DescKeyErrDisclosureMalformedEntryHeader = "err.disclosure.malformed-entry-header"
)

// DescKeys for the milestone-3 mover (the digesting pass that writes
Expand Down
32 changes: 32 additions & 0 deletions internal/disclosure/regions.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

cfgDisc "github.com/ActiveMemory/ctx/internal/config/disclosure"
cfgFile "github.com/ActiveMemory/ctx/internal/config/file"
"github.com/ActiveMemory/ctx/internal/config/regex"
"github.com/ActiveMemory/ctx/internal/config/token"
)

Expand Down Expand Up @@ -202,6 +203,37 @@ func entryBelowThemes(themesRaw string, k Kind) bool {
return false
}

// malformedEntryHeading finds the first line that opens like an entry
// heading ("## [") but is not a full "## [YYYY-MM-DD-HHMMSS] Title"
// entry header, e.g. a date-only "## [2026-09-26] Title". The block
// parser does not start an entry at such a line, so it and its body
// would be silently folded into the entry above. Lines inside an HTML
// comment are skipped: DECISIONS.md's format guide ships a
// "## [YYYY-MM-DD] Decision Title" example.
//
// Parameters:
// - content: the text to scan
//
// Returns:
// - line: 1-based line number of the offending line, or 0 if none
// - heading: that line, whitespace-trimmed, or "" if none
func malformedEntryHeading(content string) (line int, heading string) {
spans := htmlCommentSpans(content)
for i, n := 0, 1; i < len(content); n++ {
text, next := lineAt(content, i)
if regex.EntryHeading.MatchString(text) &&
!regex.EntryHeader.MatchString(text) &&
!insideAnySpan(i, spans) {
return n, strings.TrimSpace(text)
}
if next == -1 {
break
}
i = next
}
return 0, ""
}

// htmlCommentSpans returns the [start, end) byte ranges of every HTML
// comment (token.HTMLCommentOpen … token.HTMLCommentClose) in content; an
// unterminated open runs to EOF. The structural heading scans use it to
Expand Down
20 changes: 18 additions & 2 deletions internal/disclosure/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import (
// - no entry heading below "## Themes" (ErrEntryBelowThemes): entries
// must stay in the staging zone above it. The heading that opens an
// entry is per-kind ([EntryPrefix]).
// - for the timestamped kinds, every "## [" line outside an HTML
// comment is a full "## [YYYY-MM-DD-HHMMSS] Title" header
// (MalformedEntryHeaderError, which matches ErrStagingUnparsable):
// the block parser would fold any other one into the entry above.
// - a non-empty staging zone must enumerate into discrete entries
// (ErrStagingUnparsable), for every kind.
// - for conventions, no two staged sections share a title
Expand All @@ -37,16 +41,28 @@ import (
// - r: a parsed root (from Parse)
//
// Returns:
// - error: one of the disclosure sentinels, or nil when well-formed
// - error: one of the disclosure sentinels, a
// *MalformedEntryHeaderError, or nil when well-formed
func Validate(r Root) error {
if len(headingLineOffsets(r.Reconstruct(), cfgDisc.HeadingThemes)) > 1 {
content := r.Reconstruct()
if len(headingLineOffsets(content, cfgDisc.HeadingThemes)) > 1 {
return errDisc.ErrMultipleThemes
}

if r.HasThemes && entryBelowThemes(r.ThemesRaw, r.Kind) {
return errDisc.ErrEntryBelowThemes
}

// Conventions carry no timestamp, so "## [" is ordinary title text
// there. Scanning the whole root keeps line numbers file-relative;
// only staging can hold a hit: staging starts at the first "## ["
// line, and entryBelowThemes has cleared the themes region.
if r.Kind != KindConvention {
if line, h := malformedEntryHeading(content); line > 0 {
return errDisc.MalformedEntryHeader(line, h)
}
}

blocks := stagedBlocks(r.Staging, r.Kind)
if strings.TrimSpace(r.Staging) != "" && len(blocks) == 0 {
return errDisc.ErrStagingUnparsable
Expand Down
78 changes: 78 additions & 0 deletions internal/disclosure/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ package disclosure_test

import (
"errors"
"strings"
"testing"

readTpl "github.com/ActiveMemory/ctx/internal/assets/read/template"
cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx"
"github.com/ActiveMemory/ctx/internal/disclosure"
errDisc "github.com/ActiveMemory/ctx/internal/err/disclosure"
)
Expand Down Expand Up @@ -44,6 +47,18 @@ const (
"## [2026-07-15-120000] Same Title\n\nfirst.\n\n" +
"## [2026-07-16-090000] Same Title\n\nsecond.\n\n" +
"## Themes\n\n- a — g → [a](learnings/a.md)\n"

// A date-only header (legacy or hand-written) is not an entry start
// for the block parser, so without the guard it is silently folded
// into the entry above it — and moved into that entry's theme. The
// offending heading is on line 9.
dateOnlyHeader = "# Learnings\n\n<!-- guide -->\n\n" +
"## [2026-07-15-120000] a staged entry\n\n**Context**: x.\n\n" +
"## [2026-09-26] Legacy\n\n**Context**: y.\n"

// Conventions carry no timestamp: "## [" is ordinary title text.
conventionBracketTitle = "# Conventions\n\n<!-- guide -->\n\n" +
"## [Draft] Naming\n\nprose.\n"
)

// T06: the Validate precondition returns the named sentinel for each
Expand Down Expand Up @@ -84,6 +99,15 @@ func TestValidate(t *testing.T) {
"convention un-migrated", conventionUnmigrated,
disclosure.KindConvention, nil,
},
// A malformed header is the precise form of unparsable staging.
{
"date-only header after an entry", dateOnlyHeader,
disclosure.KindLearning, errDisc.ErrStagingUnparsable,
},
{
"convention bracketed title", conventionBracketTitle,
disclosure.KindConvention, nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -97,3 +121,57 @@ func TestValidate(t *testing.T) {
})
}
}

// DECISIONS.md's shipped template carries a "## [YYYY-MM-DD] Decision
// Title" example inside its <!-- DECISION FORMATS --> comment. That is
// documentation, not an entry: the malformed-header guard must skip it.
func TestValidate_DecisionTemplateExampleIgnored(t *testing.T) {
tpl, tplErr := readTpl.Template(cfgCtx.Decision)
if tplErr != nil {
t.Fatalf("read template: %v", tplErr)
}
if !strings.Contains(string(tpl), "## [YYYY-MM-DD] Decision Title") {
t.Fatal("template lost its commented date-only example; " +
"this test no longer guards anything")
}
content := string(tpl) +
"\n## [2026-09-26-120000] A real decision\n\n**Status**: Accepted\n"

err := disclosure.Validate(disclosure.Parse(content, disclosure.KindDecision))
if err != nil {
t.Errorf("Validate = %v, want nil (commented example is not an entry)", err)
}
}

// The malformed-header refusal names the 1-based line and the heading,
// on LF and CRLF files alike (the carriage return is not reported), and
// stays matchable as ErrStagingUnparsable.
func TestValidate_MalformedEntryHeader(t *testing.T) {
const wantLine, wantHeading = 9, "## [2026-09-26] Legacy"
for name, content := range map[string]string{
"LF": dateOnlyHeader,
"CRLF": strings.ReplaceAll(dateOnlyHeader, "\n", "\r\n"),
} {
t.Run(name, func(t *testing.T) {
err := disclosure.Validate(
disclosure.Parse(content, disclosure.KindLearning),
)
mErr, ok := errors.AsType[*errDisc.MalformedEntryHeaderError](err)
if !ok {
t.Fatalf("Validate = %v, want *MalformedEntryHeaderError", err)
}
if mErr.Line != wantLine || mErr.Heading != wantHeading {
t.Errorf("got line %d heading %q, want line %d heading %q",
mErr.Line, mErr.Heading, wantLine, wantHeading)
}
if !errors.Is(err, errDisc.ErrStagingUnparsable) {
t.Errorf("errors.Is(%v, ErrStagingUnparsable) = false", err)
}
msg := err.Error()
if !strings.Contains(msg, "line 9") ||
!strings.Contains(msg, wantHeading) {
t.Errorf("message %q must name line 9 and the heading", msg)
}
})
}
}
9 changes: 7 additions & 2 deletions internal/drift/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,13 +373,18 @@ func checkMissingPackages(ctx *entity.Context, report *Report) {
// extractFirstComment extracts the first HTML comment block from content.
// Returns an empty string if no comment found.
//
// CRLF is normalized to LF first, so comparing two extracts is
// line-ending-insensitive: a core.autocrlf checkout writes CRLF files,
// and a binary built from one embeds CRLF templates.
//
// Parameters:
// - content: Raw file content to scan for an HTML comment
//
// Returns:
// - string: Trimmed comment including delimiters,
// or empty string if none found
// - string: Trimmed comment including delimiters, with LF line
// endings, or empty string if none found
func extractFirstComment(content string) string {
content = strings.ReplaceAll(content, token.NewlineCRLF, token.NewlineLF)
start := strings.Index(content, marker.CommentOpen)
if start == -1 {
return ""
Expand Down
46 changes: 46 additions & 0 deletions internal/drift/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import (
"strings"
"testing"

readTpl "github.com/ActiveMemory/ctx/internal/assets/read/template"
cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx"
cfgDrift "github.com/ActiveMemory/ctx/internal/config/drift"
"github.com/ActiveMemory/ctx/internal/config/token"
"github.com/ActiveMemory/ctx/internal/context/load"
"github.com/ActiveMemory/ctx/internal/entity"
"github.com/ActiveMemory/ctx/internal/io"
Expand Down Expand Up @@ -628,3 +631,46 @@ func TestIsTemplateFile(t *testing.T) {
})
}
}

// A header that matches its template must pass whatever the line
// endings: a core.autocrlf=true checkout writes CRLF context files, and
// a binary built from one embeds CRLF templates. Both endings are
// checked so the test bites on an LF and on a CRLF checkout alike.
func TestCheckTemplateHeaders_LineEndings(t *testing.T) {
tpl, tplErr := readTpl.Template(cfgCtx.Learning)
if tplErr != nil {
t.Fatalf("read template: %v", tplErr)
}
lf := strings.ReplaceAll(string(tpl), token.NewlineCRLF, token.NewlineLF)
crlf := strings.ReplaceAll(lf, token.NewlineLF, token.NewlineCRLF)

tests := []struct {
name string
content string
wantStale bool
}{
{"LF file", lf, false},
{"CRLF file", crlf, false},
{"edited header", strings.Replace(lf, "UPDATE WHEN", "EDITED", 1), true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &entity.Context{Files: []entity.FileInfo{
{Name: cfgCtx.Learning, Content: []byte(tt.content)},
}}
report := &Report{}

checkTemplateHeaders(ctx, report)

stale := false
for _, w := range report.Warnings {
stale = stale || w.Type == cfgDrift.IssueStaleHeader
}
if stale != tt.wantStale {
t.Errorf("stale header = %v, want %v (warnings: %+v)",
stale, tt.wantStale, report.Warnings)
}
})
}
}
52 changes: 52 additions & 0 deletions internal/err/disclosure/disclosure.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,58 @@ const (
)
)

// MalformedEntryHeaderError is returned by Validate when a LEARNINGS or
// DECISIONS root has a line that opens like an entry ("## [") but is not
// a full "## [YYYY-MM-DD-HHMMSS] Title" header — e.g. a date-only
// "## [2026-09-26] Title". The block parser would not start an entry
// there and would silently fold it into the entry above, so the pass
// refuses instead. It refines [ErrStagingUnparsable]: callers using
// errors.Is on that sentinel still match, and callers using
// `errors.AsType[*MalformedEntryHeaderError]` recover the location.
type MalformedEntryHeaderError struct {
// Line is the 1-based line number of the offending heading.
Line int
// Heading is the offending line, whitespace-trimmed.
Heading string
}

// Error implements the error interface for MalformedEntryHeaderError.
//
// Returns:
// - string: message naming the line, the heading, and the fix
func (e *MalformedEntryHeaderError) Error() string {
return fmt.Sprintf(
desc.Text(text.DescKeyErrDisclosureMalformedEntryHeader),
e.Line, e.Heading,
)
}

// Is reports whether target is [ErrStagingUnparsable], of which a
// malformed entry header is the located form.
//
// Parameters:
// - target: error to compare against
//
// Returns:
// - bool: true when target is [ErrStagingUnparsable]
func (e *MalformedEntryHeaderError) Is(target error) bool {
return target == ErrStagingUnparsable
}

// MalformedEntryHeader returns a MalformedEntryHeaderError.
//
// Parameters:
// - line: 1-based line number of the offending heading
// - heading: the offending line, whitespace-trimmed
//
// Returns:
// - *MalformedEntryHeaderError: typed error for errors.AsType matching
func MalformedEntryHeader(
line int, heading string,
) *MalformedEntryHeaderError {
return &MalformedEntryHeaderError{Line: line, Heading: heading}
}

// NotAKnowledgeFile wraps [ErrNotAKnowledgeFile] with the offending path
// and the expected filenames.
//
Expand Down
3 changes: 3 additions & 0 deletions internal/err/disclosure/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
//
// - **Structure** ([ErrMultipleThemes], [ErrEntryBelowThemes],
// [ErrStagingUnparsable]): the precondition refused a malformed root.
// [MalformedEntryHeaderError] is the located form of
// [ErrStagingUnparsable]: it names the line and heading of a "## ["
// line that is not a full timestamped entry header.
// - **Cross-file** ([ErrOrphanThemeFile], [ErrMissingThemeFile],
// [ErrDuplicateEntry], [ErrBrokenThemeLink]): the root ↔ theme-file
// link graph or the one-place-per-entry invariant is broken.
Expand Down
4 changes: 3 additions & 1 deletion internal/heading/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ func ParseEntryBlocks(content string) []EntryBlock {
entry: entity.IndexEntry{
Timestamp: matches[1] + token.Dash + matches[2],
Date: matches[1],
Title: matches[3],
// TrimSpace drops the carriage return a CRLF
// line keeps after the LF split.
Title: strings.TrimSpace(matches[3]),
},
})
}
Expand Down
15 changes: 15 additions & 0 deletions internal/heading/entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ func TestParseEntryBlocks_Single(t *testing.T) {
}
}

// A CRLF file (core.autocrlf checkout on Windows) splits on "\n" and
// leaves "\r" on every line; the title must not keep it.
func TestParseEntryBlocks_CRLFTitle(t *testing.T) {
content := "# Decisions\r\n\r\n" +
"## [2026-01-15-120000] Use YAML for config\r\n\r\n" +
"**Context:** Need a config format\r\n"
blocks := ParseEntryBlocks(content)
if len(blocks) != 1 {
t.Fatalf("ParseEntryBlocks() = %d blocks, want 1", len(blocks))
}
if got := blocks[0].Entry.Title; got != "Use YAML for config" {
t.Errorf("Title = %q, want %q", got, "Use YAML for config")
}
}

func TestParseEntryBlocks_Multiple(t *testing.T) {
content := `# Decisions

Expand Down
Loading
Loading