Add deterministic fixer for common YAML checker findings - #85
Merged
Merged
Conversation
nonprofittechy
force-pushed
the
add-deterministic-yaml-fixer
branch
3 times, most recently
from
September 15, 2026 16:33
24f8060 to
2b4f62d
Compare
nonprofittechy
force-pushed
the
add-deterministic-yaml-fixer
branch
from
September 15, 2026 17:23
2b4f62d to
c117c45
Compare
This was referenced Sep 15, 2026
Merged
This was referenced Sep 15, 2026
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical fixer defects can produce invalid YAML, misapply labels, or leave findings unfixed.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds deterministic, formatting-preserving YAML fixes for four checker findings, with CLI integration, validation, tests, and documentation.
Changes:
- Implements fixer planning, validation, dry-run, and write support.
- Adds
--fixCLI integration and preserves the standalone wrapper. - Adds regression tests and README documentation.
File summaries
| File | Review summary |
|---|---|
tests/test_yaml_structure_cli.py |
No final comments. |
tests/test_fix_yaml_checker.py |
No final comments. |
src/dayamlchecker/yaml_structure.py |
No final comments. |
src/dayamlchecker/fixer.py |
Critical (2 votes): sequence-field label insertion creates an extra list item; add regression coverage. Critical (1 vote): multiple shortcuts can create duplicate fields keys. Moderate (3 votes): select the first actual EA502 offender, not only the first labelable field. Nit (1 vote): preserve CRLF line endings. Critical (1 vote): reject candidates containing parse findings such as duplicate keys. Critical (1 vote): correctly handle folded block scalars. Moderate (1 vote): match EG104 findings to the full document range. Critical (1 vote): do not rewrite boolean no label markers as labels. Moderate (1 vote): insert labels after complete block or collection values. Moderate (1 vote): resolve block ID keys case-insensitively. Nit (1 vote): count only edits from validated plans. |
scripts/fix_yaml_checker.py |
No final comments. |
README.md |
No final comments. |
Review details
Suppressed comments (5)
src/dayamlchecker/fixer.py:618
Path.read_textuses universal-newline translation, so CRLF input is already converted to LF before_newline_forruns;apply_planreads it the same way. Any changed file therefore loses its original line endings despite this fixer claiming to preserve formatting. Read raw text withnewline=""(or decoderead_bytes()) in both reads.
text = path.read_text(encoding="utf-8")
src/dayamlchecker/fixer.py:358
- EG104 is reported at the document start (
block_startinyaml_structure.py), butdocument.lc.linepoints at the first mapping key. With comments or blank lines immediately after---, those locations differ by more than one line, so this duplicate is not selected for editing and remains in the candidate. Match the finding to the actual document range rather than allowing only a ±1-line heuristic.
# Depending on the document's first key and surrounding ``---`` marker,
# the checker may report the marker, the first key, or the adjacent line.
return bool({start - 1, start, start + 1}.intersection(target_lines))
src/dayamlchecker/fixer.py:592
- The label is inserted immediately after the first key line, rather than after that key's value. When the first key has a block or collection value such as
choices:ordatatype: |, this placeslabelinside that value; candidate validation rejects the whole file, so unrelated safe fixes in the same file are also lost. Insert after the complete value or skip only this EA502 edit.
_insert_before_line(
lines,
line_number=key_location[0] + 1,
replacement_lines=[f"{indent}label: {_quote_yaml_string(question)}"],
src/dayamlchecker/fixer.py:430
- The checker accepts block-ID keys case-insensitively (
_get_case_insensitive), so a validID:key can produce EG104. This exact lookup leavescurrent_idempty and skips the duplicate; the normal checker then still reports EG104 after--fix. Resolve the ID key case-insensitively for both the value and source location.
current_id = (
str(document.get("id")).strip() if isinstance(document.get("id"), str) else ""
)
src/dayamlchecker/fixer.py:750
plan.countsis populated before candidate validation. If a candidate is rejected,plan.changedis false and nothing is written, but this loop still adds its counts, causing the CLI to report rejected edits underFixes by rule. Aggregate counts only for validated plans.
counts: Counter[str] = Counter()
for plan in plans:
counts.update(plan.counts)
- Files reviewed: 6/6 changed files
- Comments generated: 6
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review of the fixer found several edits that could write invalid or semantically changed YAML, and a validation gap that let them through. Validation now compares every rule's finding count before and after the edit rather than only the four targeted codes, and rejects a candidate that introduces any new finding or that applies an edit without reducing that rule's count. The permissive loader used for planning tolerates duplicate keys, so it could not see a botched edit on its own. Individual fixes: * only expand a yes/no shortcut when a screen has exactly one, so two shortcuts no longer produce two top-level `fields` keys; * decide the multi-line ID guard from the block scalar's source lines instead of the single-line replacement, which corrupted `id: >` values spanning two lines; * treat `no label` as a variable shorthand only when its value is a non-empty string, and replace a boolean or empty modifier with the label it is missing; * pad a sibling `label:` to the key's column instead of copying the `- ` sequence marker, which added a new list item; * label the first *offending* field rather than the first labelable one, and skip `code` fields the way the checker does; * skip a screen whose question text is already in use as a field label, keeping repeated `--fix` runs idempotent now that later offending fields are reachable; * parse with the checker's tab expansion and translate columns back to the raw line, so tab-indented files are fixable rather than skipped; * honour `--no-wcag` and `--suppress` via a new `FixOptions`, so `--fix` never rewrites source for a rule the run would not report; and * populate `plan.counts` only after validation passes, so `changes_by_code` no longer reports edits that were rejected. A file the fixer cannot safely rewrite is a limitation of the fixer, not a finding in the user's interview, so it no longer fails the run. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LNbgp4BzZY9JmUHjWjnWT1
Question text went into an ID verbatim apart from whitespace collapsing,
so IDs picked up punctuation, smart quotes and, in four places across the
corpus, a whole Mako expression: `id: "${city_only_address}"`.
`_normalized_id` now keeps only letters, digits and spaces and lowercases
the result, so the uniqueness pass sees two questions that differ only in
case or punctuation as the collision they are. Apostrophes are dropped in
place rather than separated on, so "didn't" becomes "didnt" instead of
"didn t", while every other mark becomes a space and keeps
`city_only_address` three readable words. `isalnum` is Unicode-aware, so
accented question text survives.
Only generated IDs are normalized. EA502 labels still use the question
text as written, and EG104 keeps the author's own ID text and appends
only the suffix.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LNbgp4BzZY9JmUHjWjnWT1
Question text carrying ``% if``/``% for`` cannot survive the one-line
``"question": variable`` shorthand: collapsing it puts the directives
mid-line, where Mako does not evaluate them, so the applicant sees the
literal source. Two screens in the corpus hit this, and one of them
flattened both branches of an if/else into a single label.
There is no correct flattening -- the text is genuinely conditional -- so
the fix is to stop flattening. When the question contains a ``%``
directive the field moves to the ``label:``/``field:`` form, where a
literal block scalar keeps every directive on its own line exactly as
written. Inline ``${ }`` still uses the shorthand, since it evaluates
fine mid-line.
The sibling-insertion fallback declines for these questions rather than
inserting a block scalar beside a key whose layout it has not inspected,
leaving the screen for a human the way the rule already does elsewhere.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LNbgp4BzZY9JmUHjWjnWT1
CI runs mypy across the whole tree, not just src, and the `_fixed` helper added with the regression tests returned `tuple[str, object]`. Every `plan.counts` and `plan.skipped_reason` on its result was then an attribute access on `object`, which is 8 errors in CI and none locally under `mypy src`. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LNbgp4BzZY9JmUHjWjnWT1
This was referenced Sep 16, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a single, deterministic, formatting-preserving fixer for four mechanical DAYamlChecker findings:
EG414: adds a question blockidderived from normalized question text; repeated generated IDs receive2,3, etc. while avoiding all existing IDs.EA510: expandsyesno/noyesshortcuts to an explicitfieldsentry withdatatype: yesnoradio.yesnomaybe/noyesmaybeare expanded withdatatype: yesnomaybeto preserve their third answer choice.EA502: labels only the first offending input field with the question text, including blank mapping keys such as- "": variable. It does not guess labels for additional unlabeled fields on the same screen.EG104: suffixes later duplicate block IDs, including the corpus's commonid: |block-scalar form, until IDs are unique within each YAML file.The implementation lives in
src/dayamlchecker/fixer.py, with each fix in its own function. The standalonescripts/fix_yaml_checker.pyremains as a compatibility wrapper and dry-run entry point. The maindayamlcheckerCLI now accepts--fix, writes only validated plans, and then runs the normal checker against the updated files. Rewritten mapping lines also have trailing whitespace removed. Without--fix, behavior is unchanged.Validation
yesnomaybe, residual EA502 behavior, dry-run non-mutation, and idempotence.EG414156,EA510112,EA50277,EG10438). Four pre-existing parse-invalid YAML files were skipped; no candidate was rejected.--fixwrites the changes before the ordinary checker runs and reports the updated files clean.black --check ., andpytest -q.