Skip to content

T-FZMY-extract-phrase-in-prose-shared-helper

Status: closed/done · Impact: low · Complexity: small

Any markdown linter that substring-matches required phrases against rendered prose is vulnerable to line-wrap silently breaking the match. The motivating case is lint_skill_prose.py’s required_phrases check, where the cross-repo-task-pr skill’s "never in its main checkout" invariant failed because the source prose wrapped between “never” and “in”. Today only lint_skill_prose.py does this kind of match; future prose linters (status-enum-in-prose, shell-example-lint, etc. — all flagged by an earlier consolidation pass) will hit the same trap unless the fix lives in a shared place. Land the normalize-then-match logic as a plugin/lib/prose_match/ helper so the first linter benefits today and every subsequent prose linter benefits by import.

LocationRole today
plugin/scripts/lint_skill_prose.pyCalls phrase in body where body is _section_text(...). Line-wrapped phrases silently fail to match. Concrete repro: cross-repo-task-pr skill’s "never in its main checkout" invariant.
plugin/lib/Established home for shared importable modules (lease, fpid, entity_naming proposed). No prose_match/ or markdown-handling package exists.
plugin/scripts/No other prose-prose linters live here today, but several are proposed (status-enum-in-prose, shell-example-lint). They’ll independently re-invent the whitespace problem without a shared helper.

A new plugin/lib/prose_match/ package exposes phrase_in_prose(phrase: str, body: str) -> bool that normalizes whitespace on both sides (collapses any run of whitespace to a single space, strips leading/trailing) before substring match. lint_skill_prose.py becomes the first consumer; the existing direct-substring call is replaced by an import. The library carries a small fixture suite covering: clean match, line-wrap match, leading/trailing whitespace, multiple-space collapsing, and the always-fail case (phrase truly absent).

The helper is deliberately minimal — no fuzzy matching, no stemming, no fenced-code awareness. Those are separate concerns; the contract is “exact phrase, ignoring whitespace differences.” Future prose linters that need fenced-code stripping can compose this helper with a fenced-code stripper.

  1. Create plugin/lib/prose_match/__init__.py exporting phrase_in_prose (and a co-located _normalize_whitespace private helper).
  2. Add plugin/lib/prose_match/tests/test_prose_match.py covering the fixture matrix above.
  3. Update plugin/scripts/lint_skill_prose.py to import the helper; replace the phrase in body call with phrase_in_prose(phrase, body).
  4. Re-run the full linter against plugin/skills/ and confirm no new false negatives (the test in AC-3 of the original spec).
  5. Update lint_skill_prose.py’s docstring to note that phrase matching is whitespace-insensitive.
LocationKindChange
plugin/lib/prose_match/__init__.pynewphrase_in_prose + _normalize_whitespace.
plugin/lib/prose_match/tests/test_prose_match.pynewFixture matrix: clean match, line-wrap match, leading/trailing whitespace, multiple-space collapse, absent phrase.
plugin/scripts/lint_skill_prose.pymodifyImport and use phrase_in_prose; update docstring.
  • AC-1: plugin/lib/prose_match/ exists; from prose_match import phrase_in_prose (via the project’s standard import path) returns the helper. The helper signature is phrase_in_prose(phrase: str, body: str) -> bool.
  • AC-2: A SKILL.md whose required phrase is split by a line break passes the linter (regression test in lint_skill_prose.py’s test suite, or in prose_match/tests/).
  • AC-3: A SKILL.md whose required phrase is absent still fails (no false negatives).
  • AC-4: Running lint_skill_prose.py against the full plugin/skills/ tree produces zero new violations after the change.
  • AC-5: The helper’s own test suite covers at least: clean match, line-wrap match, leading whitespace, trailing whitespace, multiple spaces between words, completely absent phrase.
  • Fuzzy or stemming-based matching. The contract is “exact phrase, ignoring whitespace differences.”
  • Fenced-code-block stripping. A separate composable concern; future prose linters that need it can layer a stripper on top of phrase_in_prose.
  • Migrating other prose linters to the helper. There are no other prose linters today; future ones will pick up the helper by import.
  • Section-heading matching whitespace normalization. The current section: hint matcher is case-insensitive substring; whitespace within headings is rare enough to skip.
  • none

Originally framed as an inline fix to lint_skill_prose.py (rev 2026-05-21). Rewritten 2026-05-28 during an open/ready audit and following a consolidation subagent’s observation that the SKILL.md-drift linter family (whitespace, status-enum, shell-example, mmdc-flag, etc.) shares the substring-match-against-prose surface. Extracting a shared helper means future prose linters get whitespace handling for free; the inline fix would have meant re-inventing the same fix N times.

Captured by /sdlc:task-work on 2026-05-28. PR: pending.

  • AC-1: auto — plugin/lib/prose_match/__init__.py exports phrase_in_prose(phrase: str, body: str) -> bool; the pytest run imports it via from prose_match import phrase_in_prose.
  • AC-2: auto — test_line_wrap_match in plugin/lib/prose_match/tests/test_prose_match.py exercises a phrase split by a \n and asserts the helper matches; pytest run is green.
  • AC-3: auto — test_absent_phrase_does_not_match (and the additional test_phrase_not_substring_when_normalized / test_phrase_absent_with_extra_words_in_between guards) confirm no false negatives.
  • AC-4: agent-manual — ran find plugin/skills -name SKILL.md -print0 | xargs -0 ./plugin/scripts/lint_skill_prose.py; the only violations are two pre-existing drift items in plugin/skills/task-work/SKILL.md (LEASE-CONFLICT ref=<ref> owner=<other-host-id> missing in section 2a, and threading.Thread(daemon=True) missing in section 6 — both unrelated to the whitespace fix; the skill recently switched from in-process threading to a subprocess heartbeat). Zero new violations introduced.
  • AC-5: auto — plugin/lib/prose_match/tests/test_prose_match.py covers the full matrix: clean, line-wrap, leading whitespace, trailing whitespace, multiple spaces, absent phrase (13 cases total including regression guards on tabs, mixed wrap whitespace, and empty-phrase semantics).
  • The plugin/lib/_example reference implementation and the prs_field package gave a clean template for layout, imports, docstring shape, and test discovery. Copying their shape made the new library a one-shot write.
  • The two-line bootstrap from plugin/conventions/python-runtime.md worked verbatim in lint_skill_prose.py; no debugging needed.
  • Baseline-gated quality checks via --diff-against-baseline made the gate noise-free even though audit_entities.py reports 232 pre-existing findings on origin/main.
  • ensure-ready treated the ## Today row “Other prose linters” as a missing-file disqualifier, even though the row’s intent was descriptive (“no other prose linters live here yet”). The verifier’s existence check is strict-by-design, but tasks that want to describe an absence-of-thing in ## Today have no documented escape hatch — the spec author has to pick a real directory as a stand-in. A future enhancement could let ## Today rows carry a kind: prose-only (or a leading _ to mark “descriptive, no path”) so the verifier skips existence on those rows.
  • The orchestrator-claimed lease was created by a different host-id than the current host; acquire_lease raised LeaseConflict rather than inheriting. The user pre-empted this by instructing “use discover_lease(), don’t re-claim,” but a future task-work change could detect the orchestrator-dispatch case (e.g. via an env var like SDLC_LEASE_DISPATCHED_BY_ORCHESTRATOR=1) and route through a same-flow that doesn’t require host-id equality.
  • The chore(tasks): start commit on main and the intermediate flag as needs-definition commit on the task branch conflicted at rebase time. The rebase recovered cleanly with a --skip of the now-stale needs-definition commit and a 2-line manual conflict resolution on the verify-stamp commit, but a fully-mechanical resolver could detect the “task branch has a stale status: value that main already supersedes” pattern and resolve it without operator action.
  • run_quality_checks.py --diff-against-baseline defaulted --baseline-dir to the worktree’s .sdlc/quality-baselines/, but the baseline was captured in the main repo’s .sdlc/. Had to pass --baseline-dir explicitly to point at the main-repo directory. The skill’s Step 7 invocation should pass --baseline-dir <project-root>/.sdlc/quality-baselines/ explicitly so worktree runs find the baseline by default.

← Back to Tasks