Skip to content

docx_plus.lint

Audit a document for formatting defects, and describe what repairing them would change. See the CLI page for the docx-plus lint and docx-plus plan commands over the same engine.

Nothing here writes. lint reports; plan_fixes turns findings into an ordered, serializable description of the repair and stops there. Designing the fix model at a point where no code path can apply it is the whole reason the two halves shipped separately.

A composing layer, not a capability module: like cli/ it sits above the capability packages and reads across them, adding no OOXML knowledge of its own. Every judgement it makes is built on styles/'s cascade resolver and the document sweep.

Rule kinds

The distinction that keeps an opinionated feature inside a lean library:

Kind Needs config? The judgement
consistency no a value fights the document's own applied styles — the document supplies the target
structural no an objective defect, true regardless of house style
policy yes a value differs from a target you supplied

No policy rule is ever enabled by default, so docx_plus never asserts a house style of its own. It reports that forty paragraphs resolve identically under three style ids; whether that is a problem stays your call.

Writing a rule

Rules register themselves at import, so a new one is a single function:

from collections.abc import Iterator

from docx_plus.lint import Issue, LintContext, rule


@rule(
    id="all-caps-heading",
    kind="consistency",
    severity="info",
    description="A heading typed in capitals rather than styled.",
    tags={"headings"},
    default_on=False,
)
def all_caps_heading(ctx: LintContext) -> Iterator[Issue]:
    for resolved in ctx.paragraphs:
        if resolved.formatting.outline_level is None:
            continue
        text = resolved.text
        if text.isupper() and len(text) > 3:
            yield Issue(
                message="Heading is typed in capitals; use the style's caps property.",
                location=Location(paragraph_index=resolved.index),
                observed=text,
            )

A rule yields Issue — only what the rule itself knows. The engine promotes each to a Finding by stamping on the id, kind, and severity from the registration, so a rule cannot advertise one severity in --list-rules and emit another.

Rules receive the whole swept document, not one paragraph at a time, because the interesting rules are comparative: "this font is an outlier", "these two styles resolve identically", "the outline skips a level". None of those can be decided from a single paragraph.

Fixes and the plan

A rule that knows how to repair what it found attaches a Fix to its Issue. There is no separate "fixable" flag to keep in step: a finding is fixable exactly when it carries one.

from docx_plus.lint import lint, plan_fixes

plan = plan_fixes(lint(doc))
for planned in plan.fixes:
    print(planned.rule, planned.safety, planned.fix.summary)

A fix is a sequence of named operations from a closed vocabulary (FixOp), not a callable — a plan has to survive being written to a file, reviewed, and handed to a different process than the one that built it.

plan_fixes then decides the three things no individual rule can, because each is a property of the set of findings:

  • Order. Deletions last and back to front. Every operation names a position in the document as it was swept, so a deletion partway down invalidates every index below it.
  • The content gate. A fix that removes a paragraph or a style definition changes what the document contains, not how it looks. Those are withheld unless you pass allow_content=True, and reported in plan.deferred so they are visible rather than silently dropped.
  • Conflicts. Two rules can independently claim the same run property or overlapping spans of the same text. Claims are per property and per character span rather than per paragraph, so a paragraph carrying several unrelated defects is still fixable; the earlier fix wins and the loser is named in plan.conflicts.

Every finding lands in exactly one of fixes, deferred, conflicts, or unfixable, so a plan accounts for the whole audit.

Not everything has a fix

Eleven of the twenty rules are report-only, and deliberately. A skipped outline level can be repaired by promoting this heading or demoting the one above it, and those produce different documents; two styles that resolve identically give no reason to prefer either as the survivor; typed indentation needs a number the document does not contain. Each rule's docstring says which case it is. A plan that guessed would be the library asserting a house style, which is exactly what the rule kinds exist to prevent.

Profiles

The one place a house opinion may live. A profile enables and disables rules, overrides severities, and supplies rule-specific options — today font-outliers's max_share / max_runs and manual-heading-formatting's max_chars; once policy rules ship, their targets. A rule reads them through LintContext.option(rule_id, key, default).

{
  "rules": {
    "double-space":  {"enabled": false},
    "style-drift":   {"severity": "error"},
    "font-outliers": {"enabled": true, "options": {"max_share": 0.02}}
  }
}

Pass it as lint(doc, profile=...), or check docx-plus-lint.json into the repository and both CLI commands will find it beside the document (or above it). Naming a rule with --rule overrides a profile that disabled it: configuration never gets to veto a direct question about one document.

A profile may not configure a tag — "apply this severity to whatever carries the tag today" is not a stable thing to check in — and a profile naming a rule that does not exist is an error on load rather than a setting that silently does nothing.

docx_plus.lint.engine

The lint entry point: sweep the document once, run the selected rules.

lint

lint(
    doc: Document,
    *,
    select: Sequence[str] | None = None,
    exclude: Sequence[str] | None = None,
    include_tables: bool = True,
    profile: Profile | str | Path | Mapping[str, Any] | None = None,
) -> list[Finding]

Audit doc and return what the selected rules noticed.

Pure read: nothing here mutates the document. The cascade is resolved once for the whole document and shared across every rule, so the cost is one sweep regardless of how many rules run.

Parameters:

Name Type Description Default
doc Document

The python-docx :class:~docx.document.Document to audit.

required
select Sequence[str] | None

Rule ids and/or tags to run. None runs the default-on set. Naming a tag also enables that cluster's off-by-default rules.

None
exclude Sequence[str] | None

Rule ids and/or tags to skip; applied last.

None
include_tables bool

Whether to sweep paragraphs inside table cells.

True
profile Profile | str | Path | Mapping[str, Any] | None

A :class:~docx_plus.lint.Profile, or anything :meth:~docx_plus.lint.Profile.load accepts. Supplies a team's enable/disable and severity overrides. select and exclude are applied after it, so naming a rule explicitly always wins over what a profile said about it.

None

Returns:

Type Description
list[Finding]

Findings sorted by severity, then document order.

Raises:

Type Description
UnknownRuleError

If a selector matches no rule id or tag.

InvalidProfileError

If profile is malformed.

StyleCascadeError

If a basedOn chain has a cycle or exceeds Word's depth limit.

Note

Only the main document body is audited. Headers, footers, footnotes, endnotes, and comments are not swept — see :func:~docx_plus.styles.iter_resolved_paragraphs.

Example

from docx import Document from docx_plus.lint import lint doc = Document() _ = doc.add_paragraph("Two spaces here.") for finding in lint(doc): ... print(finding.rule, "-", finding.message) double-space - Two or more consecutive spaces between words.

Source code in docx_plus/lint/engine.py
def lint(
    doc: Document,
    *,
    select: Sequence[str] | None = None,
    exclude: Sequence[str] | None = None,
    include_tables: bool = True,
    profile: Profile | str | Path | Mapping[str, Any] | None = None,
) -> list[Finding]:
    """Audit ``doc`` and return what the selected rules noticed.

    Pure read: nothing here mutates the document. The cascade is resolved
    once for the whole document and shared across every rule, so the cost is
    one sweep regardless of how many rules run.

    Args:
        doc: The python-docx :class:`~docx.document.Document` to audit.
        select: Rule ids and/or tags to run. ``None`` runs the
            default-on set. Naming a tag also enables that cluster's
            off-by-default rules.
        exclude: Rule ids and/or tags to skip; applied last.
        include_tables: Whether to sweep paragraphs inside table cells.
        profile: A :class:`~docx_plus.lint.Profile`, or anything
            :meth:`~docx_plus.lint.Profile.load` accepts. Supplies a team's
            enable/disable and severity overrides. ``select`` and
            ``exclude`` are applied *after* it, so naming a rule explicitly
            always wins over what a profile said about it.

    Returns:
        Findings sorted by severity, then document order.

    Raises:
        UnknownRuleError: If a selector matches no rule id or tag.
        InvalidProfileError: If ``profile`` is malformed.
        StyleCascadeError: If a ``basedOn`` chain has a cycle or exceeds
            Word's depth limit.

    Note:
        Only the main document body is audited. Headers, footers,
        footnotes, endnotes, and comments are not swept — see
        :func:`~docx_plus.styles.iter_resolved_paragraphs`.

    Example:
        >>> from docx import Document
        >>> from docx_plus.lint import lint
        >>> doc = Document()
        >>> _ = doc.add_paragraph("Two  spaces here.")
        >>> for finding in lint(doc):
        ...     print(finding.rule, "-", finding.message)
        double-space - Two or more consecutive spaces between words.
    """
    loaded = profile if isinstance(profile, Profile) else Profile.load(profile)
    rules = select_rules(select, exclude, loaded)
    # Provenance and baselines are always on. Neither is an optional extra
    # here: the consistency rules are built on knowing *which* cascade layer
    # set a value and what the value would have been without it, which is
    # the whole advantage of resolving OOXML rather than asking Word for an
    # effective number. One sweep serves every rule, so the cost is paid
    # once however many rules run.
    context = LintContext(
        doc=doc,
        paragraphs=list(
            iter_resolved_paragraphs(
                doc,
                include_provenance=True,
                include_baseline=True,
                include_tables=include_tables,
            )
        ),
        profile=loaded,
    )

    findings: list[Finding] = []
    for rule in rules:
        findings.extend(_run(rule, context, loaded))

    return sorted(findings, key=lambda f: f.sort_key)

docx_plus.lint.plan

Report to plan: turn findings into an ordered, inspectable list of edits.

Nothing here writes. :func:plan_fixes takes findings and returns a description of what a repair pass would change — the ordering it would apply the edits in, the ones it would withhold, and the pairs that cannot both be applied. Applying a plan is a later release, and building the plan first is deliberate: the whole fix model gets designed, serialized, and reviewed at a point where no code path can corrupt a document.

Three things the planner owns that no individual rule can decide, because each of them is a property of the set of findings rather than of any one:

  • Order. Deletions go last and run back to front. A rule that deletes paragraph 12 and a rule that edits the text of paragraph 40 both address positions in the document as swept, and the first deletion invalidates every index after it. Sorting deletions to the end and applying them in descending order keeps every other edit's position valid.
  • The content gate. A fix that removes a paragraph or a style definition changes what the document contains, not how it looks. Those are withheld unless the caller asks for them, and reported separately so they are visible rather than silently dropped.
  • Conflicts. Two rules can independently target the same run, the same paragraph property, or overlapping spans of the same text. Each is right on its own and they cannot both apply.

A plan is JSON-serializable end to end (:meth:FixPlan.to_dict), because a plan that cannot be written to a file, reviewed, and handed to a different process is not much of a plan.

FixPlan dataclass

FixPlan(
    fixes: tuple[PlannedFix, ...] = (),
    deferred: tuple[PlannedFix, ...] = (),
    conflicts: tuple[FixConflict, ...] = (),
    unfixable: tuple[Finding, ...] = (),
)

An ordered, inspectable description of what a repair pass would do.

Attributes:

Name Type Description
fixes tuple[PlannedFix, ...]

What would be applied, in application order.

deferred tuple[PlannedFix, ...]

Fixes withheld by the content gate. Not a failure — a caller who wants them passes allow_content=True.

conflicts tuple[FixConflict, ...]

Pairs that could not both apply, with the loser named.

unfixable tuple[Finding, ...]

Findings with no known repair. Carried so a plan accounts for every finding it was given rather than quietly shortening the list.

operations property

operations: tuple[FixOperation, ...]

Every operation that would be applied, flattened into plan order.

to_dict

to_dict() -> dict[str, Any]

The serializable record for the whole plan.

Source code in docx_plus/lint/plan.py
def to_dict(self) -> dict[str, Any]:
    """The serializable record for the whole plan."""
    return {
        "fixes": [planned.to_dict() for planned in self.fixes],
        "deferred": [planned.to_dict() for planned in self.deferred],
        "conflicts": [conflict.to_dict() for conflict in self.conflicts],
        "unfixable": [
            {
                "rule": finding.rule,
                "severity": finding.severity,
                "message": finding.message,
                "where": finding.location.describe(),
            }
            for finding in self.unfixable
        ],
    }

PlannedFix dataclass

PlannedFix(finding: Finding, fix: Fix)

One finding's fix, placed in a plan.

Position in :attr:FixPlan.fixes is the order — there is no separate sequence number to fall out of step with it.

Attributes:

Name Type Description
finding Finding

What was reported, carried whole so a plan reads without the report alongside it.

fix Fix

The repair. Non-optional here, unlike on the finding: a plan only ever holds findings that have one.

rule property

rule: str

The id of the rule that produced the finding.

safety property

safety: FixSafety

How much trust applying this fix asks for.

adds_content property

adds_content: bool

Whether this fix changes what the document contains.

operations property

operations: tuple[FixOperation, ...]

The edits, in the order the rule requires them applied.

deletes property

deletes: bool

Whether any operation removes a paragraph or a style.

lowest_touched_index property

lowest_touched_index: int

The largest paragraph_index any of this fix's operations names.

What the back-to-front deletion order has to sort on. Sorting on the finding's location instead was unsound: a finding located at paragraph 1 whose fix deletes paragraph 20, planned alongside one located at paragraph 5 deleting paragraph 6, came out as [delete 6, delete 20] — and after 6 goes, the old 20 sits at 19. No shipped rule produces that shape, but plan_fixes is public and takes arbitrary findings.

Falls back to the finding's own location for a fix whose operations name no paragraph at all, such as delete-style.

to_dict

to_dict() -> dict[str, Any]

The serializable record for this planned fix.

Source code in docx_plus/lint/plan.py
def to_dict(self) -> dict[str, Any]:
    """The serializable record for this planned fix."""
    return {
        "rule": self.rule,
        "severity": self.finding.severity,
        "message": self.finding.message,
        "where": self.finding.location.describe(),
        "location": {
            "paragraph_index": self.finding.location.paragraph_index,
            "run_index": self.finding.location.run_index,
            "style_id": self.finding.location.style_id,
            "excerpt": self.finding.location.excerpt,
        },
        "adds_content": self.adds_content,
        "fix": self.fix.to_dict(),
    }

FixConflict dataclass

FixConflict(kept: PlannedFix, dropped: PlannedFix, reason: str)

Two fixes that cannot both be applied.

Resolution is by plan order and nothing else: the earlier fix is kept and the later one is dropped. That is arbitrary between two equally good repairs, which is exactly why the loser is reported rather than discarded — the caller can exclude the winning rule and re-plan if they wanted the other one.

Attributes:

Name Type Description
kept PlannedFix

The fix that stays in the plan.

dropped PlannedFix

The fix removed because of the collision.

reason str

What the two both claimed, in the document's terms.

to_dict

to_dict() -> dict[str, Any]

The serializable record for this conflict.

Source code in docx_plus/lint/plan.py
def to_dict(self) -> dict[str, Any]:
    """The serializable record for this conflict."""
    return {
        "reason": self.reason,
        "kept": {"rule": self.kept.rule, "where": self.kept.finding.location.describe()},
        "dropped": {
            "rule": self.dropped.rule,
            "where": self.dropped.finding.location.describe(),
        },
    }

plan_fixes

plan_fixes(
    findings: Sequence[Finding], *, allow_content: bool = False
) -> FixPlan

Order the repairs for findings and say which of them can coexist.

A pure function of the findings: it never reads the document, so a plan can be built from a stored report. That is also its limit — it can only reason about what the findings say, which is why the fix vocabulary measures text spans against the original text rather than describing edits as transformations to replay.

Parameters:

Name Type Description Default
findings Sequence[Finding]

What :func:~docx_plus.lint.lint reported. Findings with no fix are carried through to :attr:FixPlan.unfixable.

required
allow_content bool

Whether to include fixes that change what the document contains rather than only how it looks. Off by default: a formatting pass should not quietly delete a paragraph.

False

Returns:

Type Description
FixPlan

The plan. Nothing is applied.

Raises:

Type Description
InvalidFixError

If a fix both deletes a paragraph and does positional work elsewhere. See that error for why the plan cannot order such a fix safely.

Example

from docx import Document from docx_plus.lint import lint, plan_fixes doc = Document() _ = doc.add_paragraph("Spaced out .") plan = plan_fixes(lint(doc)) for planned in plan.fixes: ... print(planned.rule, "-", planned.fix.summary) double-space - Collapse 1 run of spaces to a single space. space-before-punctuation - Remove the whitespace before 1 punctuation mark.

Source code in docx_plus/lint/plan.py
def plan_fixes(
    findings: Sequence[Finding],
    *,
    allow_content: bool = False,
) -> FixPlan:
    """Order the repairs for ``findings`` and say which of them can coexist.

    A pure function of the findings: it never reads the document, so a plan
    can be built from a stored report. That is also its limit — it can only
    reason about what the findings say, which is why the fix vocabulary
    measures text spans against the original text rather than describing
    edits as transformations to replay.

    Args:
        findings: What :func:`~docx_plus.lint.lint` reported. Findings with
            no fix are carried through to :attr:`FixPlan.unfixable`.
        allow_content: Whether to include fixes that change what the
            document contains rather than only how it looks. Off by default:
            a formatting pass should not quietly delete a paragraph.

    Returns:
        The plan. Nothing is applied.

    Raises:
        InvalidFixError: If a fix both deletes a paragraph and does
            positional work elsewhere. See that error for why the plan
            cannot order such a fix safely.

    Example:
        >>> from docx import Document
        >>> from docx_plus.lint import lint, plan_fixes
        >>> doc = Document()
        >>> _ = doc.add_paragraph("Spaced  out .")
        >>> plan = plan_fixes(lint(doc))
        >>> for planned in plan.fixes:
        ...     print(planned.rule, "-", planned.fix.summary)
        double-space - Collapse 1 run of spaces to a single space.
        space-before-punctuation - Remove the whitespace before 1 punctuation mark.
    """
    planned = [
        PlannedFix(finding=finding, fix=finding.fix)
        for finding in findings
        if finding.fix is not None
    ]
    for candidate in planned:
        _reject_mixed_deletion(candidate)
    unfixable = tuple(finding for finding in findings if finding.fix is None)

    ordered = sorted(planned, key=_order_key)
    gated = [p.adds_content and not allow_content for p in ordered]
    withheld = tuple(p for p, out in zip(ordered, gated, strict=True) if out)
    candidates = [p for p, out in zip(ordered, gated, strict=True) if not out]

    kept, conflicts = _resolve_conflicts(candidates)
    return FixPlan(
        fixes=tuple(kept),
        deferred=withheld,
        conflicts=tuple(conflicts),
        unfixable=unfixable,
    )

docx_plus.lint.models

The linter's vocabulary: Finding, Location, Rule, LintContext.

The shape here is deliberately close to the sibling wordlive linter's (../wordlive/spec-linter.md §4), so a document audited by either tool reads the same way. The one structural difference is :class:Location: wordlive addresses a live document by anchor id (para:7), which only means anything to a running Word instance, so findings here carry an index into the sweep plus an excerpt.

FixOp module-attribute

FixOp = Literal[
    "clear-run-properties",
    "clear-paragraph-properties",
    "clear-paragraph-numbering",
    "set-run-language",
    "replace-paragraph-text",
    "delete-paragraph",
    "delete-style",
]

The closed vocabulary a fix is expressed in.

Deliberately a fixed set of named operations rather than arbitrary callables. A plan has to survive being written to JSON, read by a human, and applied by a different process than the one that built it, and none of that works if an edit is a Python object holding a bound method.

Each op and its args:

clear-run-properties {"paragraph_index": int, "run_index": int, "properties": [str, ...]} — delete the named direct properties from the run's w:rPr. clear-paragraph-properties {"paragraph_index": int, "properties": [str, ...]} — the same for a paragraph's w:pPr. clear-paragraph-numbering {"paragraph_index": int} — delete the paragraph's direct w:numPr, so the list its style supplies applies again. set-run-language {"paragraph_index": int, "run_index": int, "lang": str}. replace-paragraph-text {"paragraph_index": int, "spans": [{"start": int, "end": int, "replacement": str}, ...]} — half-open character spans into the paragraph's text, all measured against the original text, so the order they are applied in does not matter and two rules' spans can be checked for overlap. delete-paragraph {"paragraph_index": int}. delete-style {"style_id": str}.

Property names are :class:~docx_plus.styles.ResolvedFormatting field names — the vocabulary the finding already reported in, so a plan reads in the same terms as the report that produced it.

FixSafety module-attribute

FixSafety = Literal['safe', 'review', 'destructive']

How much trust applying a fix asks for.

Orthogonal to :attr:Finding.adds_content, which is about what changes (content or only formatting); this is about how recoverable the change is.

  • safe — the document renders identically afterwards. Only the XML gets tidier: a property is deleted from a run and the same value arrives from the style instead. This is the class redundant-direct-formatting produces, and it is provable rather than asserted — the rule found the property precisely by comparing against the value that would surface without it.
  • review — the rendering or the text changes, deliberately. The old value is in the finding's observed, so the change is reversible by hand.
  • destructive — something is removed that the document cannot reconstruct: a style definition and everything it declared, a paragraph and its formatting.

RuleKind module-attribute

RuleKind = Literal['consistency', 'structural', 'policy']

What kind of judgement a rule makes — the distinction that keeps this layer honest about opinions.

  • consistency — a value fights the document's own applied styles. Needs no configuration, because the document supplies the target: the rule only says "this deviates from what you established elsewhere".
  • structural — an objective defect, true regardless of house style: an outline that skips a level, a REF to a bookmark that does not exist.
  • policy — a value differs from a target the user supplied via a profile. Inert without one, so the library ships no opinion of its own.

Severity module-attribute

Severity = Literal['error', 'warning', 'info']

How much a finding matters. error is a defect that will misrender or resolve wrongly; warning is a real inconsistency; info is a nudge.

Finding dataclass

Finding(
    rule: str,
    kind: RuleKind,
    severity: Severity,
    message: str,
    location: Location = Location(),
    observed: str | None = None,
    expected: str | None = None,
    fix: Fix | None = None,
    adds_content: bool = False,
)

One thing a rule noticed.

Attributes:

Name Type Description
rule str

The stable id of the rule that produced it.

kind RuleKind

The rule's :data:RuleKind.

severity Severity

The rule's :data:Severity.

message str

A sentence describing the problem, in the document's terms.

location Location

Where it is.

observed str | None

The value found, rendered for display.

expected str | None

The value the rule would have expected, where that is meaningful. None for rules that report a shape rather than a mismatch.

fix Fix | None

What would repair it, or None where no unambiguous repair exists — which is most of the outline and reference rules, since "the outline skips a level" does not say whether to promote the heading or demote the one above it.

adds_content bool

Whether the fix would insert or delete content rather than only change formatting. :func:~docx_plus.lint.plan_fixes withholds those unless a caller opts in.

fixable property

fixable: bool

Whether a repair is known — exactly whether :attr:fix is set.

sort_key property

sort_key: tuple[int, int, int]

Severity first, then document order — the report's natural order.

Issue dataclass

Issue(
    message: str,
    location: Location = Location(),
    observed: str | None = None,
    expected: str | None = None,
    severity: Severity | None = None,
    fix: Fix | None = None,
    adds_content: bool = False,
)

What a rule body yields — the parts only the rule knows.

The rule's id, kind, and severity come from its registration, so a rule never restates them and they cannot drift from what list_rules advertises. The engine promotes each :class:Issue to a :class:Finding by stamping that metadata on.

Attributes:

Name Type Description
message str

A sentence describing the problem, in the document's terms.

location Location

Where it is.

observed str | None

The value found, rendered for display.

expected str | None

The value the rule would have expected, where that is meaningful.

severity Severity | None

Overrides the rule's default severity for this one finding, for rules whose seriousness depends on what they found.

fix Fix | None

What would repair it, or None where no unambiguous repair exists. There is no separate "fixable" flag to keep in step: a finding is fixable exactly when it carries a fix.

adds_content bool

Whether the eventual fix would insert or delete content rather than only change formatting. Set independently of :attr:fix, so a rule can say "repairing this would change what the document says" while leaving the repair itself unmodelled.

Location dataclass

Location(
    paragraph_index: int | None = None,
    run_index: int | None = None,
    style_id: str | None = None,
    excerpt: str = "",
)

Where a finding sits.

Every field is optional because findings are not all positional: a rule about a style definition (an unused style, two styles that resolve identically) has no paragraph to point at, and reports style_id alone.

Attributes:

Name Type Description
paragraph_index int | None

Position in the sweep's document order — the index of the :class:~docx_plus.styles.ResolvedParagraph the finding came from. Note this counts table-cell paragraphs, which doc.paragraphs omits.

run_index int | None

Position within that paragraph, for a run-level finding.

style_id str | None

The w:styleId a finding is about, when the subject is a style rather than a position.

excerpt str

A short slice of the paragraph's text, so a report reads usefully without the document open alongside it.

describe

describe() -> str

A short human-readable position, for report lines.

Source code in docx_plus/lint/models.py
def describe(self) -> str:
    """A short human-readable position, for report lines."""
    if self.paragraph_index is None:
        return f"style {self.style_id}" if self.style_id else "document"
    where = f"paragraph {self.paragraph_index}"
    if self.run_index is not None:
        where += f", run {self.run_index}"
    return where

Rule dataclass

Rule(
    id: str,
    kind: RuleKind,
    severity: Severity,
    description: str,
    check: CheckFn,
    tags: frozenset[str] = frozenset(),
    default_on: bool = True,
)

A registered rule — its metadata and the check that implements it.

Attributes:

Name Type Description
id str

Stable, kebab-case, and part of the public surface: users select and exclude by it, so it should not change once shipped.

kind RuleKind

See :data:RuleKind.

severity Severity

Default severity of the findings it emits.

description str

One line, shown by list_rules.

check CheckFn

The implementation.

tags frozenset[str]

Cluster names a user can select instead of naming ids (typography, structure, ...). Naming a tag also enables that cluster's off-by-default rules.

default_on bool

Whether the rule runs when the caller selects nothing. Unambiguous defects ship on; heuristic or opinion-flavoured rules ship off, so default output stays worth reading. Every policy rule is off, since it has no target without a profile.

matches

matches(selector: str) -> bool

True if selector names this rule, by id or by one of its tags.

Source code in docx_plus/lint/models.py
def matches(self, selector: str) -> bool:
    """True if ``selector`` names this rule, by id or by one of its tags."""
    return selector == self.id or selector in self.tags

LintContext dataclass

LintContext(
    doc: Document,
    paragraphs: list[ResolvedParagraph],
    profile: Profile = Profile(),
)

Everything a rule is given to work with.

Rules receive the whole swept document rather than one paragraph at a time, because the interesting rules are comparative — "this font is an outlier", "these two styles resolve identically", "the outline skips a level" — and none of those can be decided from a single paragraph.

Attributes:

Name Type Description
doc Document

The document, for rules that need a part the sweep does not cover (the styles element, the bookmark table).

paragraphs list[ResolvedParagraph]

Every swept paragraph, in document order, materialised so rules can walk it more than once.

profile Profile

The loaded profile, so a rule can read its own options through :meth:option. Defaults to the empty profile.

option

option(rule_id: str, key: str, default: Any) -> Any

One rule-specific value from the profile, or the rule's default.

The seam between a rule's built-in threshold and a team's override: a rule keeps its constant as the default and a profile's options block replaces it per document run. The rule validates the value; the profile only promises it is JSON.

Source code in docx_plus/lint/models.py
def option(self, rule_id: str, key: str, default: Any) -> Any:
    """One rule-specific value from the profile, or the rule's ``default``.

    The seam between a rule's built-in threshold and a team's
    override: a rule keeps its constant as the ``default`` and a
    profile's ``options`` block replaces it per document run. The
    rule validates the value; the profile only promises it is JSON.
    """
    return self.profile.option(rule_id, key, default)

resolve

resolve(
    target: Paragraph | Run | _Cell, *, stop_below: Layer | None = None
) -> ResolvedFormatting

Resolve one target the sweep did not precompute.

The sweep already carries each paragraph's and run's full resolve plus its baseline (the same target without its own direct layer), which is what nearly every rule needs. This covers the rest: a rule wanting some other slice of the cascade — the numbering a style would supply if the paragraph did not override it, say.

Deliberately not cache-shared with the sweep, so it costs a full cascade walk per call. Call it for the subset a rule genuinely needs, never for every paragraph.

Parameters:

Name Type Description Default
target Paragraph | Run | _Cell

A paragraph, run, or cell in :attr:doc.

required
stop_below Layer | None

Stop the walk below this :data:~docx_plus.styles.inspect.Layer.

None

Returns:

Type Description
ResolvedFormatting

The resolved formatting for target.

Source code in docx_plus/lint/models.py
def resolve(
    self,
    target: Paragraph | Run | _Cell,
    *,
    stop_below: Layer | None = None,
) -> ResolvedFormatting:
    """Resolve one target the sweep did not precompute.

    The sweep already carries each paragraph's and run's full resolve
    plus its ``baseline`` (the same target without its own direct
    layer), which is what nearly every rule needs. This covers the rest:
    a rule wanting some *other* slice of the cascade — the numbering a
    style would supply if the paragraph did not override it, say.

    Deliberately not cache-shared with the sweep, so it costs a full
    cascade walk per call. Call it for the subset a rule genuinely
    needs, never for every paragraph.

    Args:
        target: A paragraph, run, or cell in :attr:`doc`.
        stop_below: Stop the walk below this
            :data:`~docx_plus.styles.inspect.Layer`.

    Returns:
        The resolved formatting for ``target``.
    """
    return resolve_effective_formatting(target, stop_below=stop_below)

excerpt

excerpt(paragraph_index: int, limit: int = 60) -> str

A one-line slice of a paragraph's text, for a report.

Internal spacing is preserved, because several rules are about whitespace and an excerpt that tidied it would hide the very thing being reported — a double-space finding whose excerpt shows single spaces reads like a false positive. Tabs render as \t so they are visible at all, and line breaks collapse to a space so one finding stays one line.

Output is ASCII-only, and enforced rather than hoped for. This reaches a Windows console via docx-plus lint, where Python encodes stdout as cp1252 whenever it is redirected to a file or a pipe — so a document containing CJK text or an emoji used to end the command in an unhandled UnicodeEncodeError rather than a report. Anything outside ASCII becomes a \x/\u escape, which keeps the character visible and greppable instead of dropping it.

Truncation happens after escaping, so limit bounds the printed width rather than the source length. See :func:render_for_report, which is the same treatment for text that does not come from a paragraph.

Source code in docx_plus/lint/models.py
def excerpt(self, paragraph_index: int, limit: int = 60) -> str:
    r"""A one-line slice of a paragraph's text, for a report.

    Internal spacing is **preserved**, because several rules are about
    whitespace and an excerpt that tidied it would hide the very thing
    being reported — a `double-space` finding whose excerpt shows single
    spaces reads like a false positive. Tabs render as ``\t`` so they are
    visible at all, and line breaks collapse to a space so one finding
    stays one line.

    Output is **ASCII-only, and enforced rather than hoped for**. This
    reaches a Windows console via ``docx-plus lint``, where Python
    encodes stdout as cp1252 whenever it is redirected to a file or a
    pipe — so a document containing CJK text or an emoji used to end
    the command in an unhandled ``UnicodeEncodeError`` rather than a
    report. Anything outside ASCII becomes a ``\x``/``\u`` escape,
    which keeps the character visible and greppable instead of
    dropping it.

    Truncation happens **after** escaping, so ``limit`` bounds the
    printed width rather than the source length. See
    :func:`render_for_report`, which is the same treatment for text
    that does not come from a paragraph.
    """
    return render_for_report(self.paragraphs[paragraph_index].text, limit)

Fix dataclass

Fix(summary: str, safety: FixSafety, operations: tuple[FixOperation, ...])

What a rule would do about the thing it found.

A fix is described, never executed, in this release: lint and :func:~docx_plus.lint.plan_fixes are both pure reads. Describing it first is the point — the fix model gets designed and reviewed while nothing can yet corrupt a document.

Attributes:

Name Type Description
summary str

One line, in the document's terms: what would change.

safety FixSafety

See :data:FixSafety.

operations tuple[FixOperation, ...]

The edits, in the order they must be applied. A rule supplying more than one is asserting that order matters — a run of empty paragraphs is deleted back to front so the earlier indices stay valid.

to_dict

to_dict() -> dict[str, Any]

The serializable record for this fix.

Source code in docx_plus/lint/models.py
def to_dict(self) -> dict[str, Any]:
    """The serializable record for this fix."""
    return {
        "summary": self.summary,
        "safety": self.safety,
        "operations": [op.to_dict() for op in self.operations],
    }

FixOperation dataclass

FixOperation(op: FixOp, args: Mapping[str, Any])

One named edit, with JSON-serializable arguments.

Attributes:

Name Type Description
op FixOp

Which operation, from the :data:FixOp vocabulary.

args Mapping[str, Any]

Its arguments. Restricted to JSON types so a plan round-trips through a file.

to_dict

to_dict() -> dict[str, Any]

The serializable record for this operation.

Source code in docx_plus/lint/models.py
def to_dict(self) -> dict[str, Any]:
    """The serializable record for this operation."""
    return {"op": self.op, "args": dict(self.args)}

docx_plus.lint.profile

Profiles — the one place a house opinion is allowed to live.

The rule kinds keep the library's own opinions out of the linter: consistency and structural rules judge a document against itself, and policy rules — the ones that need somebody to say what "right" looks like — are inert without a target. A profile is where those targets come from, and the reason docx_plus can ship no house style while still being useful to a team that has one.

{
  "rules": {
    "double-space":     {"enabled": false},
    "style-drift":      {"severity": "error"},
    "font-outliers":    {"enabled": true, "options": {"max_share": 0.02}}
  }
}

options reach a rule through :meth:LintContext.option, which falls back to the rule's own built-in threshold — so a profile overrides a number a rule already has rather than supplying one it lacks. The knobs that exist today are font-outliers's max_share / max_runs and manual-heading-formatting's max_chars; no policy rule ships yet, so nothing requires an option to run.

What a profile deliberately does not do is select rules. --rule / --exclude stay the caller's, applied after the profile, so a profile never stops someone asking a specific question of a specific document.

Profile dataclass

Profile(rules: Mapping[str, RuleSettings] = dict())

A loaded lint profile.

Attributes:

Name Type Description
rules Mapping[str, RuleSettings]

Settings by rule id. A rule the profile does not mention keeps its registered behaviour, so a profile is a set of deltas rather than a replacement catalogue.

settings

settings(rule_id: str) -> RuleSettings

What this profile says about rule_id, or the empty settings.

Source code in docx_plus/lint/profile.py
def settings(self, rule_id: str) -> RuleSettings:
    """What this profile says about ``rule_id``, or the empty settings."""
    return self.rules.get(rule_id, RuleSettings())

enabled

enabled(rule_id: str, *, default: bool) -> bool

Whether rule_id runs, given its registered default.

Source code in docx_plus/lint/profile.py
def enabled(self, rule_id: str, *, default: bool) -> bool:
    """Whether ``rule_id`` runs, given its registered ``default``."""
    override = self.settings(rule_id).enabled
    return default if override is None else override

severity

severity(rule_id: str, *, default: Severity) -> Severity

The severity to report rule_id at, given its registered default.

Source code in docx_plus/lint/profile.py
def severity(self, rule_id: str, *, default: Severity) -> Severity:
    """The severity to report ``rule_id`` at, given its registered ``default``."""
    return self.settings(rule_id).severity or default

option

option(rule_id: str, key: str, default: Any = None) -> Any

One rule-specific value, or default.

The hook a rule reads its thresholds through, via :meth:LintContext.option — see the module docstring for which rules honour which keys.

Source code in docx_plus/lint/profile.py
def option(self, rule_id: str, key: str, default: Any = None) -> Any:
    """One rule-specific value, or ``default``.

    The hook a rule reads its thresholds through, via
    :meth:`LintContext.option` — see the module docstring for which
    rules honour which keys.
    """
    return self.settings(rule_id).options.get(key, default)

load classmethod

load(source: str | Path | Mapping[str, Any] | None) -> Profile

Build a profile from a path, an already-parsed mapping, or nothing.

Parameters:

Name Type Description Default
source str | Path | Mapping[str, Any] | None

A path to a JSON file, a mapping in the same shape, or None for the empty profile — which changes nothing, so every caller can pass its argument straight through without branching.

required

Returns:

Type Description
Profile

The profile.

Raises:

Type Description
InvalidProfileError

If the file is unreadable, is not JSON, or is not in the documented shape.

Example

profile = Profile.load({"rules": {"double-space": {"enabled": False}}}) profile.enabled("double-space", default=True) False profile.enabled("style-drift", default=True) True

Source code in docx_plus/lint/profile.py
@classmethod
def load(cls, source: str | Path | Mapping[str, Any] | None) -> Profile:
    """Build a profile from a path, an already-parsed mapping, or nothing.

    Args:
        source: A path to a JSON file, a mapping in the same shape, or
            ``None`` for the empty profile — which changes nothing, so
            every caller can pass its argument straight through without
            branching.

    Returns:
        The profile.

    Raises:
        InvalidProfileError: If the file is unreadable, is not JSON, or
            is not in the documented shape.

    Example:
        >>> profile = Profile.load({"rules": {"double-space": {"enabled": False}}})
        >>> profile.enabled("double-space", default=True)
        False
        >>> profile.enabled("style-drift", default=True)
        True
    """
    if source is None:
        return cls()
    if isinstance(source, str | Path):
        return cls._parse(_read(Path(source)), origin=str(source))
    return cls._parse(source, origin="the supplied mapping")

discover classmethod

discover(start: str | Path) -> Profile

Find and load :data:DEFAULT_PROFILE_NAME at or above start.

Walks up from start (a file or a directory) to the filesystem root, so running the linter from anywhere inside a project picks up the conventions checked in at its top.

Parameters:

Name Type Description Default
start str | Path

Where to begin looking.

required

Returns:

Type Description
Profile

The first profile found, or the empty profile if there is none.

Raises:

Type Description
InvalidProfileError

If a profile is found and is malformed. Silently ignoring a broken checked-in profile would be worse than not having one.

Source code in docx_plus/lint/profile.py
@classmethod
def discover(cls, start: str | Path) -> Profile:
    """Find and load :data:`DEFAULT_PROFILE_NAME` at or above ``start``.

    Walks up from ``start`` (a file or a directory) to the filesystem
    root, so running the linter from anywhere inside a project picks up
    the conventions checked in at its top.

    Args:
        start: Where to begin looking.

    Returns:
        The first profile found, or the empty profile if there is none.

    Raises:
        InvalidProfileError: If a profile is found and is malformed.
            Silently ignoring a broken checked-in profile would be
            worse than not having one.
    """
    here = Path(start).resolve()
    if here.is_file():
        here = here.parent
    for directory in (here, *here.parents):
        candidate = directory / DEFAULT_PROFILE_NAME
        if candidate.is_file():
            return cls.load(candidate)
    return cls()

RuleSettings dataclass

RuleSettings(
    enabled: bool | None = None,
    severity: Severity | None = None,
    options: Mapping[str, Any] = dict(),
)

What a profile says about one rule.

Attributes:

Name Type Description
enabled bool | None

Force the rule on or off, overriding its default_on. None leaves the default alone.

severity Severity | None

Report this rule's findings at another severity — the knob a team reaches for first, since "we treat drift as an error" is a house opinion that changes no detection.

options Mapping[str, Any]

Rule-specific values. Untouched by the loader beyond being required to be a JSON object; each rule validates its own.

InvalidProfileError

Bases: DocxPlusError, ValueError

Raised when a profile is malformed.

Loudly, and on load rather than on use: a profile with a typo in a rule id would otherwise configure nothing and read exactly like a profile that was working.

docx_plus.lint.registry

Rule registration and selection.

Rules register themselves at import time via the :func:rule decorator, so adding one is a single new function in lint/rules/ — no central list to keep in sync. :func:select_rules implements the selection semantics the CLI exposes.

UnknownRuleError

Bases: DocxPlusError, KeyError

Raised when a selector names neither a registered rule id nor a tag.

A typo in a rule name would otherwise silently select nothing, which reads exactly like a clean document.

rule

rule(
    *,
    id: str,
    kind: RuleKind,
    severity: Severity,
    description: str,
    tags: Iterable[str] = (),
    default_on: bool = True,
) -> Callable[[CheckFn], CheckFn]

Register the decorated function as a lint rule.

Parameters:

Name Type Description Default
id str

Stable kebab-case identifier; part of the public surface.

required
kind RuleKind

consistency / structural / policy.

required
severity Severity

error / warning / info.

required
description str

One line for list_rules.

required
tags Iterable[str]

Cluster names for bulk selection.

()
default_on bool

Whether it runs when nothing is selected.

True

Returns:

Type Description
Callable[[CheckFn], CheckFn]

The undecorated function, so rules stay directly callable in tests.

Raises:

Type Description
ValueError

If id is already registered.

Source code in docx_plus/lint/registry.py
def rule(
    *,
    id: str,  # noqa: A002 — "id" is the field's name in the public Finding shape
    kind: RuleKind,
    severity: Severity,
    description: str,
    tags: Iterable[str] = (),
    default_on: bool = True,
) -> Callable[[CheckFn], CheckFn]:
    """Register the decorated function as a lint rule.

    Args:
        id: Stable kebab-case identifier; part of the public surface.
        kind: ``consistency`` / ``structural`` / ``policy``.
        severity: ``error`` / ``warning`` / ``info``.
        description: One line for ``list_rules``.
        tags: Cluster names for bulk selection.
        default_on: Whether it runs when nothing is selected.

    Returns:
        The undecorated function, so rules stay directly callable in tests.

    Raises:
        ValueError: If ``id`` is already registered.
    """

    def decorate(check: CheckFn) -> CheckFn:
        if id in _REGISTRY:
            raise ValueError(f"duplicate lint rule id: {id!r}")
        _REGISTRY[id] = Rule(
            id=id,
            kind=kind,
            severity=severity,
            description=description,
            check=check,
            tags=frozenset(tags),
            default_on=default_on,
        )
        return check

    return decorate

all_rules

all_rules() -> list[Rule]

Every registered rule, sorted by id.

Source code in docx_plus/lint/registry.py
def all_rules() -> list[Rule]:
    """Every registered rule, sorted by id."""
    _load_rules()
    return sorted(_REGISTRY.values(), key=lambda r: r.id)

select_rules

select_rules(
    select: Sequence[str] | None = None,
    exclude: Sequence[str] | None = None,
    profile: Profile | None = None,
) -> list[Rule]

Resolve selectors to the rules that should run.

Selection semantics, matching the sibling wordlive linter so the two behave the same way:

  • select=None runs every rule with default_on=True, as adjusted by profile.
  • A non-empty select runs exactly what it names, including off-by-default rules and anything a profile disabled — naming a tag is how a user opts into that cluster's heuristic rules, and asking for a rule by name is not something configuration gets to veto.
  • exclude is applied last and always wins.

Parameters:

Name Type Description Default
select Sequence[str] | None

Rule ids and/or tags to run.

None
exclude Sequence[str] | None

Rule ids and/or tags to skip.

None
profile Profile | None

A loaded profile whose per-rule enabled overrides the registered default_on.

None

Returns:

Type Description
list[Rule]

The matching rules, sorted by id.

Raises:

Type Description
UnknownRuleError

If a selector, or a rule id named by profile, matches no registered rule or tag.

Source code in docx_plus/lint/registry.py
def select_rules(
    select: Sequence[str] | None = None,
    exclude: Sequence[str] | None = None,
    profile: Profile | None = None,
) -> list[Rule]:
    """Resolve selectors to the rules that should run.

    Selection semantics, matching the sibling `wordlive` linter so the two
    behave the same way:

    - ``select=None`` runs every rule with ``default_on=True``, as adjusted
      by ``profile``.
    - A non-empty ``select`` runs exactly what it names, **including
      off-by-default rules and anything a profile disabled** — naming a tag
      is how a user opts into that cluster's heuristic rules, and asking
      for a rule by name is not something configuration gets to veto.
    - ``exclude`` is applied last and always wins.

    Args:
        select: Rule ids and/or tags to run.
        exclude: Rule ids and/or tags to skip.
        profile: A loaded profile whose per-rule ``enabled`` overrides the
            registered ``default_on``.

    Returns:
        The matching rules, sorted by id.

    Raises:
        UnknownRuleError: If a selector, or a rule id named by ``profile``,
            matches no registered rule or tag.
    """
    rules = all_rules()

    if profile is not None:
        _reject_unknown_ids(profile.rules, rules)

    if select:
        _reject_unknown(select, rules)
        chosen = [r for r in rules if any(r.matches(s) for s in select)]
    elif profile is not None:
        chosen = [r for r in rules if profile.enabled(r.id, default=r.default_on)]
    else:
        chosen = [r for r in rules if r.default_on]

    if exclude:
        _reject_unknown(exclude, rules)
        chosen = [r for r in chosen if not any(r.matches(s) for s in exclude)]

    return chosen