The Foreman Documentation Contributors' Guide consolidates contribution guidelines for the Foreman Documentation project from the following sources:

  • CONTRIBUTING.md

  • Documentation AI Skills

  • Vale Rules for Foreman documentation

The guide concatenates resources from the above sources into a single guide for easy reference. When one of these resources changes, the guide is automatically updated.

1. About the Foreman Documentation Contributors' Guide

The Foreman Documentation Contributors' Guide consolidates contribution guidelines for the Foreman Documentation project from the following sources:

The guide concatenates resources from the above sources into a single guide for easy reference. When one of these resources changes, the guide is automatically updated. For more information, see the doc-Contributing/README.

Last updated: 2026-07-30


2. Contributing to Foreman Documentation

Foreman community welcomes all feedback, issues, pull requests (PRs), and reviews. Every contributor has different backgrounds, interests, and experiences with Foreman and its open source community, and they are all welcome to help improve and maintain Foreman documentation.

If you need help to get started, open an issue, ask the docs team on Matrix, or ping @docs on the Foreman Community Forum.

2.1. Contributor’s pledge

As a contributor, I will:

  • Familiarize myself with the Pull Request Checklist and Foreman documentation conventions guide.

  • Open additional issues if my contribution is incomplete.

  • Put in my best effort to ensure that my contribution does not worsen the state of any build target. For example, this might include reviewing how my changes affect upstream build targets even when working on behalf of a downstream product.

2.2. Maintainer’s pledge

As a maintainer, I will:

  • Help less experienced community members with git, Github, PRs, asciidoc, and asciidoctor and make friendly and helpful comments.

  • Only merge PRs if the Github Actions are green.

  • Try to review PRs in a timely manner.

  • Keep non-trivial PRs open for at least 24 hours (72 hours if over the weekend) to allow for input from the community. Examples of trivial PRs: Fixing a typo, fixing markup, or fixing links. Non-trivial PRs might not only benefit from additional review but they also represent an opportunity for community members to ask questions and learn.

2.3. Pull request checklists

Checklist for documentation contributions:

  • ❏ My contribution is my own work and I agree to the license of the project. See LICENSE.

  • ❏ My commits include meaningful commit messages. See seven rules for git commit messages.

  • ❏ My change does not add trailing whitespaces. Some editors can help with this. For example, VS Code has multiple settings related to handling whitespaces.

Checklist for raising PRs:

  • ❏ I re-read my work carefully before raising a PR or before marking a draft PR as ready for review. This can include using git show, viewing the diff against master or the target branch, running a local Vale check, and other methods.

  • ❏ My PR description includes a meaningful description of the changes for the community.

  • ❏ I fill out the cherry-picking list in the PR description to the best of my abilities to signify which versions my update applies to. If unsure, I let reviewers know so that they can assist.

  • ❏ Before pinging others about my PR, I await the GitHub Actions checks to see if my branch builds. If a GitHub check fails and I’m unsure how to proceed, I let reviewers know so that they can assist.

Each PR should undergo tech review. (Tech review is performed by an Engineer who did not author the PR. It can be skipped if the PR does not significantly change description of product behavior.)

  • ❏ The PR documents a recommended, user-friendly path.

  • ❏ The PR removes steps that have been made unnecessary or obsolete.

  • ❏ Any steps introduced or updated in the PR have been tested to confirm that they lead to the documented end result.

Each PR should undergo style review. (Style review is performed by a Technical Writer who did not author the PR.)

  • ❏ The PR conforms with the team’s style guidelines.

  • ❏ The PR introduces documentation that describes a user story rather than a product feature.

2.4. Additional resources


3. AI skills for contribution guidelines

The repository includes specialized skills (slash commands) for common documentation tasks. These can be invoked in AI-assisted editors like Claude Code or Cursor.

3.1. Validate against repository conventions

Overview

Foreman documentation follows specific conventions for code, attributes, conditionals, and file structure. These conventions ensure consistency across all guides and enable multi-product builds from a single source.

Code conventions:

  • Use UTF-8 character encoding in source files

  • Do not add trailing whitespace on lines or in files

  • Surround user input with underscores to indicate variable input (e.g., hammer organization create --name "My_Organization")

AsciiDoc attributes:

  • Use attributes instead of hardcoded product names: {Project} instead of Foreman, {ProjectServer} instead of Satellite Server, {SmartProxy} instead of Capsule, etc.

  • To use attributes in code blocks, add the subs="+quotes,attributes" option

  • Attributes are located in the files named attributes*.adoc in guides/common/.

Conditional content:

  • Use ifdef::[] to show content only for specific builds. For example, ifdef::katello[] to show content only for the katello build target.

  • Use ifndef::[] to hide content for specific builds For example, ifndef::satellite[] to hide content for the satellite build target.

  • Use comma for logic "or": ifdef::katello,satellite[]

  • Nest conditionals for logic "and".

File structure:

  • Follow the Modular documentation framework

  • Assemblies (assembly_*.adoc): User stories at the top of common/. Do not nest assemblies: An assembly never contains another assembly as an include. If you need to include an assembly in another assembly, include its modules directly and adjust the [leveloffset] value to show the nesting.

  • Concept modules (con_*.adoc): Explain what and why. Start with :_mod-docs-content-type: CONCEPT

  • Procedure modules (proc_*.adoc): Explain how .Start with :_mod-docs-content-type: PROCEDURE

  • Reference modules (ref_*.adoc): Tables, options, lists, and other referential material. Start with :_mod-docs-content-type: REFERENCE

  • Snippets (snip_*.adoc): Reusable text fragments without IDs

  • When unsure what a particular file type should look like, copy an existing file of the same type and use it as a template.

AI Tool Instructions
Instructions

When invoked (manually or automatically after making changes), validate modified .adoc files in guides/ against the conventions described above.

Auto-fix the following:

  • UTF-8 character encoding

  • Trailing whitespace on lines and at end of files

  • Variable input formatting

  • Missing subs="+quotes,attributes" in code blocks that use attributes

  • File naming and prefixes (con_, proc_, ref_, snip_)

  • Content type attribute on first line

  • Vale linting issues

Report only if issues were discovered (do not auto-fix):

  • Nested assemblies

  • Conditional content usage

Report final validation step:

  • Build test results


4. AI skills for style guidelines

The repository includes specialized skills (slash commands) for common documentation tasks. These can be invoked in AI-assisted editors like Claude Code or Cursor.

4.1. Abstracts

Overview

An abstract is the first paragraph of a documentation module that helps readers and AI-powered search tools find the information they need and confirm they are in the right place.

An abstract follows after the module heading and is prefixed by the [role="_abstract"] AsciiDoc tag.

Abstracts typically contain:

  • The "What" - what the content covers

  • The "Why" - why it matters or when to use it

  • Where appropriate, an example use case

Guidelines:

  • Do not simply repeat the heading of the module; build upon it

  • Avoid self-referential language (e.g., "This procedure…​", "This module…​", "This table…​")

  • Avoid feature-centric language (e.g., "This feature…​")

  • Do not use sentence fragments

  • When addressing the user, address them as "you"

  • Follow these length constraints: 50-300 characters, 1-2 sentences, a single paragraph

Module-type-specific guidelines:

AI Tool Instructions
Instructions

Review and improve the abstract for this file. Write it if it doesn’t exist yet.

When reviewing or writing the abstract, follow the guidelines in the Overview section.

For module-type-specific abstract rules, look up the reference file that matches the :_mod-docs-content-type: AsciiDoc attribute in the module (open and apply that file in full).

4.2. Headings

Overview

Headings should be clear, concise, and use familiar keywords that help users understand what the content covers.

General principles for all headings:

  • Make the heading 3-11 words long

  • Use clear headings with familiar keywords for users

  • Ensure the heading summarizes the contents of the part of the documentation it introduces

Module-type-specific principles:

  • Concepts (con_*.adoc): Do not start with a gerund or verb. Use a noun phrase and include nouns that appear in the body text.

  • Procedures (proc_*.adoc): Start the heading with a gerund (e.g., "Configuring…​", "Creating…​").

  • References (ref_*.adoc): Do not start with a gerund or verb. Include nouns that appear in the body text.

  • Assemblies containing procedures: Start the heading with a gerund.

  • Assemblies containing only concepts or references: Do not start with a gerund or verb. Use a noun phrase.

Examples

Concept headings:

  • Provisioning methods in {Project}

  • Security considerations in {Project}

  • Host groups overview

Procedure headings:

  • Deploying SSH keys during provisioning

  • Opening required ports

  • Configuring pull-based transport for remote execution

Reference headings:

  • Job template examples and extensions

  • Host parameter hierarchy

  • Operating system requirements

Assembly headings:

  • Connecting AI applications to the MCP server for {Project}

  • Configuring {SmartProxy} and hosts to authenticate with SSH certificates during remote execution

  • Content and patch management with {Project}

AI Tool Instructions
Instructions

Review and improve the heading for this file. Write it if it doesn’t exist yet.

Follow the principles outlined in the Overview section above.

Post-command cleanup

If you rename a heading, use the /refactor-adoc command to update the anchor, and filename of the module and all references and links to the module in the repository.

4.3. User personas

Overview

Foreman documentation targets different user personas with varying responsibilities and permissions. Understanding the target persona helps ensure documentation addresses the user’s needs and capabilities appropriately.

User persona:

  • Has limited permissions

  • Runs regular Foreman operations

  • Example responsibilities: managing content, provisioning hosts, managing hosts

Admin persona:

  • Has unlimited permissions, including root access to the Foreman Server

  • Example responsibilities: managing Foreman server, configuring system settings

Architect persona:

  • Has no permissions, does not perform practical administrative or management tasks

  • Example responsibilities: evaluating whether Foreman/Katello meets organizational needs, planning deployment architecture

AI Tool Instructions
Instructions

When reviewing or writing documentation, identify the target persona and ensure:

  1. The content matches the persona’s permission level and responsibilities

  2. Technical depth is appropriate for the persona’s expertise

  3. Prerequisites assume the persona’s typical environment

  4. Examples and use cases reflect the persona’s real-world scenarios

If content targets multiple personas, report this for human review and suggest how to split it.

4.4. Prerequisites

Overview

Prerequisites are a bulleted list of conditions that must be satisfied before the user starts a procedure. Only procedure modules (proc_*.adoc) can include a .Prerequisites section.

What prerequisites are:

  • Checks that are true before the user begins

  • Conditions the user must have completed beforehand

  • Items the user must have ready

  • Actions that the user, another person, or technology has completed before the user can begin

Formatting guidelines:

  • The first word of each prerequisite must be capitalized

  • Each prerequisite must start with a bullet point

  • Prerequisites must not use imperative voice

  • If prerequisites are full sentences, end all with a period

  • If prerequisites are sentence fragments, do not use any end punctuation

  • Use passive voice for prerequisites representing an action not completed by the current user

  • Prerequisites must use parallel language (all sentences or all fragments, not mixed)

Content guidelines:

  • Focus on relevant prerequisites that users might not otherwise be aware of

  • Do not list obvious prerequisites

  • Do not include procedure steps in prerequisites

  • Do not exceed 10 prerequisites

Examples

Good prerequisites:

  • The kernelcare package is installed on your hosts.

  • The base system of the {SmartProxy} is registered to the newly upgraded {ProjectServer}.

  • Your user account has a role that grants the view_policies permission.

  • You are logged in to the registry.redhat.io container registry.

  • If you use dzdo for Ansible jobs, the community.general Ansible collection must be installed.

Bad prerequisites (and why):

  • You are logged in. (Obvious prerequisite)

  • The host is registered to {Project}. (Obvious prerequisite)

  • At least one host exists in {Project}. (Obvious prerequisite)

  • Ensure the kernelcare package is installed on your hosts. (Uses imperative voice)

  • Install the kernelcare package on your hosts. (Phrased like a procedure step)

AI Tool Instructions
Instructions
  1. Only process procedure modules (proc_*.adoc). These are the only modules that can include a .Prerequisites section.

  2. If a section titled .Prerequisites exists in the file, ensure it uses consistent formatting as described in the Overview.

  3. If a section titled .Prerequisites exists in the file, ensure it contains information suitable for prerequisites.

  4. If a procedure module does not include a .Prerequisites section, scan the module to identify steps that meet criteria for prerequisites. If such steps exist, create a .Prerequisites section and rephrase the steps as prerequisites.


5. AI skills for structure

The repository includes specialized skills (slash commands) for common documentation tasks. These can be invoked in AI-assisted editors like Claude Code or Cursor.

5.1. An assembly covers a single user story

Overview

An assembly must cover a single user story - what the user wants to accomplish. When an assembly attempts to describe multiple user stories, it often loses focus, becomes too long, and can be confusing to follow.

Usage:

/review-assembly-user-story @assembly_example.adoc

The one-user-story principle:

  • Each assembly should answer "What does the user want to accomplish?"

  • All modules in an assembly should support the same goal

  • Think from the user’s perspective, not the product’s features

When an assembly needs splitting:

  • It contains procedures for multiple independent tasks that users might want to do separately

  • The concept module describes multiple distinct use cases or workflows

  • Different sections serve different user goals

  • Modules could be logically grouped into two or more cohesive assemblies

When an assembly is fine:

  • All procedures work toward a single user goal

  • Procedures are sequential steps or alternative approaches for the same task

  • Modules provide context, reference, or examples for one user story

Important considerations:

  • Inverse operations belong together - Creating and deleting, or enabling and disabling, are part of the same user story

  • Alternative methods belong together - Multiple procedures for the same task (web UI, CLI, API) belong in the same assembly

  • Sequential workflows belong together - If steps must be done in order for one goal, they belong in the same assembly

Examples

Good examples (single user story):

  • assembly_backing-up-server-and-proxy.adoc - User wants to back up their system

  • assembly_configuring-email-notifications.adoc - User wants to set up email notifications

  • assembly_managing-organizations.adoc - User wants to create and delete organizations

AI Tool Instructions
Instructions

Review the assembly file to determine if it follows the one-user-story principle. Each assembly must describe a single user story.

Step 1: Analyze the assembly

Read the assembly file and:

  1. Identify the primary user story - What is the main goal or task the user wants to accomplish?

  2. Check the introductory concept module (the first include in the assembly) - Does it describe a single focused topic?

  3. Review included modules - Do they all support the same user story, or do they describe separate independent tasks?

  4. Look for clear boundaries - Are there sections that could stand alone as separate user stories?

Step 2: Determine if splitting is needed

The assembly needs splitting if:

  • It contains procedures for multiple independent tasks that users might want to do separately

  • The concept module describes multiple distinct use cases or workflows

  • Different sections serve different user goals (e.g., "creating/deleting" vs "configuring" vs "monitoring")

  • Modules could be logically grouped into 2 or more cohesive assemblies

Note
Inverse operations (creating/deleting, enabling/disabling) belong together in the same assembly.

The assembly is fine if:

  • All procedures work toward a single user goal

  • Procedures are sequential steps or alternative approaches for the same task

  • Modules provide context, reference, or examples for one user story

Step 3: If splitting is needed

For each identified user story:

  1. Create a new assembly file - Name it assembly_<descriptive-name>.adoc

  2. Include relevant modules - Copy the appropriate module includes for this user story

  3. Create or update the introductory concept module - Write a focused concept module with an abstract for this specific user story if needed

  4. Update parent files - Replace the original assembly include with includes for all new assemblies in the master.adoc file

  5. Delete the original assembly - Once all content is split and includes are updated

Step 4: Report findings

Provide a summary that includes:

  • Whether the assembly follows the one-user-story principle

  • If splitting occurred, list the new assembly files created and their user stories

  • Any recommendations for improving the assembly structure

Principles
  • One user story per assembly - Each assembly should answer "What does the user want to accomplish?"

  • User-centric organization - Think from the user’s perspective, not the product’s features

  • Cohesive modules - All modules in an assembly should support the same goal


6. AI skills for file management

The repository includes specialized skills (slash commands) for common documentation tasks. These can be invoked in AI-assisted editors like Claude Code or Cursor.

6.1. Rename a module

Overview

This command changes the title, ID, and filename of an assembly, module, or snippet according to a new title, and updates all includes and ID references across the documentation.

Usage:

/refactor-adoc @old-file.adoc "New title"

What it does:

  • Renames the file with the appropriate prefix (e.g., proc_new-title.adoc)

  • Updates the ID if present (e.g., [id="new-title"])

  • Updates the title if present (e.g., = New title)

  • Updates all include:: directives that reference this file

  • Updates all cross-references to the old ID throughout the documentation

When to use:

  • When you need to rename a documentation file to better match its content

  • After significantly revising the content of a module

  • When improving heading clarity requires filename changes

AI Tool Instructions
Instructions

Refactor the following .adoc file according to the following title. Follow these principles:

  • Rename the file as prefix_new-title.adoc.

  • Change the ID of the file as [id="new-title"] if the file contains an ID.

  • Change the title of the file as = New title if the file contains a title.

  • Update all includes of that file in all other .adoc files.

  • If the original file contains an ID, update all occurrences of the ID in all other .adoc files. If the old ID contains _{context}, the tail of the occurrence after _ is a variable string.


7. Foreman-documentation custom Vale rules

The following custom Vale rules enforce documentation standards specific to Foreman. Run the Vale linter locally or in CI to check AsciiDoc files against these rules.

7.1. AbstractLength

Keep abstract paragraphs between 50 and 300 characters. Abstracts shorter than 50 characters lack sufficient context, while those longer than 300 characters become difficult to scan quickly.

Rule Definition
---
extends: script
message: "Abstract must be between 50 and 300 characters. Place any information that exceeds 300 characters in a new paragraph or paragraphs. It cannot be part of the abstract."
level: error
scope: raw
script: |
  // Import required Tengo modules
  text := import("text")  // Text manipulation functions
  fmt := import("fmt")    // String formatting functions

  // Initialize empty array to store rule violations
  matches := []

  // Compile regex to find the abstract paragraph in AsciiDoc
  // Each module contains exactly one abstract (a single paragraph following [role="_abstract"])
  // Pattern breakdown:
  //   \[role="_abstract"\]  - Matches the abstract role marker
  //   \s*\n                 - Matches optional whitespace and newline after marker
  //   ([\s\S]+?)            - Captures the abstract paragraph (non-greedy, including newlines)
  //   \n\n                  - Matches double newline (blank line) that ends paragraph
  // Note: Using [\s\S] instead of . to match across multiple lines
  abstract_regex := text.re_compile(`\[role="_abstract"\]\s*\n([\s\S]+?)\n\n`)

  // Find the abstract (there's only one per module)
  // scope = the full document text being checked
  // 1 = find only first match (since there's only one abstract per module)
  result := abstract_regex.find(scope, 1)

  // Check if an abstract was found (find returns undefined if no match)
  if result != undefined && len(result) > 0 && len(result[0]) > 1 {
    // result[0][0] = full match including [role="_abstract"] marker
    // result[0][1] = captured group (just the abstract paragraph text)
    abstract_text := result[0][1].text

    // Count characters in the abstract
    char_count := len(abstract_text)

    // Check if character count is outside valid range (50-300)
    if char_count < 50 || char_count > 300 {
      // Build custom error message with actual character count
      msg := fmt.sprintf("Abstract must be between 50 and 300 characters (currently %d characters).", char_count)

      // Add this violation to the matches array
      matches = append(matches, {
        begin: result[0][1].begin,    // Start position of abstract paragraph
        end: result[0][1].end,        // End position of abstract paragraph
        message: msg                  // Custom error message with character count
      })
    }
  }

7.2. AvoidBrandedTerminology

Do not use branded terms in documentation. Use AsciiDoc attributes like {Project}, {ProjectServer}, and {SmartProxy} instead of product-specific names like "Satellite", "orcharhino", or "Capsule". This ensures the documentation works for all build targets.

Rule Definition
---
extends: substitution
level: error
message: "Use attribute '%s' instead of a branded term."
action:
  name: replace
swap:
  "(?:orcharhino|Satellite) Server": "{ProjectServer}"
  "(?:orcharhino Proxy|Capsule)": "{SmartProxy}"
  "(?:orcharhino Proxy|Capsule) Server": "{SmartProxyServer}"
  "Red Hat Satellite": "{ProjectName}"
  "(?:orcharhino|Satellite)": "{Project}"

7.3. Capitalization

Use proper capitalization for product names and technical terms. For example, write "Ansible" not "ansible", "PostgreSQL" not "postgresql", and "lifecycle environment" not "Lifecycle Environment". Different capitalization may be required in specific contexts like Web UI buttons, settings, or templates.

Rule Definition
---
extends: substitution
ignorecase: false
level: error
message: "Use '%s' instead of '%s' unless you are using the term in a context that requires different capitalization. For example, different capitalization might be required in Web UI buttons, settings, or templates."
action:
  name: replace
swap:
  ansible: Ansible
  candlepin: candlepin
  chef: Chef
  discovery: Discovery
  enterprise linux: Enterprise Linux
  hammer: Hammer
  katello: Katello
  library: Library
  openscap: OpenSCAP
  postgresql: PostgreSQL
  pulp: Pulp
  python: Python
  salt: Salt
  salt minions: Salt Minions
  secure boot: Secure Boot
  subscription manager: Subscription Manager
  tracer: Tracer
  Lifecycle Environment: lifecycle environment
  '(?<!Pulp )Manifest': manifest
  Pulp manifest: Pulp Manifest
  Remote Execution: remote execution
  '(?<=\s)Subscription(?! Manager)': subscription
  Red Hat network: Red{nbsp}Hat Network

Do not include links or cross references in the main body of a concept module. In concept modules, links are allowed only in the Additional resources section. Based on ConceptLink.yml from https://github.com/jhradilek/asciidoctor-dita-vale.

Rule Definition
---
extends: script
message: "To improve content usability and user focus, links and cross references in the main body of a concept module are discouraged. Move them to the Additional resources section."
level: warning
scope: raw
script: |
  text              := import("text")
  matches           := []

  r_add_resources   := text.re_compile("^(?:={2,}[ \\t]+|\\.{1,2})Additional resources[ \\t]*$")
  r_any_title       := text.re_compile("^(?:={2,}[ \\t]+|\\.{1,2})[^ \\t.].*$")
  r_attr_link       := text.re_compile("\\{[0-9A-Za-z_][0-9A-Za-z_-]*URL\\}[^\\[\\n]*\\[[^\\]]*\\]")
  r_code_block      := text.re_compile("^(?:\\.{4,}|-{4,})[ \\t]*$")
  r_comment_block   := text.re_compile("^/{4,}\\s*$")
  r_comment_line    := text.re_compile("^(//|//[^/].*)$")
  r_content_type    := text.re_compile("^:_(?:mod-docs-content|content|module)-type:[ \\t]+(?i:assembly|concept)")

  document          := text.split(text.trim_suffix(scope, "\n"), "\n")

  is_relevant       := false
  in_code_block     := false
  in_comment_block  := false
  in_add_resources  := false
  start             := 0
  end               := 0

  for line in document {
    start += end
    end    = len(line) + 1

    if r_comment_block.match(line) {
      delimiter := text.trim_space(line)
      if ! in_comment_block {
        in_comment_block = delimiter
      } else if in_comment_block == delimiter {
        in_comment_block = false
      }
      continue
    }
    if in_comment_block { continue }
    if r_comment_line.match(line) { continue }

    if r_code_block.match(line) {
      delimiter := text.trim_space(line)
      if ! in_code_block {
        in_code_block = delimiter
      } else if in_code_block == delimiter {
        in_code_block = false
      }
      continue
    }
    if in_code_block { continue }

    if r_content_type.match(line) {
      is_relevant = true
      continue
    }

    if r_add_resources.match(line) {
      in_add_resources = true
      continue
    }

    if r_any_title.match(line) {
      in_add_resources = false
      continue
    }

    if in_add_resources { continue }

    for i, entry in r_attr_link.find(line, -1) {
      link := entry[0]
      matches = append(matches, {begin: start + link.begin, end: start + link.end - 1})
    }
  }

  if ! is_relevant {
    matches = []
  }

7.5. HeadingWordCount

Check that module headings have between 3 and 11 words. A module has a single heading (the line starting with =). Note: AsciiDoc attributes like {Project} count as one word each.

Rule Definition
---
extends: script
message: "Heading must have between 3 and 11 words."
level: error
scope: raw
script: |
  // Import required Tengo modules
  text := import("text")  // Text manipulation functions
  fmt := import("fmt")    // String formatting functions

  // Initialize empty array to store rule violations
  matches := []

  // Compile regex to find the module heading
  // Each module contains exactly one heading (line starting with = )
  // Pattern breakdown:
  //   (?m)        - Multi-line mode (^ and $ match line boundaries, not just string boundaries)
  //   ^=[ \t]+    - Matches line starting with = followed by whitespace
  //   (.+)$       - Captures the heading text to end of line
  heading_regex := text.re_compile(`(?m)^=[ ]+(.+)$`)

  // Find the heading (there's only one per module)
  // scope = the full document text being checked
  // 1 = find only first match (since there's only one heading per module)
  result := heading_regex.find(scope, 1)

  // Check if a heading was found (find returns undefined if no match)
  if result != undefined && len(result) > 0 && len(result[0]) > 1 {
    // result[0][1] = captured group (just the heading text)
    heading_text := result[0][1].text

    // Count words including AsciiDoc attributes as words
    // Match either {AttributeName} or regular words
    word_regex := text.re_compile(`\{[A-Za-z][A-Za-z0-9_-]*\}|\b\w+\b`)
    words := word_regex.find(heading_text, -1)

    // Get word count (0 if no words found)
    word_count := 0
    if words != undefined {
      word_count = len(words)
    }

    // Check if word count is outside valid range (3-11)
    if word_count < 3 || word_count > 11 {
      msg := fmt.sprintf("Heading must have between 3 and 11 words (currently %d words).", word_count)
      matches = append(matches, {
        begin: result[0][1].begin,
        end: result[0][1].end,
        message: msg
      })
    }
  }

7.6. OneSentencePerLine

Write one sentence per line. This makes diffs easier to read, simplifies reviewing changes, and helps track modifications to individual sentences. End each sentence with proper punctuation (period, question mark, or exclamation point) before starting a new line.

Rule Definition
---
extends: existence
message: "Write one sentence per line."
level: error
scope: raw
nonword: true
tokens:
  # Match: word/)/]/quote + sentence-end punctuation + space/tab + capital letter/[
  # Using [ \t] instead of \s to match only horizontal whitespace, not newlines
  - '[a-z0-9\)\]"][.!?][ \t]+[A-Z\[]'

7.7. ProcedureStepLimit

Limit procedures to a maximum of 10 steps. Procedures with too many steps are difficult to follow.

Rule Definition
---
extends: script
message: "Procedure contains more than 10 steps. Split it into multiple procedures or leverage substeps to group related steps. If the procedure contains conditionals, review the preview to ensure all built targets comply with the limit of 10 steps."
level: error
scope: raw
script: |
  text := import("text")
  matches := []
  lines := text.split(scope, "\n")
  count := 0
  step_positions := []
  position := 0
  in_procedure := false

  for line in lines {
    // Detect .Procedure heading (only one per file)
    if text.re_match("^\\.Procedure", line) {
      in_procedure = true
    }
    // Reset when we encounter another heading at the same level
    if text.re_match("^\\.[A-Z]", line) && !text.re_match("^\\.Procedure", line) {
      in_procedure = false
    }
    // Count steps only when inside a .Procedure section
    if in_procedure && text.re_match("^\\. [^\\.]", line) {
      count += 1
      step_positions = append(step_positions, position)
    }
    position += len(line) + 1
  }

  if count > 10 {
    // Report error on ALL steps to ensure it appears in GitHub Actions diff context
    // when ANY step is modified in a procedure that exceeds the 10-step limit
    for i := 0; i < count; i++ {
      step_pos := step_positions[i]
      matches = append(matches, {begin: step_pos, end: step_pos + 1})
    }
  }