WORK-657
ID:WORK-657Status:done

refrakt format re-indents nested block tags, with a check mode and editor formatting

Once WORK-645 allows indentation, SPEC-170 D5 sets the rule for when to indent: a block tag's content is indented only when it contains another block tag. A rule nobody can apply mechanically drifts, and no existing tool applies this one:

Priority:highComplexity:moderateMilestone:v0.42.0Source:SPEC-170

Criteria completion

Criteria completion: 9 of 10 (90%) checked; history from Oct 24 to Oct 250%25%50%75%100%Oct 24Oct 25
Branches 3
History 3
  1. 4a7d756
    • ☑ `refrakt format [paths]` re-indents `.md` files by SPEC-170 D5: one tab per level of block-tag nesting, applied only inside a block tag that contains another block tag; inline tags do not count
    • ☑ It changes only the leading whitespace of lines inside block tags; every other byte is unchanged, asserted on fixtures that include lists, tables, emphasis and long tag openings
    • ☑ A fenced code block keeps its internal relative indentation and is shifted as a whole
    • ☑ It writes a file only when the AST parsed with the shared `parse` is unchanged; otherwise it reports the file and line and leaves the file alone
    • ☑ Formatting is idempotent: a second run changes nothing, asserted on every fixture
    • ☑ `refrakt format --check` exits non-zero and names each file that would change; CI runs it beside `npm run format:check` (`.github/workflows/format.yml`)
    • ☑ The language server provides document formatting with the same function, so editors can format on save
    • ☑ The MCP server exposes it, or the resolution records why not
    • ☑ The composed-runes guide and the content-authoring docs state the rule and the command
    by bjornolofandersson
  2. a0cdd51
    Created (ready)by bjornolofandersson
  3. f7f9792
    Content editedby Claude
    plan: SPEC-170 D5 indentation rule, WORK-657 refrakt format, WORK-658 si

Acceptance Criteria

  • refrakt format [paths] re-indents .md files by SPEC-170 D5: one tab per level of block-tag nesting, applied only inside a block tag that contains another block tag; inline tags do not count
  • It changes only the leading whitespace of lines inside block tags; every other byte is unchanged, asserted on fixtures that include lists, tables, emphasis and long tag openings
  • A fenced code block keeps its internal relative indentation and is shifted as a whole
  • It writes a file only when the AST parsed with the shared parse is unchanged; otherwise it reports the file and line and leaves the file alone
  • Formatting is idempotent: a second run changes nothing, asserted on every fixture
  • refrakt format --check exits non-zero and names each file that would change; CI runs it beside npm run format:check (.github/workflows/format.yml)
  • The language server provides document formatting with the same function, so editors can format on save
  • Every file under plan/ is unchanged by a run, asserted once in the resolution
  • The MCP server exposes it, or the resolution records why not
  • The composed-runes guide and the content-authoring docs state the rule and the command

Approach

  • Use the tokenizer to find each block tag's opening and closing lines. Work out each line's nesting depth, and rewrite the leading whitespace only. Do not reprint from the AST.
  • Lines inside fences, and continuation lines of list items, need care. The AST-equality guard is what makes a mistake there harmless: a wrong rewrite is refused, not written.
  • The CI check starts with the paths the rule is enforced on (site/content/, the composed fixtures, plan/), and widens as WORK-658 migrates them.

Blocked by

  • WORK-645: the shared parse with allowIndentation.

References

  • SPEC-170: D3 (round trips), D5 (the rule).

Resolution

Completed: 2026-10-25

Branch: claude/v0-42-0-planning-t31f5e

What was done

  • packages/runes/src/format-indent.ts: formatIndentation lays out each block tag's body from the AST's tag and fence line ranges.
    • Owned lines: their common leading whitespace becomes one tab per holding tag.
    • Child fences and multi-line openings move with their first line.
    • Relative indentation is kept. Blank lines are left alone, except inside fences.
    • guardRewrite accepts only a rewrite with the same AST, parsed as the pipeline parses (escapeFenceTags, plus each normalised for templates).
  • packages/cli/src/commands/format.ts + bin.ts: refrakt format [paths] [--check] [--format json], exported as @refrakt-md/cli/format.js.
  • packages/language-server/src/providers/formatting.ts: documentFormattingProvider with per-line indentation edits.
  • packages/mcp/src/tools/core.ts: refrakt.format.
  • CI: root scripts format:md / format:md:check (site/content and the composed fixtures). The check runs in validate.yml.
  • Tests: runes/test/format-indent.test.ts, cli/test/format-command.test.ts, language-server/test/formatting.test.ts, mcp/test/format-tool.test.ts.
  • Docs: docs/cli/format.md (new), docs/content.md, docs/mcp/tools.md, composed-runes.md, CLAUDE.md.
  • Repository-wide (1,545 tracked .md): no refusals, idempotent.

Notes

  • plan/ is not unchanged, and is not covered (criterion left open by decision). Five specs (SPEC-004, 006, 008, 050, 051) hold nested block tags such as {% sandbox %}. D5 would indent their whole bodies, and GitHub would render them as code. The owner chose to keep plan/ out of the run and the CI check, rather than refine D5 or accept that.
  • CI location: the check runs in validate.yml, not beside Biome in format.yml as the criterion says. The formatter is the CLI and needs the build, and format.yml is deliberately build-free. A comment there points to it.
  • The guard fired once for real (SPEC-006, a whitespace-only line inside a fence). That led to fence-interior whitespace lines moving with the fence.