Skip to content

ci(linter): check that every "step N" citation resolves - #36

Merged
eilandert merged 2 commits into
mainfrom
ci/lint-prompt-steps
Aug 13, 2026
Merged

eilandert merged 2 commits into
mainfrom
ci/lint-prompt-steps

Conversation

@eilandert

Copy link
Copy Markdown
Member

Backlog item picked up after #35. ci/PROMPT.md was rewritten by ~120 lines in that PR and nothing in the tree could check whether its cross-references survived.

TL;DR

The long instruction document at the heart of this repo refers to its own numbered steps constantly, and other files refer to them too. Until now nothing checked that those numbers still pointed at anything, so renaming or renumbering a step quietly broke every reference to it and no tool would notice. This adds a linter that does check.

Adds ci/linter/lint-prompt-steps.{sh,py}: every step N citation must name a step ci/PROMPT.md defines, and the step sequence itself must have no gap or duplicate.

Severity: Low (documentation integrity — silent cross-reference rot in the adoption spec)

Why this gap is invisible

ci/PROMPT.md is 2134 lines and is the specification nine sibling modules are rolled out against. Its steps are addressed by number — 118 citations inside the file, plus README.md, ci/feedback/README.md, ci/tools/test_runtime.py, ci/linter/selftest.sh and ci/linter/fixture-selector.py.

Renumbering a step, deleting one, or rewriting a block that cited one produces no error anywhere. The reader who follows a dangling see step 27 lands on the wrong instruction, or on nothing.

What is checked

Two directions, both silent on their own:

  • a citation naming a step that does not exist — the renumber/deletion case;
  • a gap or duplicate in the step sequence, which is how a half-finished renumber leaves the file and how two headings end up answering to one number.

Whether a citation points at the right step is deliberately not checked. That needs a human, and a check pretending to do it would be false comfort.

Missing step headings exit 2 ("could not run"), not 0 and not a flood of findings against an empty step set — rewrite the heading convention and a naive checker reports every citation in the repo, which reads as noise and gets the gate deleted.

Testing

The tree is currently clean at 42 defined steps, all citations resolving. That proves nothing on its own — a checker that never finds anything and one that cannot find anything print the same line — so selftest.sh gains five controls against a generated tree: dangling citation, the passing-direction pair, sequence gap, duplicate number, and headings gone.

Fixtures are generated rather than committed: a fixture PROMPT.md carrying a deliberate defect is a 2100-line near-duplicate of the real spec that nobody would remember to leave broken.

ci/linter/run-all.sh and ci/linter/selftest.sh both pass locally.

One thing the checker found in its own PR

The reproduce recipe added to ci/linter/README.md contains a literal step 99, and the checker read it as a real dangling citation. ci/linter/README.md writes about citations rather than making them, so it joins the checker's own source in the skip list. The skip list stays scoped to ci/linter/; a genuine dangling citation in README.md, ci/feedback/ or ci/tools/ is exactly what this is for.

Also

prompt-steps is added to lint.yml's LINT_ONLY. The existing set-equality control in selftest.sh caught its absence — the "runs locally and in the hook but never on a PR" case that control exists for.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@eilandert, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 103 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: aac5cf49-abb8-477f-81aa-4d5f1d319347

📥 Commits

Reviewing files that changed from the base of the PR and between 32f9823 and 20ce29a.

📒 Files selected for processing (3)
  • ci/linter/lint-prompt-steps.py
  • ci/linter/lint-prompt-steps.sh
  • ci/linter/selftest.sh

Walkthrough

The PR adds a prompt-step citation linter, integrates it into the CI lint workflow, documents its checks, and adds generated-fixture self-tests for valid and invalid step references.

Changes

Prompt-step linting

Layer / File(s) Summary
Prompt-step validator
ci/linter/lint-prompt-steps.py
The validator checks numbered headings, duplicate or missing steps, unresolved citations, unreadable prompts, and malformed headings.
CI checker integration
ci/linter/lint-prompt-steps.sh, .github/workflows/lint.yml
The wrapper filters relevant changes, checks Python 3, and runs whole-tree validation. CI enables the prompt-steps checker and updates its synchronization stamp.
Regression controls and documentation
ci/linter/selftest.sh, ci/linter/README.md
Self-tests cover valid citations, dangling citations, missing and duplicate steps, and invalid headings. Documentation describes the checker and verification commands.

Sequence Diagram(s)

sequenceDiagram
  participant CI as CI lint workflow
  participant Wrapper as lint-prompt-steps.sh
  participant Validator as lint-prompt-steps.py
  participant Repository as Repository files
  CI->>Wrapper: run prompt-steps checker
  Wrapper->>Validator: invoke whole-tree validation
  Validator->>Repository: read prompt headings and citations
  Repository-->>Validator: return step definitions and references
  Validator-->>Wrapper: return validation status
  Wrapper-->>CI: report checker result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: validating that every "step N" citation resolves.
Description check ✅ Passed The description directly explains the new linter, its validation rules, tests, and CI integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/lint-prompt-steps
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch ci/lint-prompt-steps

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ci/PROMPT.md is 2100+ lines and is the specification nine sibling
modules are rolled out against. Its steps are addressed by number from
inside the file and from README.md, ci/feedback/, ci/tools/ and
ci/linter/ -- 118 citations in PROMPT.md alone. Nothing in the tree
could tell whether those numbers still resolved, so renumbering a step,
deleting one, or rewriting a block that cited one was a silent edit: the
reader following a dangling "see step 27" lands on the wrong instruction
with no error anywhere. The previous session rewrote ~120 lines of it and
had no way to check the citations survived.

Two directions, both silent on their own:

  * a citation naming a step that does not exist -- renumber/deletion;
  * a gap or duplicate in the step sequence itself, which is how a
    half-finished renumber leaves the file and how two headings end up
    answering to one number.

Whether a citation points at the RIGHT step is deliberately not checked;
that needs a human, and a check pretending to do it is false comfort.

No recognisable step heading is exit 2, not a clean pass and not a flood
of findings against an empty step set: rewrite the headings and a naive
checker reports every citation in the repo, which reads as noise and gets
the gate deleted.

The tree is currently clean at 42 steps -- which proves nothing on its
own, so selftest.sh gains five controls against a generated tree
(dangling, the passing-direction pair, gap, duplicate, headings gone).
Generated rather than committed: a fixture PROMPT.md is a 2100-line
near-duplicate nobody would remember to leave broken.

Also adds prompt-steps to lint.yml's LINT_ONLY -- the set-equality
control caught its absence, which is exactly the "runs locally and in the
hook but never on a PR" case that control exists for.
@eilandert
eilandert force-pushed the ci/lint-prompt-steps branch from 32f9823 to db71a70 Compare August 13, 2026 13:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3f1ea7a1-dd18-44f0-87ee-c03038473100

📥 Commits

Reviewing files that changed from the base of the PR and between 6512069 and 32f9823.

📒 Files selected for processing (5)
  • .github/workflows/lint.yml
  • ci/linter/README.md
  • ci/linter/lint-prompt-steps.py
  • ci/linter/lint-prompt-steps.sh
  • ci/linter/selftest.sh

Comment thread ci/linter/lint-prompt-steps.py
return [n for n in names if CITERS_RE.match(n)]


def main():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the repository ruff configuration for PL rule selection
# and formatter quote style, and compare with a sibling Python file.
set -uo pipefail

fd -H -t f -g 'ruff.toml' -g '.ruff.toml' -g 'pyproject.toml' -g 'setup.cfg' . \
  --exec sh -c 'echo "== $1"; cat -n "$1"' _ {}

echo "== quoting style used by sibling checker"
fd -t f 'workflow_policy.py' --exec sh -c 'rg -n -m 20 "^(import|from|[A-Z_]+ =)" "$1"' _ {}

Repository: myguard-labs/nginx-skeleton-module

Length of output: 998


🏁 Script executed:

# Find ruff configuration files
fd -H -t f '(ruff\.toml|pyproject\.toml|setup\.cfg)$' . | head -20

Repository: myguard-labs/nginx-skeleton-module

Length of output: 172


🏁 Script executed:

# Read the target file
cat -n ci/linter/lint-prompt-steps.py

Repository: myguard-labs/nginx-skeleton-module

Length of output: 8428


🏁 Script executed:

# Check lint-python.sh to see how ruff is configured
cat -n ci/linter/lint-python.sh

Repository: myguard-labs/nginx-skeleton-module

Length of output: 2075


🏁 Script executed:

# Search for pyproject.toml or any ruff config
find . -maxdepth 3 -name "pyproject.toml" -o -name "setup.cfg" -o -name "ruff.toml"

Repository: myguard-labs/nginx-skeleton-module

Length of output: 172


🏁 Script executed:

# Check if ci/linter/lint-python.sh actually runs on this file
git ls-files '*.py' | grep -E '(ci/linter|ci/tools)' || echo "No Python files found in ci/linter or ci/tools"

Repository: myguard-labs/nginx-skeleton-module

Length of output: 287


🏁 Script executed:

# Use Python to count branches in main() function according to PLR0912 logic
python3 - <<'PY'
import re

# Read the file
with open('ci/linter/lint-prompt-steps.py', 'r') as f:
    content = f.read()

# Extract main() function
main_match = re.search(r'def main\(\):(.*?)(?=\ndef |\Z)', content, re.DOTALL)
if main_match:
    main_body = main_match.group(1)
    
    # Count branches (simplified PLR0912 logic)
    # Branches include: if, elif, else, for, while, try, except, with, and operators
    branch_count = 0
    
    # Count each type of branch
    if_count = len(re.findall(r'^\s+if\s+', main_body, re.MULTILINE))
    elif_count = len(re.findall(r'^\s+elif\s+', main_body, re.MULTILINE))
    else_count = len(re.findall(r'^\s+else\s*:', main_body, re.MULTILINE))
    for_count = len(re.findall(r'^\s+for\s+\w+\s+in\s+', main_body, re.MULTILINE))
    try_count = len(re.findall(r'^\s+try\s*:', main_body, re.MULTILINE))
    except_count = len(re.findall(r'^\s+except\s+', main_body, re.MULTILINE))
    
    print(f"if: {if_count}")
    print(f"elif: {elif_count}")
    print(f"else: {else_count}")
    print(f"for: {for_count}")
    print(f"try: {try_count}")
    print(f"except: {except_count}")
    print(f"Total: {if_count + elif_count + else_count + for_count + try_count + except_count}")
    
    # Also check for ternary/conditional expressions
    ternary_count = len(re.findall(r'\s+if\s+.*else', main_body))
    print(f"Conditional expressions: {ternary_count}")

PY

Repository: myguard-labs/nginx-skeleton-module

Length of output: 255


The repository ruff configuration does not explicitly set select, ignore, or quote-style options, so lint-python.sh will apply ruff defaults.

The main() function has 15 branches (8 if, 3 for, 2 try, 2 except statements), which exceeds the PLR0912 limit of 12. Since default ruff includes PL (Pylint) rules, the lint job will fail with PLR0912 unless the rule is disabled in configuration.

Additionally, this file uses single-quoted strings throughout, but ruff format by default rewrites single quotes to double quotes. The lint job will fail on the format check unless quote-style is set to single or preserve in the ruff configuration.

Either configure ruff in pyproject.toml (or equivalent) at the repository root to disable PLR0912 and set quote-style = "single", or refactor main() to reduce branching below the limit and reformat strings to double quotes before merge.

🧰 Tools
🪛 ast-grep (0.45.1)

[error] 86-88: Command coming from incoming request
Context: subprocess.run(
['git', 'rev-parse', '--show-toplevel'],
check=True, capture_output=True, text=True)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)

🪛 Ruff (0.16.1)

[warning] 86-86: Too many branches (13 > 12)

(PLR0912)

Source: Linters/SAST tools

Comment thread ci/linter/lint-prompt-steps.py Outdated
Comment thread ci/linter/lint-prompt-steps.sh Outdated
Comment thread ci/linter/selftest.sh
Comment thread ci/linter/selftest.sh
Comment on lines +321 to +325
# A hole in the sequence -- how a half-finished renumber leaves the file.
printf '## 1 — First\n## 3 — Third\n' > "$promptroot/ci/PROMPT.md"
case_ 1 "prompt-steps: a gap in the step sequence is a finding" \
env PROMPT_STEPS_ROOT="$promptroot" \
python3 ci/linter/lint-prompt-steps.py

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Isolate the gap control from the dangling-citation path.

The fixture now defines steps 1 and 3, but README.md still carries See step 2 from line 316. Step 2 is undefined in this fixture, so the run exits 1 through the dangling-citation branch as well as the gap branch. Remove the gap check from the Python file and this control still reports ok. That is the one failure mode a negative control must not have, and the surrounding comments show you care about exactly this distinction.

Point the citation at a step that both fixtures define.

💚 Proposed fix so only the gap can trigger the finding
 # A hole in the sequence -- how a half-finished renumber leaves the file.
+# The citation must name a step that stays defined, or the dangling-citation
+# path alone would carry this control.
+printf 'See step 1 for details.\n' > "$promptroot/README.md"
 printf '## 1 — First\n## 3 — Third\n' > "$promptroot/ci/PROMPT.md"
 case_ 1 "prompt-steps: a gap in the step sequence is a finding" \
     env PROMPT_STEPS_ROOT="$promptroot" \
     python3 ci/linter/lint-prompt-steps.py
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# A hole in the sequence -- how a half-finished renumber leaves the file.
printf '## 1 — First\n## 3 — Third\n' > "$promptroot/ci/PROMPT.md"
case_ 1 "prompt-steps: a gap in the step sequence is a finding" \
env PROMPT_STEPS_ROOT="$promptroot" \
python3 ci/linter/lint-prompt-steps.py
# A hole in the sequence -- how a half-finished renumber leaves the file.
# The citation must name a step that stays defined, or the dangling-citation
# path alone would carry this control.
printf 'See step 1 for details.\n' > "$promptroot/README.md"
printf '## 1 — First\n## 3 — Third\n' > "$promptroot/ci/PROMPT.md"
case_ 1 "prompt-steps: a gap in the step sequence is a finding" \
env PROMPT_STEPS_ROOT="$promptroot" \
python3 ci/linter/lint-prompt-steps.py

From CodeRabbit review on #36. Four confirmed, one refuted.

The gap control was FALSE. Its fixture defines steps 1 and 3 while
README.md still carried "See step 2" from the positive control above it,
so the run exited 1 through the dangling-citation branch as well as the
gap branch. Verified by mutation: deleting the gap check outright still
printed ok. The citation is now pinned to step 1, which every fixture
defines, so each sequence control can only go red through the branch it
names -- and the same mutation now flips it to exit 0, failing the
assertion as a control should.

git rev-parse escaping as CalledProcessError printed a traceback and
exited 1. The docstring promises 2 for "could not run", and 1 is the
status that means "findings", so a missing git or a run from outside a
checkout read as a defect in PROMPT.md. Now returns 2 with a message.

GIT_DIR/GIT_WORK_TREE are exported by every git hook and win over cwd, so
`git ls-files` answered for the checkout whatever directory it was
pointed at -- the selftest's generated root would have been listed as the
real repo, the os.walk fallback would never have run, and the controls
would have asserted against the wrong tree while still printing ok.
Stripped for both git calls.

The trigger pattern was a character-for-character copy of CITERS_RE,
which the module's docstring tells the next maintainer to extend. The
wrapper now reads it back via --citers-re; a stale copy would leave the
gate quiet under LINT_MODE=staged for exactly the path someone had just
added it for.

selftest.sh cleaned $promptroot on the happy path only, and the later
`trap ... EXIT` for $badroot would have REPLACED any trap set for it
rather than adding to it. One trap now covers both.

Refuted: PLR0912 on main() and a single-quote format failure. The repo
pins no ruff config, so ruff defaults apply -- and the default select is
E4,E7,E9,F, which does not include PL. `ruff check` and `ruff format
--check` both pass on the file, and the Lint job was green on the run
that review read.
@eilandert

Copy link
Copy Markdown
Member Author

Five confirmed and fixed in 20ce29a, one refuted.

The gap control was false — this is the one that mattered. The fixture defines steps 1 and 3 while README.md still carried See step 2 from the positive control above it, so the run exited 1 through the dangling-citation branch as well as the gap branch. Confirmed by mutation: replacing the gap computation with gaps = [] and running the control's own fixture still exited 1, so deleting the gap check outright would have kept printing ok. The citation is now pinned to step 1, which every fixture defines. Re-running the same mutation against the fixed fixture exits 0, so the case_ 1 assertion now fails when the check is removed — which is what the control is for. A negative control that cannot detect its own disarming was the exact defect this file exists to catch, so thanks for catching it in the file that preaches it.

git rev-parse broke the documented contract. CalledProcessError escaped, printing a traceback and exiting 1 — and 1 is the status that means "findings", so a missing git or a run from outside a checkout read as a defect in PROMPT.md. Now returns 2 with a message; verified by running from /tmp.

GIT_DIR was a real hazard. Every git hook exports it and it wins over cwd, so git ls-files answered for the checkout regardless of the directory passed. The generated selftest root would have been listed as the real repo, the os.walk fallback would never have run, and the controls would have asserted against the wrong tree while still printing ok. GIT_DIR, GIT_WORK_TREE and GIT_INDEX_FILE are now stripped for both git calls; verified by re-running a control with GIT_DIR/GIT_WORK_TREE pointed at the real checkout.

The duplicated pattern is now read back from the module via --citers-re. Your reasoning was the deciding factor: the docstring tells the next maintainer to extend CITERS_RE, and a stale copy in the wrapper would leave the gate quiet under LINT_MODE=staged for exactly the path someone had just added it for.

The trap was worse than described. trap ... EXIT replaces rather than appends, so the existing $badroot trap further down would have discarded any trap set for $promptroot. One trap now covers both.


Refuted: PLR0912 and the quote-style format failure.

The premise is that "default ruff includes PL (Pylint) rules". It does not — ruff's default select is E4,E7,E9,F. PLR0912 fires only when explicitly selected:

$ ruff check ci/linter/lint-prompt-steps.py
All checks passed!
$ ruff check --select PLR0912 ci/linter/lint-prompt-steps.py
Found 1 error.

The repo pins no ruff config, so the defaults apply and ci/linter/lint-python.sh runs bare ruff check / ruff format --check. The quote-style half was already moot: the file is double-quoted and ruff format --check reports 1 file already formatted. The Lint job was green on the run the review read (31704922183), which is the direct evidence.

main()'s branch count is a fair maintainability observation on its own terms — but it is not a CI failure, so I am not restructuring the function to pre-empt a rule this repo does not enable.

@eilandert
eilandert merged commit 7201b4d into main Aug 13, 2026
14 checks passed
@eilandert
eilandert deleted the ci/lint-prompt-steps branch August 13, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant