| estado | Completed |
|---|
The rootline fix command goes beyond simple mechanical repairs. It analyzes validation errors and proposes intelligent solutions based on data patterns and heuristics.
fix --all reports link problems it will not repair, under link_findings in JSON and as a
"Link findings" block in table output:
$ rootline fix --all docs/
Link findings: 1 (reported, not repaired)
a.md: link target "guied.md" does not resolve to an existing file (case-sensitive) (link_resolve) — did you mean "guide.md"?link_resolve findings do not trigger automatic body rewrites. Correcting a link on a fuzzy
guess is a destructive edit outside the data-repair contract — the suggestion is offered so
a human can make the call. Before this, a record validate failed with link_resolve came
back from fix as a clean run, which read as a tool bug.
Historical correct_link proposals remain accepted by repair apply and the legacy
proposal-application path. fix surfaces link-check findings separately, while internally
generated legacy link proposals can still be applied. Each proposal replaces only the
first exact occurrence of from with to. An absent or empty from, or identical
from and to, produces no write and no successful-change
entry: repair apply reports the target in skipped[], and the legacy path exposes
the skipped proposal paths in skipped_proposals[] instead of counting them as applied.
Table output identifies these as no content change, without --fill-missing guidance.
Reapplying a report after its source disappeared leaves both file bytes and
modification time untouched.
repair apply --dry-run uses the same content-change decision and composes successive
same-file proposals in its preview. Real changes retain post-validation and rollback.
Both application paths check every target for lexical and physical containment before
reading any document in a proposal; escaping paths and symlinks are refused. Repair
reports reject absolute paths, while the legacy path accepts absolute targets only
inside its root, matching its existing containment policy.
fix --all can repair a narrow class of YAML representation mismatches where the schema expects
type: string but the frontmatter scalar was parsed by YAML as a native value. The repair preserves
the exact scalar text and changes only the representation by letting the YAML writer quote the value.
| Expected | Actual YAML representation | Result |
|---|---|---|
| string | timestamp | exact lexeme quoted automatically |
| string | boolean | exact lexeme quoted automatically |
| string | integer | exact lexeme quoted automatically |
| string | mapping, sequence, null, number | type_findings, no mutation |
| boolean/integer/other | string or another representation | type_findings, no coercion |
Contract details:
fromandtocan be textually equal because the representation changes.from_representationis set only for type repairs and omitted for historical proposal shapes.repair applyrequires exact lexeme and representation matches; unknown markers fail closed. A typed stored report re-applied after the field has already been quoted is rejected, not skipped, because the current representation isstringand no longer matchesfrom_representation.- Findings are reported in JSON/table but do not change exit
0; callers runvalidatefor a validity verdict. - The YAML writer adds quoting; reports never embed quote characters in
to.
Rootline categorizes issues into specific proposal types to preserve semantic meaning.
| Type | Logic | Example |
|---|---|---|
| extend_enum | If N files share an invalid enum value, propose adding it to the .stem |
Adding "Obsolete" to status |
| correct_value | Suggests the closest valid enum value for a typo | "Completd" → "Completed" |
| migrate_value | Extracts structured data from free-text invalid values | "Pending (blocked by T001)" → [[blocks:T001]] |
| extract_body | Finds **Key**: Value patterns in the document body |
Extracting status from a legacy README |
| add_field | Adds a missing required field with a default or inferred value | Adding estado: Pending to a file without it |
| infer_from_siblings | Uses statistical majority of a directory to fill missing values | Setting tipo: software because 90% of files are software |
| correct_outlier | Identifies values that differ from a strong consensus in a folder | Flagging a task as "manual" in a "deploy" folder |
| infer_from_children | Historical type retained in the JSON counters, but no detector currently emits it; configure parent rollups with .stem aggregate: expressions instead |
Summary counter remains 0 |
| correct_link | Historical link correction supported by stored reports and internal legacy proposals; link_findings remain report-only |
Replace the first exact source occurrence, or skip when content is unchanged |
| add_aggregate | Schema-surface suggestion to add an aggregate expression to a .stem; current generation uses a conservative quoted default, not semantic len(filter(...)) logic |
Adding estado: "Completed" to .stem for review |
| remove_stem_field | Removes invalid fields from .stem detected by stem health checks |
Removing a field that references a non-existent type |
| propagate_aggregate | Detects stale aggregate values in index files and corrects them | Updating completed count after child status changes |
| set_field | Sets a frontmatter field to a specific value via the rootline set pipeline |
Setting estado: Completed explicitly |
| set_section | Sets, appends to, or creates a section body via the rootline set pipeline |
Replacing ## Summary with inferred content |
| schema_evolution | Explicit migration marker for destructive schema evolution | Recording a reviewed incompatible schema change |
| remove_field | Explicit field removal from schema during migration | Removing a deprecated schema field |
| loosen_required | Explicit required→optional change during migration | Making owner optional |
| change_type | Explicit incompatible type change during migration | Changing priority from string to integer |
| replace_enum_values | Explicit replacement of an enum's value set during migration | Replacing legacy lifecycle values |
| loosen_severity | Explicit validation-severity reduction during migration | Lowering a rule from error to warning |
Each proposal in the output has the following fields:
| Field | Type | Description |
|---|---|---|
type |
string | Proposal type (see table above) |
field |
string | Field name the proposal targets |
description |
string | Human-readable explanation |
paths |
string[] | Affected file paths |
from |
string | Exact scalar text expected before a correct_value repair applies |
to |
string | Replacement scalar text; for representation-only repairs this matches from textually |
value_source |
string | Optional provenance for add_field values (schema_default, enum_first, or empty) |
from_representation |
string | Optional evidence for type representation repairs (timestamp, boolean, or integer) |
heading |
string | For set_section proposals: the Markdown heading to mutate |
mode |
string | For set_section proposals: replace (default), append, or create |
heading and mode are only populated on set_section proposals. set_field reuses the existing applySetField pipeline from rootline set.
Rootline provides three separate commands for different repair scenarios. Choose the right one for your workflow:
Deprecated. Use rootline repair apply instead (see below).
rootline fix --all --dry-run # Preview data repairs and withheld schema suggestions
rootline fix --all # Apply data-only repairs to documentsfix combines proposal generation and application in one step. It writes document frontmatter only. Schema-surface proposals such as extend_enum, add_aggregate, and remove_stem_field are withheld for review: in dry-run JSON they appear as a schema_suggestions array, while apply JSON reports a schema_suggestions integer count.
Within each effective-schema scope, extend_enum suggestions are ordered lexicographically by field, then value; each suggestion's paths are also lexicographically ordered. This does not reorder other proposal phases or schema scopes.
Use this for applying the versioned rootline/proposals report produced by
rootline fix --all --dry-run. Analyze reports are schema/diagnostic inference
reports and are accepted by schema apply, not repair apply.
Workflow:
cd docs/
# 1. Generate a proposals report
rootline fix --all . --dry-run -o json > fix-proposals.json
# 2. Review the proposals (schema_suggestions are separated)
# 3. Apply repair proposals (frontmatter only, never .stem)
rootline repair apply --report fix-proposals.json --dry-run
rootline repair apply --report fix-proposals.jsonWhere the paths in a report resolve. fix --all records the directory it scanned in the
report (path as you spelled it, root as an absolute path), and both repair apply and
schema apply resolve against that — not against wherever the report file happens to sit. So the
normal CI shape works:
rootline fix --all docs/ --dry-run -o json > artifacts/repairs.json
rootline repair apply --report artifacts/repairs.json # resolves to docs/, not artifacts/The rule is one shared precedence chain, identical in both commands:
--root <dir>— an explicit override, the last wordrootrecorded in the report — the absolute scan rootpathrecorded in the report — the scan root as it was spelled- the directory holding the report file — the pre-
rootbehaviour
Rung 4 keeps reports written before rungs 2 and 3 existed working exactly as they did, so an old
report stored beside its documents still applies. It was also the whole problem: on its own it
made a report in a reports/ directory resolve to paths that do not exist, and the run silently
changed nothing.
repair apply checks the governance boundary at that resolved absolute root, not at the
working directory or report directory unless the legacy fallback selects it. It reads and
validates the report once before preflight, then applies the same report snapshot and root.
Replacing the report between preflight and application cannot redirect the repair to another
tree. This target binding also applies to --dry-run; the boundary policy itself is unchanged.
Both commands report the root they settled on in their output envelope as root, so a run that
touched nothing tells you where it looked:
rootline repair apply --report artifacts/repairs.json -o json | jq .root
# "/abs/path/to/docs"Report envelope. repair apply validates the report envelope before touching any file
and rejects anything that is not version: 1 and kind: rootline/proposals:
rootline repair apply --report analyze.json
# Error: wrong report kind: rootline/analyze (expected rootline/proposals)An analyze report is therefore not a valid input here — produce the report with
rootline fix --all <dir> --dry-run -o json. For the schema-surface half of an analyze
report, use rootline schema apply, which does accept kind: rootline/analyze.
Exit status. repair apply and schema apply exit non-zero exactly when the run failed to
carry through something it accepted — that is, when errors[] or rolled_back[] came back
non-empty. Everything else exits 0. The payload is always written to stdout first, so a
failing run stays machine-readable; the short Error: apply failed: ... line goes to stderr.
| Outcome | Field | Exit | Why |
|---|---|---|---|
| Applied cleanly | changed[] |
0 |
The command did what it was asked. |
| Refused on policy | rejected[] |
0 |
A deliberate refusal, already reported — a containment violation, a schema proposal handed to repair apply, an existing .stem without --force. |
| Deferred | skipped[] |
0 |
Nothing was attempted; for schema apply this includes requires_agent work, while for repair apply it includes engine-chosen missing-field values withheld unless --fill-missing is passed. |
| Could not be done | errors[] |
1 |
An unreadable path, a failed write, an unresolvable target. |
| Written, then reverted | rolled_back[] |
1 |
Post-validation rejected the result and the pre-write bytes were restored. The caller asked for a change that could not be made, so a tidy revert is still a failure. |
rolled_back[] is a separate condition, not a subset of errors[]: a successful revert leaves
errors[] empty, so a script testing only errors[] would read that run as a success.
With repair apply -o table, reverted files appear in a separate Rolled back (N)
section with each path and all recorded validation reasons. They are not counted as
successful changes, and a rollback-only result does not print No repairs applied.
--dry-run is not an exception. A preview that cannot resolve a path reports it in errors[]
and exits 1, which makes repair apply --report r.json --dry-run usable as a CI precondition
check. A dry run performs no writes, so it can never populate rolled_back[].
rootline repair apply --report fix-proposals.json && deploy
# the deploy no longer runs when the repair failedAn apply run makes two guarantees and deliberately does not make a third. Stated plainly, because the cost of guessing is a half-written governed document.
Per file: atomic. Every write goes to a staging file in the target's own directory and is renamed over the target. A file is therefore only ever observed as its old self or its new self, never as a truncated middle. A bare write truncates and then writes, so a process killed in between leaves a document its own schema would reject. If the write fails, the target is untouched and the staging file is removed.
Per file: validated, and reverted if it fails. After a write, the file is re-read and
validated on its own. If validation rejects the result, the pre-write bytes are restored, the file
moves from changed[] to rolled_back[], and the run reports failure. The check is per file: an
error against one path — including a path that could not be read at all — never disables the check
for the rest.
This includes set_section body edits as well as frontmatter and link repairs. Proposals for the
same target compose in report order. If their final document fails validation, all changes for that
path are removed from changed[], one rolled_back[] entry records the failure, and the exact
original bytes are restored. Successful section or frontmatter repairs to other files remain in
place. A rollback makes complete false and the command exit non-zero, even when errors[] is empty.
In dry-run, section transforms use the same per-target content and change ledger without writing or
post-validating files; no rollback is reported.
Per run: best-effort with honest reporting. NOT all-or-nothing. A run that fails partway leaves the files it already wrote in place. It does not buffer every rewrite and flush at the end, and it does not roll the whole run back.
That is a deliberate choice, not an omission. Run-level atomicity would mean holding every rewritten document in memory until the last proposal succeeded, and it would still not be atomic against a kill — the flush itself is many writes. Worse, it makes the common case worse: one unreadable path in a hundred-file report would discard ninety-nine good repairs. Best-effort plus an exact account of what happened is more useful and more honest than a guarantee that cannot be kept.
So a run is answerable rather than transactional. Read the envelope to know where it got to:
| Field | Meaning |
|---|---|
complete |
true iff the run carried through everything it accepted. Same condition as exit 0. |
changed[] |
written and validated — these are on disk |
rolled_back[] |
written, rejected by validation, restored — these are back to their original bytes |
rejected[] |
never attempted, refused on policy |
skipped[] |
never attempted, deferred |
errors[] |
attempted or resolved and failed |
complete is not redundant with the exit status. A report saved as a CI artifact is read long
after $? is gone, and a consumer should not have to re-derive the rule — or parse prose — to
learn whether the tree is in the state it asked for:
rootline repair apply --report r.json -o json > result.json # exit status observed here
jq -e .complete result.json # ...and still answerable hereRecovering from a partial run is a re-run: the report is declarative, and re-applying it skips what
is already correct. Take a --dry-run first if you want to see what remains.
For set_field, an existing string equal to the requested value is skipped in both dry-run and
apply, and the remaining paths are still processed. Equality is type-strict: a YAML boolean or
integer is not the same as its string spelling, and a missing field is not an empty string.
repair apply applies only repair-surface proposals to document frontmatter:
- correct_value
- add_field
- migrate_value
- extract_body
- infer_from_siblings
- correct_outlier
- infer_from_children
- propagate_aggregate
- set_field
- set_section
Schema proposals (extend_enum, add_aggregate, remove_stem_field) are rejected visibly in rejected[] — use rootline schema apply instead.
Path containment. A report is untrusted input, so every path it names is checked against
the scan root before any file is opened. A path that escapes the root — through
../../../etc/shadow, an absolute path, or an in-root symlink that resolves outside — is
refused outright and never read, let alone written. Containment resolves existing symlinks
through the deepest existing ancestor, so aliases that remain physically inside the root are
allowed and direct missing in-root targets retain their normal planning behavior. A dangling
symlink is refused because its physical destination cannot be established. Refusals land in
rejected[], not errors[], keeping a policy decision distinguishable from a failed write:
rootline repair apply --report fix-proposals.json
# rejected: containment violation: path "../outside.md" escapes root (root "/abs/scan")A proposal is applied whole or not at all, so a single escaping path discards the entire proposal rather than applying it partially.
schema apply classifies the identical event the identical way: a create_stem target outside
the scan root lands in rejected[] and the run still exits 0. It previously reported that
refusal in errors[], which made the two commands disagree about the same containment decision
and — once the exit rule above exists — would have turned a deliberate refusal into a build
failure.
With --dry-run, the output additionally carries resolved_targets so you can see where each
write would have landed and why any were refused before committing to the run:
{
"resolved_targets": {
"accepted": { "tasks/T001.md": "/abs/scan/tasks/T001.md" },
"rejected": { "../outside.md": "escapes root" }
}
}resolved_targets is additive and omitted outside dry-run; the report contract stays version: 1.
Flags:
--dry-run— Preview changes without modifying files--fill-missing— Also applyadd_fieldproposals whose value was engine-chosen rather than schema-declared--report <file>— Path to proposals JSON file (required)--root <dir>— Absolute scan-root override; takes precedence over reportroot/path
Use this for applying schema changes (extending enums, creating .stem fields, updating .stem files).
Workflow:
# 1. Generate schema proposals from analyze report
rootline analyze docs/ --incremental > analyze.json
# 2. Review schema_suggestions in analyze.json
# 3. Apply schema proposals to .stem files
rootline schema apply --report analyze.json --dry-run
rootline schema apply --report analyze.json
# Or generate proposals without analysis:
rootline schema propose docs/ > proposals.json
rootline schema apply --report proposals.json --dry-run
rootline schema apply --report proposals.jsonschema apply accepts a report from rootline analyze or rootline schema propose and applies only schema-surface proposals to .stem files:
- create_stem
- extend_enum (analyze path only)
Data-only repairs (correct_value, add_field, etc.) are rejected visibly in rejected[] — use rootline repair apply instead.
On the analyze-report path, empty or whitespace-only untyped_field and
sequence_incomplete values are visibly deferred in skipped[]. Non-empty
sequence values lacking a non-blank prefix or positive integer width are refused
in rejected[]. Neither decision invents schema values or blocks valid sibling
inferences, including same-field section bindings; both have exit status 0
unless another accepted operation fails. See Consuming the Report.
What propose proposes is what apply writes. Each create_stem proposal carries a patch
field holding the complete inferred YAML, and schema apply writes those bytes verbatim. It does
not re-derive the schema at apply time, so the artifact a reviewer approved is the artifact that
lands on disk. The neighbouring patch_preview is the same YAML truncated to 200 characters for
display; it is not the write source. A proposal with no patch — a report generated before this
field existed — is refused with an instruction to re-run schema propose, rather than falling
back to a generator that emits untyped type: string shells.
Bootstrap boundaries. Flat and hierarchical schema propose patches declare
root: true when the scan directory has no ancestor .stem with that marker.
Applying such a patch establishes the boundary needed by governed commands such
as query and validate. When an ancestor already declares the boundary, the
patch omits the marker so the subtree keeps inheriting its governing schema
instead of introducing a nested root. An unmarked ancestor is not a boundary.
This policy belongs to proposal generation; init keeps its existing behavior
of marking the generated target as a root.
Where the scan root comes from. schema propose records an absolute root, so a report
applies from any working directory — the usual CI shape, where proposals are written into an
artifacts/ directory and applied from elsewhere. Reports without root keep the older behaviour
of resolving path against the caller's working directory. Post-apply validation runs against
that root, and a scan that fails surfaces in errors[] rather than as an all-zero
validation_summary, which reads identically to a clean run.
Post-apply validation coverage. After a non-dry-run apply, validation_summary
uses the same corpus validation as validate --all: effective field rules, governed
link checks, extraction errors (including malformed YAML), leading-frontmatter
structure, and structural rules for record-bearing directories and their ancestors
within the scan scope. The scan uses the AST-enabled extractor and honors the same
scope exclusions. Drift diagnostics remain specific to validate --all.
total_files counts documents only, and valid_files + invalid_files always equals
total_files. total_errors includes both document errors and directory structural
errors. If only directory errors exist, one otherwise-valid document is conservatively
counted as invalid so the compact summary cannot appear green; directories never
increase total_files. Warning-only findings do not count as errors.
Validation findings are summary-only: they do not roll back the applied schema,
populate errors[], change complete, or cause a non-zero exit. Operational scan,
derivation, or schema-resolution failures instead populate errors[], omit the
summary, and fail the command. Use rootline validate --all <root> for individual
findings and a corpus-validity exit status.
Path containment. As with repair apply, a report is untrusted input: every create_stem
target is checked against the scan root before a .stem is written, so neither
<root>/../../outside/.stem nor an in-root symlink to an external directory can scaffold
outside the tree the command was pointed at.
schema propose emits absolute targets, so the propose→apply loop depends on absolute paths
staying valid — they are accepted and then confined, not refused outright. That is the one
difference from repair apply, which rejects absolute paths as malformed input.
The second difference is where refusals land. schema apply reports policy refusals such as overwrite without --force, unknown operations, and containment violations in rejected[]; operational failures such as scan or write errors remain in errors[]:
rootline schema apply --report proposals.json
# rejected: ".stem already exists in /docs (use --force to overwrite)"
# OR
# rejected: containment violation: path "/tmp/outside/.stem" escapes root (root "/abs/scan")With schema-proposals reports, --dry-run carries an additive resolved_targets envelope so you can see where each .stem would land before writing. Analyze-report dry runs do not emit this key: they plan directly from in-memory inferences and report accepted/skipped actions instead.
{
"resolved_targets": {
"accepted": { "/abs/scan/tasks/.stem": "/abs/scan/tasks/.stem" },
"rejected": { "/tmp/outside/.stem": "escapes root" }
}
}resolved_targets is omitted outside dry-run; the contract stays version: 1.
Flags:
--dry-run— Preview changes without modifying files--force— Overwrite existing.stemfiles when applyingcreate_stemproposals--report <file>— Path to schema proposals report JSON file (required)--root <dir>— Absolute scan-root override; takes precedence over reportroot/path
| Scenario | Command | Example |
|---|---|---|
| Add missing required fields to documents | repair apply |
Missing estado field in all tasks |
| Correct misspelled enum values | repair apply |
"Complted" → "Completed" |
Extend .stem enum to accept new valid values |
schema apply |
Adding "Archived" to allowed statuses |
Create .stem for a new directory |
schema apply |
Initialize schema after inferring patterns |
Update .stem after schema changes |
schema apply |
Add new field definitions after inference |
When Rootline modifies a .stem or a frontmatter block, it rewrites through YAML nodes so comments and key order are preserved where possible. Inter-token whitespace, inline-comment spacing, and nested indentation are normalized by the YAML encoder; do not expect byte-identical formatting outside the edited value.
When a valid frontmatter block uses LF newlines and contains only comments and blank lines, adding its first fields retains those comments inside the block, in their original order. The Markdown body and existing file permissions are preserved.
To prevent noisy guesses, sibling inference requires:
- A minimum number of siblings (default: 2 for missing, 3 for outliers).
- A strong consensus threshold (default: 60% for missing, 75% for outliers).