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
62 changes: 42 additions & 20 deletions .claude/skills/rootline/ref-validate.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Use `validate` to check Markdown records against the effective `.stem` schema.
| repository scope | `rootline validate --all -o json` |
| staged files | `rootline validate --staged -o json` |

`validate` exits non-zero when validation errors exist. If JSON was requested, parse stdout anyway.
`validate` exits non-zero when validation errors exist. If JSON was requested, parse stdout
anyway — every invocation emits the envelope, including the failure paths.

### Frontmatter Scope

Expand Down Expand Up @@ -46,33 +47,54 @@ workaround is obsolete.
| `--strict` | treat warnings as errors |
| `--where "expr"` | filter records in `--all` mode |

### JSON Shapes
### JSON Shape

Single file:
One envelope for every invocation — single file, multiple files, `--all`, `--staged` with an
empty index, and the scan-failure path. Never branch on the flags; the keys are always present.

```json
{
"version": 1,
"kind": "rootline/validate",
"path": "file.md",
"valid": false,
"errors": [],
"warnings": []
}
```

Batch:

```json
{
"version": 1,
"version": 2,
"kind": "rootline/validate-batch",
"results": [],
"summary": { "total": 10, "valid": 8, "invalid": 2, "errors_count": 3, "warnings_count": 1 }
"results": [
{ "version": 1, "kind": "rootline/validate", "path": "file.md",
"valid": false, "errors": [], "warnings": [] }
],
"structural": [
{ "version": 1, "kind": "rootline/validate", "path": "sub/",
"valid": true, "errors": [], "warnings": [] }
],
"stem_health": [
{ "path": "sub/.stem", "check": "scope-match", "field": "",
"severity": "warn", "message": "scope.match \"*.txt\" matches no files in directory" }
],
"drift_warnings": [],
"notices": [ { "severity": "warn", "code": "no_records", "message": "no records found in scope" } ],
"summary": {
"total": 10, "valid": 8, "invalid": 2,
"errors_count": 3, "warnings_count": 1, "drift_warnings_count": 0,
"structural_errors_count": 0, "structural_warnings_count": 0,
"stem_health_errors_count": 0, "stem_health_warnings_count": 1, "stem_health_info_count": 0
}
}
```

Each issue includes `rule`, `field`, `message`, `source`, `severity`, and optional `suggestion`.
Reading it:

- A single-file verdict is `.results[0]`, not the top level. `--field valid` is now
`--field "results[].valid"`.
- `results` holds documents only. `summary.total` is a record count and agrees with
`query --count` on the same path. Directory verdicts from `structural:` rules are in
`structural[]` (trailing-slash paths; the scan root is `"/"`).
- `.stem` diagnostics are in `stem_health`, keyed by `check`, with `severity` `error`,
`warn` or `info`. `info` (e.g. `nested-root-marker`) never fails `--strict`.
- `notices` carries run-level diagnostics by stable `code`: `scan_failed`,
`schema_resolution_failed`, `stem_health_unavailable`, `no_records`.
- A tree with no `.stem`, or one that does not parse, still emits this envelope —
`stem-files-exist` / `yaml-valid` in `stem_health`, `scan_failed` in `notices`, exit 1.
- `validate --staged` on an empty index emits the envelope with `summary.total: 0`, exit 0.

Each issue in `results[]` includes `rule`, `field`, `message`, `source`, `severity`, and optional `suggestion`.

Link-check rules (emitted when the effective `.stem` sets `links.checks`): `link_resolve` (target missing, case-sensitive; carries fuzzy `suggestion`; wikilinks infer `.md` so `[[b]]` matches `b.md` and `[[sub/README]]` matches `sub/README.md`, while markdown targets resolve literally; root-anchored `/x.md` resolves against the scan root, or the governance boundary for single-file `validate`; resolution is clamped to that root, so a target escaping it via `..` or via a symlink pointing outside the tree never resolves, while a symlink staying inside still does), `link_anchor` (`#anchor` matches no heading slug in the target), `link_encoding` (raw space in target; use `%20`). These are not auto-fixable by `fix` — repair the link or the target file manually. `checks.cycles: true` additionally makes `graph --check` fail on link cycles; without it cycles are printed as informational and only broken links set the exit code (override per-run with `--fail-cycles`).

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel

### Added

- `validate` envelope gained `structural[]` (directory verdicts, previously trailing-slash pseudo-records inside `results[]`) with `summary.structural_errors_count` / `structural_warnings_count`
- `validate` envelope gained `stem_health[]` (`.stem` diagnostics with `error`/`warn`/`info` severity), `notices[]` (run-level diagnostics keyed by a stable `code`: `scan_failed`, `schema_resolution_failed`, `stem_health_unavailable`, `no_records`), and three `summary.stem_health_*_count` fields
- Pull request template now has a dedicated **Related issue** section with a `Closes #<N>` field and a checklist item, so issue linkage stops depending on the author remembering the keyword
- `CHANGELOG.md` (this file) — ecosystem documentation baseline
- GitHub Issues enabled on the repository
Expand All @@ -19,6 +21,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Rel

### Changed

- **BREAKING**: `validate` emits one envelope shape for every invocation — `rootline/validate-batch` **version 2** — including single-file runs, which previously emitted a bare `rootline/validate` object. Read a single verdict as `.results[0]`; `--field valid` becomes `--field "results[].valid"`. See the upgrade table in `docs/validate.md`.
- **BREAKING**: `.stem` health findings no longer appear in `results[]` (they carried `source: "stem-health"` and a `.stem` path) and no longer count toward `summary.total`, `summary.valid` or `summary.warnings_count`. They move to `stem_health[]` with their own summary counts. `summary.total` is now a record count and agrees with `query --count`, `tree --field root.total` and `stats --field total` on the same path — previously it varied with schema hygiene, and `.stem` entries survived a `--where` filter they could not match.
- **BREAKING**: directory structural verdicts (trailing-slash paths such as `sub/`) moved out of `results[]` into `structural[]` and no longer count toward `summary.total`, for the same reason `.stem` findings did. An error there still exits 1.
- **BREAKING**: `validate --staged` with an empty index now emits the envelope with `summary.total: 0` instead of writing zero bytes, so `rootline validate --staged | jq -e '.summary.invalid == 0'` no longer fails in a pre-commit hook.
- `validate --all` on a tree with no `.stem`, or one that does not parse, now emits the envelope — carrying `stem-files-exist` or `yaml-valid` in `stem_health` and `scan_failed` in `notices`, still exit 1 — instead of a raw Go error on stderr and no JSON. Both checks were computed and then discarded, making them unreachable through the command.
- `validate --all` on an emptied or renamed path now reports `total: 0` plus a `no_records` notice. It previously reported `total: 1, valid: 1` — the `stem-files-exist` pseudo-record — which a CI gate read as green.
- `nested-root-marker` is now delivered at `info` severity as authored, and no longer fails `--strict`. The severity mapper handled only `pass` and `fail`, silently promoting `info` to a warning that CI could not suppress.
- `monotonic-violations` messages now name the category that was violated. Type widening, required loosening, severity loosening and structural loosening all rendered as `(type change: ...)`; structural bounds were truncated to the field `structural`, making `min_children` and `max_children` indistinguishable. They now report their full constraint path.
- CI now runs for feature-based and stacked pull requests, while preserving the existing push and release safeguards
- License changed from PolyForm Noncommercial 1.0.0 to Apache License 2.0 — commercial use is now permitted
- picokit dependency bumped to its Apache-2.0-relicensed release, so distributed binaries no longer embed noncommercially licensed code
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ Derivation evaluates per-record expressions from `.stem` `derive:` fields. Aggre
### Key Design Decisions

- CLI commands call the Core Engine directly and emit stable versioned contracts.
- Each JSON payload carries its own `version` for contract stability. Most commands use version 1; `tree` uses version 2.
- Each JSON payload carries its own `version` for contract stability. Most commands use version 1; `tree` and `validate` use version 2.
- `validate` emits exactly one envelope shape — `rootline/validate-batch` version 2 — for every invocation: one file, several files, `--all`, `--staged` with an empty index, and the corpus-scan failure path. Its six keys (`results`, `structural`, `stem_health`, `drift_warnings`, `notices`, `summary`) are always present, empty collections as `[]`. `results` holds documents only — directory verdicts from `structural:` rules moved to `structural[]` — so `summary.total` agrees with `query --count` on the same path; `.stem` diagnostics live in `stem_health` with severity `error`/`warn`/`info` and their own `stem_health_*_count` summary fields; run-level diagnostics (`scan_failed`, `schema_resolution_failed`, `stem_health_unavailable`, `no_records`) live in `notices`, keyed by a stable `code`. Stem health runs before the corpus scan and survives its failure, so a missing or unparseable `.stem` still emits JSON (with `stem-files-exist` or `yaml-valid`) instead of a raw Go error. Exit is non-zero on an invalid record, a structural error, a stem-health error, or an error notice; `--strict` adds warnings on all three axes; `info` never fails. See `docs/validate.md` for the version 1 → 2 upgrade table.
- `.stem` merge behavior is determined by YAML data type, not field names.
- Version is injected via ldflags at build time (`cmd/rootline/root.go`).

Expand Down
6 changes: 1 addition & 5 deletions cmd/rootline/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,7 @@ func TestValidateSingleFile(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if result["valid"] != true {
if result := firstResult(t, out); result["valid"] != true {
t.Errorf("expected valid=true, got %v", result["valid"])
}
}
Expand Down
6 changes: 1 addition & 5 deletions cmd/rootline/nested_stem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,7 @@ func TestValidateNestedStem_AllValid(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if result["valid"] != true {
if result := firstResult(t, out); result["valid"] != true {
t.Errorf("expected valid=true for T001.md, got %v", result["valid"])
}
}
Expand Down
25 changes: 25 additions & 0 deletions cmd/rootline/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ func isExemptFromBoundaryPreflight(cmd *cobra.Command) bool {
return false
}

// commandsReportingSchemaFailure lists commands that surface an unreadable
// `.stem` as structured output instead of a raw error. They stay governed —
// only the shape of the failure changes, not whether it fails.
var commandsReportingSchemaFailure = map[string]bool{
"validate": true,
}

// reportsSchemaFailureItself reports whether cmd, or any command it hangs
// under, renders schema failures in its own contract.
func reportsSchemaFailureItself(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if commandsReportingSchemaFailure[c.Name()] {
return true
}
}
return false
}

// boundaryPreflight runs once before any schema-governed command.
//
// It is wired at the root rather than at each of the sixteen WalkUp call sites:
Expand All @@ -74,6 +92,13 @@ func boundaryPreflight(cmd *cobra.Command, args []string) error {

entries, err := rules.WalkUp(target)
if err != nil {
if !errors.Is(err, rules.ErrNoSchemaFound) && reportsSchemaFailureItself(cmd) {
// `validate` is the command whose job is to report a broken schema.
// Failing here handed the user a raw Go parser error on stderr and
// no JSON at all, which is precisely the diagnostic the yaml-valid
// stem-health check exists to produce. Let it run and report.
return nil
}
if !errors.Is(err, rules.ErrNoSchemaFound) {
// A real IO or parse failure, and this is the only place that sees
// every governed command. `query` and `stats` never resolve a
Expand Down
64 changes: 62 additions & 2 deletions cmd/rootline/staged_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,51 @@ func TestValidateStagedNoFiles(t *testing.T) {
if err != nil {
t.Fatalf("expected no error with empty staging area, got: %v", err)
}
if out != "" {
t.Fatalf("expected empty output, got: %s", out)

// An empty index is still a validated corpus of size zero. Writing nothing
// breaks `rootline validate --staged | jq -e '.summary.invalid == 0'`,
// which is exactly the pre-commit hook this flag exists for.
env := decodeEnvelope(t, out)
if env["kind"] != "rootline/validate-batch" {
t.Errorf("kind = %v, want rootline/validate-batch", env["kind"])
}
summary := env["summary"].(map[string]any)
if summary["total"].(float64) != 0 {
t.Errorf("summary.total = %v, want 0", summary["total"])
}
if summary["invalid"].(float64) != 0 {
t.Errorf("summary.invalid = %v, want 0", summary["invalid"])
}
}

// TestGetStagedFilesIgnoresAmbientGitScope pins the fixture against an inherited git
// scope. `getStagedFiles` runs `git diff --cached` with the process environment, which
// is correct in production: a pre-commit hook wants the index git handed it. But git
// exports GIT_DIR / GIT_INDEX_FILE into every hook it runs, so the same suite executed
// from `.githooks/pre-push` read the outer repository's index instead of the fixture's
// and reported zero staged files. Issue #121 cleared the environment for the fixture's
// own git writes; the production reader was still inheriting it.
func TestGetStagedFilesIgnoresAmbientGitScope(t *testing.T) {
foreign := t.TempDir()
runFixtureGit(t, foreign, "init", "--quiet")
t.Setenv("GIT_DIR", filepath.Join(foreign, ".git"))
t.Setenv("GIT_INDEX_FILE", filepath.Join(foreign, ".git", "index"))

dir := makeStagedRepo(t, map[string]string{"document.md": "# Document\n"})
mustChdir(t, dir)

files, err := getStagedFiles()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(files) != 1 || files[0] != "document.md" {
t.Fatalf("expected only document.md, got: %v", files)
}
}

func makeStagedRepo(t *testing.T, stagedFiles map[string]string) string {
t.Helper()
isolateAmbientGitScope(t)
dir := t.TempDir()
runFixtureGit(t, dir, "init", "--quiet")

Expand All @@ -70,6 +108,28 @@ func makeStagedRepo(t *testing.T, stagedFiles map[string]string) string {
return dir
}

// isolateAmbientGitScope removes every repo-scoping git variable from the test process
// for the duration of the test, restoring the previous values afterwards. Production
// code deliberately inherits these — see TestGetStagedFilesIgnoresAmbientGitScope — so
// a fixture repository is only authoritative once the ambient scope is gone.
func isolateAmbientGitScope(t *testing.T) {
t.Helper()
for _, name := range gitenv.ScopingVars() {
previous, present := os.LookupEnv(name)
if !present {
continue
}
if err := os.Unsetenv(name); err != nil {
t.Fatalf("unset %s: %v", name, err)
}
t.Cleanup(func() {
if err := os.Setenv(name, previous); err != nil {
t.Fatalf("restore %s: %v", name, err)
}
})
}
}

func runFixtureGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...) //nolint:gosec
Expand Down
7 changes: 1 addition & 6 deletions cmd/rootline/strict_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -48,11 +47,7 @@ func TestValidateWarnOnlyNoExitCode(t *testing.T) {
t.Fatalf("expected no error for warn-only, got: %v", err)
}
// Should show valid=true
var result map[string]any
if err := json.Unmarshal([]byte(out), &result); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if result["valid"] != true {
if result := firstResult(t, out); result["valid"] != true {
t.Errorf("expected valid=true with only warnings, got: %v", result["valid"])
}
}
Expand Down
Loading
Loading