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
6 changes: 5 additions & 1 deletion .claude/skills/rootline/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,11 @@ Do not apply results automatically. For `apply`, inspect the report first and tr

## Canonical Field Contract

Use only `string`, `list`, `enum`, `sequence`, `link`, `boolean`, and `integer`; do not coerce YAML values. A body section pairs a real type with `source: body.section["## Heading"]`. Frontmatter is an explicit override; an empty section is present, duplicate matching headings fail, and child omission inherits a stable source binding. `new` and `migrate --scaffold` add missing required sections in lexical heading order with a non-empty default or `<!-- TODO -->`. Validation error paths are governance-root-relative while symbolic sources remain symbolic. Ancestor-qualified selectors are deferred to #190.
Use only `string`, `list`, `enum`, `sequence`, `link`, `boolean`, and `integer`. Do not coerce YAML values. A body section pairs a real type with a source such as `body.section["## Heading"]`. Each selector component contains one to six `#` characters, one space, and the exact parsed heading text. The `#` characters encode only the heading level. They do not encode the original Markdown form. An ATX heading `## Notes ##` uses `body.section["## Notes"]`. The selector `body.section["## Notes ##"]` identifies literal parsed text `Notes ##`. Setext headings use the same form. A multiline Setext heading uses an escaped `\n` in the quoted text.

A qualified selector can use `body.section["## Parent"]["### Notes"]`. Its components must be contiguous. The selector matches a contiguous suffix of the heading path. More than one match is ambiguous. A frontmatter override takes precedence. An empty section is present. Child omission inherits a stable source binding.

Inference preserves the parsed level and text. It does not preserve the original ATX or Setext form. `new` and `migrate --scaffold` materialize a missing required simple selector with ATX or Setext syntax that preserves the level and text. Setext syntax is available only for levels 1 and 2. If neither form preserves the selector, Rootline fails before it writes the affected file. The commands use a non-empty default or `<!-- TODO -->`. They do not invent ancestor headings. A missing required qualified section also stops the command before it writes the affected file. Validation error paths are governance-root-relative while symbolic sources remain symbolic.

## Reference Files

Expand Down
6 changes: 5 additions & 1 deletion .claude/skills/rootline/ref-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ Use `--field summary` only after confirming the analyze JSON contains that path.

## Canonical schema transport

`init`, `analyze`, `schema apply`, and `migrate --split` preserve a section as a real type plus `source: body.section["## Heading"]`. Inference preserves exact headings, makes partial-frequency candidates optional, and fails logical-name collisions. `new` and `migrate --scaffold` materialize missing required sections in lexical heading order with a non-empty default or `<!-- TODO -->`; frontmatter overrides are never written as empty shadow keys.
`init`, `analyze`, `schema apply`, and `migrate --split` preserve a section as a real type plus a source such as `source: body.section["## Heading"]`. Each selector component contains one to six `#` characters, one space, and the exact parsed heading text. The `#` characters encode only the heading level. They do not encode the original Markdown form. An ATX heading `## Notes ##` uses `body.section["## Notes"]`. The selector `body.section["## Notes ##"]` identifies literal parsed text `Notes ##`. Setext headings use the same form. A multiline Setext heading uses an escaped `\n` in the quoted text.

A qualified selector can use `source: body.section["## Parent"]["### Notes"]`. It matches a contiguous suffix of the heading path. More than one matching path is ambiguous. Inference preserves the parsed heading level and text. It does not preserve the original ATX or Setext form. Rootline calculates the frequency of each exact heading family. Rootline discards each family that does not reach the threshold. Rootline can emit the shortest common selector. After the threshold filter, Rootline resolves selectors and checks logical-name collisions only among families that reach the threshold. If retained families collide, Rootline fails inference. Rootline does not invent logical names. Rootline makes a retained partial-frequency family optional.

`new` and `migrate --scaffold` materialize missing required simple selectors in lexical heading order. They use ATX or Setext syntax that preserves the level and text. Setext syntax is available only for levels 1 and 2. If neither form preserves the selector, Rootline fails before it writes the affected file. The commands use a non-empty default or `<!-- TODO -->`. They do not invent ancestor headings. A missing required qualified section also stops the command before it writes the affected file. Frontmatter overrides are never written as empty shadow keys.

## apply (Removed)

Expand Down
6 changes: 5 additions & 1 deletion .claude/skills/rootline/ref-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,11 @@ Use these instead of hand-rolling `WalkUp` + `entries[0]` indexing in new comman
- `source` — a logical body extraction directive when the field has one
- `defined_in` — the physical `.stem` that declares the field

A source-backed field uses a real type plus `source: body.section["## Heading"]`; frontmatter is an override. Child omission inherits the stable source binding. `new` and `migrate --scaffold` materialize missing required sections in lexical heading order using a non-empty default or `<!-- TODO -->`.
A source-backed field uses a real type plus a source such as `body.section["## Heading"]`. Each selector component contains one to six `#` characters, one space, and the exact parsed heading text. The `#` characters encode only the heading level. They do not encode the original Markdown form. An ATX heading `## Notes ##` uses `body.section["## Notes"]`. The selector `body.section["## Notes ##"]` identifies literal parsed text `Notes ##`. Setext headings use the same form. A multiline Setext heading uses an escaped `\n` in the quoted text.

A qualified selector can use `body.section["## Parent"]["### Notes"]`. Its components must be contiguous. The selector matches a contiguous suffix of the heading path. More than one match is ambiguous. Frontmatter is an override. Child omission inherits the stable source binding. Inference preserves the parsed level and text, not the original ATX or Setext form.

`new` and `migrate --scaffold` materialize missing required simple selectors in lexical heading order. They use ATX or Setext syntax that preserves the level and text. Setext syntax is available only for levels 1 and 2. If neither form preserves the selector, Rootline fails before it writes the affected file. The commands use a non-empty default or `<!-- TODO -->`. They do not invent ancestor headings. A missing required qualified section also stops the command before it writes the affected file.

Author required section-backed fields in `.stem` like this; `defined_in` appears only in command output, not in authored declarations:

Expand Down
9 changes: 6 additions & 3 deletions .claude/skills/rootline/ref-validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,11 @@ Link-check rules (emitted when the effective `.stem` sets `links.checks`): `link
Schema fields with `source:` directives (body-extracted fields) now participate in validation. The `required` and `enum` constraints apply to values extracted from the document body:

**Extraction directives**:
- `source: body.h1` — extracts the text of the first H1 heading (e.g., `# My Document` → `"My Document"`)
- `source: body.section["## Heading"]` — extracts content under the named section (e.g., `## Notes` with content below it)
- `source: body.h1` extracts the text of the first H1 heading, such as `"My Document"` from `# My Document`.
- `source: body.section["## Heading"]` identifies the final heading by exact level and text. It extracts the content under that heading.
- `source: body.section["## Parent"]["### Notes"]` matches a contiguous suffix of the hierarchical heading path. It extracts content from the matched section.

Only multiple paths that match the complete selector produce ambiguity. Rootline does not select the first or last matching path. A required qualified selector with no match fails validation.

**Precedence**: Frontmatter takes absolute precedence. If a field key exists in the record's YAML frontmatter, that value is used and body extraction is skipped.

Expand Down Expand Up @@ -153,7 +156,7 @@ In validation:

### Source and provenance

`body.section[...]` matches an exact heading level and text. Duplicate matching headings fail rather than choosing an occurrence; frontmatter remains an override. Path-like validation error sources are governance-root-relative, while symbolic sources stay symbolic.
A simple `body.section[...]` selector identifies the final heading by exact level and text. A qualified selector matches a contiguous suffix of the hierarchical heading path. Only multiple paths that match the complete selector produce ambiguity. Frontmatter remains an override. Path-like validation error sources are governance-root-relative, while symbolic sources stay symbolic.

### Reporting Format

Expand Down
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,13 @@ Schema fields with `source:` directives (e.g., `source: body.h1` or `source: bod

## Canonical Field Contract

Use exactly `string`, `list`, `enum`, `sequence`, `link`, `boolean`, and `integer`; validation does not coerce YAML representations. A body section is a source binding on a real type, such as `source: body.section["## Summary"]`. Frontmatter is an explicit override, duplicate matching headings fail, and an inherited source binding is stable: a child omission inherits it, while a change or removal conflicts.
Use exactly `string`, `list`, `enum`, `sequence`, `link`, `boolean`, and `integer`. Validation does not coerce YAML representations. A body section is a source binding on a real type. Each selector component contains one to six `#` characters, one space, and the exact parsed heading text. The `#` characters encode only the heading level. They do not encode the original Markdown form. An ATX heading `## Notes ##` uses `body.section["## Notes"]`. The selector `body.section["## Notes ##"]` identifies literal parsed text `Notes ##`. Setext headings use the same form. A multiline Setext heading uses an escaped `\n` in the quoted text.

`new` and `migrate --scaffold` append missing required sections in lexical heading order, using a non-empty default or `<!-- TODO -->`. `query`, `set`, `describe`, and `explain` resolve the same canonical binding; `set` writes frontmatter only. In public inspection output, logical `source` is separate from physical `defined_in`. Path-like validation errors are relative to the governance root; symbolic sources remain symbolic. Ancestor-qualified selectors are deferred to #190.
A qualified binding can use `source: body.section["## Parent"]["### Notes"]`. Its components must be contiguous. The selector matches a contiguous suffix of the heading path. More than one match is ambiguous. Frontmatter is an explicit override. A child omission inherits a stable source binding.

Inference preserves the parsed level and text. It does not preserve the original ATX or Setext form. `new` and `migrate --scaffold` materialize a missing required simple selector with ATX or Setext syntax that preserves the level and text. Setext syntax is available only for levels 1 and 2. If neither form preserves the selector, Rootline fails before it writes the affected file. The commands use a non-empty default or `<!-- TODO -->`. They do not invent ancestor headings. A missing required qualified section also stops the command before it writes the affected file.

`query`, `set`, `describe`, and `explain` resolve the same canonical binding. `set` writes frontmatter only. In public inspection output, logical `source` is separate from physical `defined_in`. Path-like validation errors are relative to the governance root. Symbolic sources remain symbolic.

## Module Path

Expand Down
112 changes: 112 additions & 0 deletions cmd/rootline/migrate_scaffold_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"testing"

"github.com/pablontiv/rootline/internal/extract"
"github.com/pablontiv/rootline/internal/migrate"
)

Expand Down Expand Up @@ -69,6 +70,57 @@ func TestMigrateScaffoldRequiredSectionSourceWarningsDoNotBlockWrite(t *testing.
}
}

func TestMigrateScaffoldMaterializesLosslessSetextHeading(t *testing.T) {
dir := newMigrateScaffoldSectionSourceProject(t, `schema:
notes:
type: string
required: true
source: 'body.section["## Notes #"]'
`, "")

result, out, err := runMigrateScaffoldJSON(t, dir)
if err != nil {
t.Fatalf("scaffold failed: %v\noutput: %s", err, out)
}
if result.SectionsAdded != 1 || result.Details[0].Heading != "## Notes #" {
t.Fatalf("result = %+v", result)
}
target := filepath.Join(dir, "T001-task.md")
content := string(mustReadFile(t, target))
if !strings.Contains(content, "\nNotes #\n---\n\n<!-- TODO -->\n") {
t.Fatalf("scaffolded content does not contain the Setext heading:\n%s", content)
}
record, err := extractProspectiveRecord(target, target, []byte(content))
if err != nil {
t.Fatal(err)
}
got, present, err := extract.ResolveBodyValue(record, `body.section["## Notes #"]`)
if err != nil || !present || got != "<!-- TODO -->" {
t.Fatalf("extracted value = %q, present = %v, error = %v", got, present, err)
}
}

func TestMigrateScaffoldRejectsLossyHeadingWithoutWrite(t *testing.T) {
original := "---\ntitle: Task\n---\n# Task\n"
dir := newMigrateScaffoldSectionSourceProject(t, `schema:
notes:
type: string
required: true
source: 'body.section["### Notes #"]'
`, original)

out, err := runCmd(t, "migrate", "--scaffold", dir)
if err == nil {
t.Fatalf("scaffold output = %s, want an error", out)
}
if !strings.Contains(err.Error(), "without data loss") {
t.Fatalf("error = %q", err)
}
if got := string(mustReadFile(t, filepath.Join(dir, "T001-task.md"))); got != original {
t.Fatalf("affected file changed\ngot: %q\nwant: %q", got, original)
}
}

func TestMigrateScaffoldRequiredSectionSourceRejectsBrokenProspectiveLinkWithoutWrite(t *testing.T) {
dir := newMigrateScaffoldSectionSourceProject(t, `schema:
notes:
Expand Down Expand Up @@ -131,6 +183,66 @@ func TestMigrateScaffoldRequiredSectionSourceDryRunParityAndNoWrite(t *testing.T
}
}

func TestMigrateScaffoldRejectsMissingQualifiedSectionWithoutWriting(t *testing.T) {
schema := `schema:
notes:
type: string
required: true
source: 'body.section["## Parent"]["### Notes"]'
`
selector := `body.section["## Parent"]["### Notes"]`
original := "---\ntitle: Task\n---\n# Task\n"

writeDir := newMigrateScaffoldSectionSourceProject(t, schema, original)
writeOut, writeErr := runCmd(t, "migrate", "--scaffold", writeDir)
if writeErr == nil {
t.Fatalf("expected scaffold to reject the qualified selector, output: %s", writeOut)
}
if !strings.Contains(writeErr.Error(), `field "notes"`) || !strings.Contains(writeErr.Error(), selector) {
t.Fatalf("write error = %q, want field and canonical selector", writeErr)
}
writeContent, err := os.ReadFile(filepath.Join(writeDir, "T001-task.md"))
if err != nil {
t.Fatal(err)
}
if string(writeContent) != original {
t.Fatalf("affected file changed after materialization error\ngot: %q\nwant: %q", writeContent, original)
}

dryDir := newMigrateScaffoldSectionSourceProject(t, schema, original)
dryOut, dryErr := runCmd(t, "migrate", "--scaffold", "--dry-run", dryDir)
if dryErr == nil {
t.Fatalf("expected dry-run to reject the qualified selector, output: %s", dryOut)
}
if dryErr.Error() != writeErr.Error() {
t.Fatalf("dry-run error = %q, want write error %q", dryErr, writeErr)
}
dryContent, err := os.ReadFile(filepath.Join(dryDir, "T001-task.md"))
if err != nil {
t.Fatal(err)
}
if string(dryContent) != original {
t.Fatalf("dry-run changed the affected file\ngot: %q\nwant: %q", dryContent, original)
}
}

func TestMigrateScaffoldQualifiedSectionFrontmatterPrecedence(t *testing.T) {
dir := newMigrateScaffoldSectionSourceProject(t, `schema:
notes:
type: string
required: true
source: 'body.section["## Parent"]["### Notes"]'
`, "---\nnotes: override\n---\n# Task\n")

result, out, err := runMigrateScaffoldJSON(t, dir)
if err != nil {
t.Fatalf("frontmatter must satisfy the qualified field: %v\noutput: %s", err, out)
}
if result.SectionsAdded != 0 || result.FilesScaffolded != 0 {
t.Fatalf("result = %+v, want no materialization", result)
}
}

func TestMigrateScaffoldRequiredSectionSourcePreservesModeAndValidatesAfterWrite(t *testing.T) {
dir := newMigrateScaffoldSectionSourceProject(t, `schema:
anchor:
Expand Down
2 changes: 1 addition & 1 deletion cmd/rootline/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func generateMarkdown(absPath string, effective *rules.StemFile) (string, error)
}
for _, section := range sections {
b.WriteString("\n")
b.WriteString(section.Heading)
b.WriteString(section.MarkdownHeading())
b.WriteString("\n\n")
b.WriteString(section.Content)
b.WriteString("\n")
Expand Down
66 changes: 66 additions & 0 deletions cmd/rootline/new_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"testing"

"github.com/pablontiv/rootline/internal/extract"
"github.com/pablontiv/rootline/internal/rules"
)

Expand Down Expand Up @@ -54,6 +55,32 @@ func TestNewSectionSourceMaterializesRequiredSectionsAndValidates(t *testing.T)
}
}

func TestNewSectionSourceMaterializesLosslessSetextHeading(t *testing.T) {
dir := newSectionSourceProject(t, `schema:
notes:
type: string
required: true
source: 'body.section["## Notes #"]'
`)
target := filepath.Join(dir, "T001-task.md")

if out, err := runCmd(t, "new", target); err != nil {
t.Fatalf("new failed: %v\noutput: %s", err, out)
}
content := string(mustReadFile(t, target))
if !strings.Contains(content, "\nNotes #\n---\n\n<!-- TODO -->\n") {
t.Fatalf("generated content does not contain the Setext heading:\n%s", content)
}
record, err := extractProspectiveNewRecord(target, content)
if err != nil {
t.Fatal(err)
}
got, present, err := extract.ResolveBodyValue(record, `body.section["## Notes #"]`)
if err != nil || !present || got != "<!-- TODO -->" {
t.Fatalf("extracted value = %q, present = %v, error = %v", got, present, err)
}
}

func TestNewSectionSourceUsesRecordPathAwareSchema(t *testing.T) {
dir := newSectionSourceProject(t, `schema:
task_notes:
Expand Down Expand Up @@ -133,6 +160,12 @@ func TestNewSectionSourceNoWriteOnSchemaMaterializationOrValidationFailure(t *te
type: string
required: true
source: 'body.section["Notes"]'
`},
{"heading cannot be materialized without data loss", `schema:
notes:
type: string
required: true
source: 'body.section["### Notes #"]'
`},
{"invalid prospective ordinary frontmatter", `schema:
status:
Expand Down Expand Up @@ -169,6 +202,39 @@ func TestNewSectionSourceNoWriteOnSchemaMaterializationOrValidationFailure(t *te
}
}

func TestNewRejectsMissingQualifiedSectionBeforeWriting(t *testing.T) {
dir := newSectionSourceProject(t, `schema:
notes:
type: string
required: true
source: 'body.section["## Parent"]["### Notes"]'
`)
selector := `body.section["## Parent"]["### Notes"]`

target := filepath.Join(dir, "T001-task.md")
out, err := runCmd(t, "new", target)
if err == nil {
t.Fatalf("expected new to reject the qualified selector, output: %s", out)
}
if !strings.Contains(err.Error(), `field "notes"`) || !strings.Contains(err.Error(), selector) {
t.Fatalf("error = %q, want field and canonical selector", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("target must remain absent, stat error = %v", statErr)
}

existing := filepath.Join(dir, "existing.md")
original := []byte("---\ntitle: Keep\n---\n# Existing\n")
mustWriteFile(t, existing, original, 0o640)
out, err = runCmd(t, "new", existing, "--force")
if err == nil {
t.Fatalf("expected forced new to reject the qualified selector, output: %s", out)
}
if got := mustReadFile(t, existing); string(got) != string(original) {
t.Fatalf("forced target changed after materialization error\ngot: %q\nwant: %q", got, original)
}
}

func mustResolveNewEffective(t *testing.T, dir, target string) *rules.StemFile {
t.Helper()
effective, err := rules.ResolveForRecord(dir, target)
Expand Down
Loading
Loading