diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 41647e1..8d3cd7b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,6 +14,8 @@ "./skills/meta/agent-readiness", "./skills/reference/rhdh-context", "./skills/meta/ask-rhdh", + "./skills/meta/clean-prose", + "./skills/reference/prose-editing", "./skills/meta/setup-rhdh-skills", "./skills/reference/mutation-gate", "./skills/meta/skill-authoring" @@ -27,6 +29,7 @@ "./skills/release/rhdh-overlay-cve-export", "./skills/release/rhdh-platform-lifecycle", "./skills/release/rhdh-release-announce", + "./skills/reference/prose-editing", "./skills/release/rhdh-release-schedule", "./skills/release/rhdh-release-status", "./skills/release/rhdh-release-teams", @@ -41,8 +44,11 @@ "./skills/ci/rhdh-base-images", "./skills/reference/mutation-gate", "./skills/ci/rhdh-konflux-tasks", + "./skills/ci/rhdh-konflux-rpa", + "./skills/reference/rhdh-forge", "./skills/ci/rhdh-prow-jobs", "./skills/ci/rhdh-prow-release-branch", + "./skills/reference/prose-editing", "./skills/ci/rhdh-prow-trigger", "./skills/ci/rhdh-yarn-bump" ] @@ -57,6 +63,7 @@ "./skills/plugins/rhdh-local", "./skills/plugins/rhdh-overlay", "./skills/reference/mutation-gate", + "./skills/reference/prose-editing", "./skills/plugins/rhdh-plugin-authoring", "./skills/plugins/rhdh-plugin-bug-fix", "./skills/reference/rhdh-forge", @@ -76,6 +83,7 @@ "skills": [ "./skills/jira/rhdh-jira-create", "./skills/reference/rhdh-jira-api", + "./skills/reference/prose-editing", "./skills/reference/rhdh-jira-authoring", "./skills/reference/mutation-gate", "./skills/jira/rhdh-jira-link", diff --git a/.markdownlint.json b/.markdownlint.json index fb39e2e..031030e 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -5,6 +5,7 @@ "MD024": { "siblings_only": true }, + "MD028": false, "MD029": false, "MD036": false, "MD040": false, diff --git a/AGENTS.md b/AGENTS.md index a7648a0..2622375 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,15 +56,19 @@ utterances is several skills. Split by verb, never by noun, and weight the split by what a misroute costs — merge where a misroute produces a wrong write, split where it produces an obvious wrong answer. See ADR-0005. -Only `ask-rhdh` and `setup-rhdh-skills` are human-invoked. They carry -`disable-model-invocation: true` in `SKILL.md` and -`policy.allow_implicit_invocation: false` in `agents/openai.yaml`. Every other +Human invocation is a class, not a roster. A human-invoked skill is an entry +point a person types by name, and the router never reaches it. Every member +carries `disable-model-invocation: true` in `SKILL.md` and +`policy.allow_implicit_invocation: false` in `agents/openai.yaml`. Admit a new +one only when it holds no substance of its own and delegates to exactly one +model-invoked skill, the way `clean-prose` delegates to `prose-editing`; +`ask-rhdh` and `setup-rhdh-skills` are the other members today. Every other promoted skill is model-invoked and omits both flags. Every promoted skill has an `agents/openai.yaml` interface entry. -The complete pack also requires three external skills. Creation and interview -flows use `/grilling`; PR-review prose uses `/humanizer`; `/handoff` carries -context into a later session, which is why no artifact store does. +The complete pack also requires two external skills. Creation and interview +flows use `/grilling`; `/handoff` carries context into a later session, which is +why no artifact store does. Keep drafts and retired skills outside the promoted discovery root: @@ -86,6 +90,13 @@ Do not add them to promoted manifests or catalogs. artifacts remain credential-free. Setup owns login and never creates a parallel credential store. - `/rhdh-context` owns shared repository and version context. +- `/prose-editing` owns the prose pass. The final composer invokes it exactly + once for free-form GitHub, GitLab, Jira, or Slack prose before anybody sees, + gates, or posts that text. The caller names the register because it knows what + it wrote. Helpers invoke it only when they return the final prose directly; + transport layers never do. Structured payloads, fixed commands, checksums, + generated reports, and local documents with an owning authoring skill stay + outside this automatic pass. - Skills pass context by invoking each other by name. There is no artifact envelope and no artifact store. When the user needs context to survive into a later session, tell them to run `/handoff`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c247127..2a2f42f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ uv run pytest ``` For end-to-end skill use, install the complete pack documented in -[README.md](README.md), including `/grilling` and `/humanizer`. +[README.md](README.md), including `/grilling` and `/handoff`. ## Choose the owning module @@ -95,9 +95,11 @@ installed, so `AGENTS.md`, `CONTEXT.md`, this file, and `docs/adr/` do not trave with it. A skill citing them is broken for everyone who installs it. Restate the rule locally instead. -Human-only metadata is for `ask-rhdh` and `setup-rhdh-skills` alone. A skill -missing from `catalog.json` fails `scripts/validate_skill_catalog.py`, and nothing -installs it. +Human-only metadata is for entry points a person explicitly invokes and the +router must never select. A delegating entry point contains no domain behavior, +names exactly one model-invoked target, and passes the person's input to it; +`clean-prose` is the current example. A skill missing from `catalog.json` fails +`scripts/validate_skill_catalog.py`, and nothing installs it. Do not add prose-shape assertions. Tests should survive editorial improvements that preserve the skill interface. @@ -146,8 +148,8 @@ skill aliases and a mixed old/new catalog are intentionally excluded. After the breaking branch is merged and tagged, a maintainer signs in at `https://skills.sh/packs/create`, creates the `RHDH complete` pack from the -tagged repository plus `grilling` and `humanizer`, and records the resulting +tagged repository plus `grilling` and `handoff`, and records the resulting `https://skills.sh/p/` URL in the setup catalog. Packs are unlisted, not access-controlled, so never include credentials or private files. Until -that URL exists, `/setup-rhdh-skills` emits the equivalent three-source install +that URL exists, `/setup-rhdh-skills` emits the equivalent two-source install plan. diff --git a/README.md b/README.md index 7d67430..9ccc2c3 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,12 @@ behind a small set of task-oriented interfaces. ## Install -Install the pack and the three external skills it depends on. Each command opens +Install the pack and the two external skills it depends on. Each command opens the skills wizard, which asks where to put them: ```bash npx skills add redhat-developer/rhdh-skills --global npx skills add mattpocock/skills --global -npx skills add blader/humanizer --global ``` Restart your agent client so it discovers them. @@ -47,9 +46,9 @@ one. It performs no work itself. `skills/meta/setup-rhdh-skills/assets/catalog.json` is the machine-readable roster and the single source of truth for membership. This file does not restate it. -Two skills are human-invoked and never selected automatically: `/ask-rhdh` and -`/setup-rhdh-skills`. The other 39 are model-invoked, and can also be called by -name. +Three skills are human-invoked and never selected automatically: `/ask-rhdh`, +`/setup-rhdh-skills`, and `/clean-prose`. The other 41 are model-invoked, and can +also be called by name. Skills are grouped into six folders: @@ -57,10 +56,10 @@ Skills are grouped into six folders: | --- | --- | | `jira/` | Creating, refining, updating, and reporting on RHIDP, RHDHPLAN, RHDHBUGS, and RHDHSUPP work, plus sprint ceremonies and linking PRs to issues. | | `plugins/` | Authoring, wiring, exporting, and fixing Backstage dynamic plugins; the overlays repository; local RHDH; opening and reviewing pull requests; midstream propagation. | -| `ci/` | Prow job configuration and nightly triggers, Konflux and Tekton task updates, base images, and Yarn bumps. | +| `ci/` | Prow job configuration and nightly triggers, Konflux and Tekton task updates, release-data admission tags, base images, and Yarn bumps. | | `release/` | Release status and readiness, milestone schedules, freeze announcements, teams, test-plan review, platform lifecycle, and the plugin CVE export. | -| `reference/` | The reusable layer other skills invoke by name: repository and version context, the forge read seam, the write gate, and the Jira and Backstage reference material. | -| `meta/` | The two human-invoked entry points, plus skill authoring and repository agent-readiness. | +| `reference/` | The reusable layer other skills invoke by name: repository and version context, the forge read seam, the write gate, the prose pass, and the Jira and Backstage reference material. | +| `meta/` | The three human-invoked entry points, plus skill authoring and repository agent-readiness. | ## How skills compose @@ -79,12 +78,16 @@ there is no shared runtime package, provided anything it invokes is also present skill it did not install from one you wrote and kept, so removing anything else is yours to do. -Three skills come from outside this repository and are required rather than +Two skills come from outside this repository and are required rather than optional. `/grilling` supplies the interview discipline that skill authoring and -Jira creation depend on, so those flows stop rather than guess. `/humanizer` runs -before any PR-review prose is shown or posted, so drafts do not go out reading -like a machine wrote them. `/handoff` is what carries context into a later -session, which is why this pack ships no artifact store of its own. +Jira creation depend on, so those flows stop rather than guess. `/handoff` is +what carries context into a later session, which is why this pack ships no +artifact store of its own. + +Free-form GitHub, GitLab, Jira, and Slack prose goes through `/prose-editing` +exactly once at its final composer. Structured payloads, commands, generated +reports, and local authoring artifacts do not. Run `/clean-prose` with pasted +text or a file path to put your own draft through the same pass. Every external write goes through the write gate. The skill states each operation with its target, exact command, preview, and what happens on failure; you approve diff --git a/docs/adr/0005-one-skill-per-trigger-phrase.md b/docs/adr/0005-one-skill-per-trigger-phrase.md index db3157f..5df84d2 100644 --- a/docs/adr/0005-one-skill-per-trigger-phrase.md +++ b/docs/adr/0005-one-skill-per-trigger-phrase.md @@ -39,6 +39,10 @@ produces a **wrong mutation** — an Epic where a Story belonged — so those me Sprint planning and sprint reporting share a rare noun and distinct verbs, and a misroute there is read-only and immediately visible, so those split. +The same rule keeps a release-data RPA tag update separate from a Tekton task +digest or migration update. Both use Konflux, but they act on different +repositories, artifacts, and publication paths; the shared noun is not a route. + Shared material becomes a **reference skill**: a model-invoked skill whose reason for existing is material two or more skills would otherwise copy, reached by name like any other skill. Two callers is the threshold; one caller means the material diff --git a/docs/adr/0010-prose-editing-registers.md b/docs/adr/0010-prose-editing-registers.md new file mode 100644 index 0000000..ec7e622 --- /dev/null +++ b/docs/adr/0010-prose-editing-registers.md @@ -0,0 +1,134 @@ +# One prose skill, three registers + +**Status:** Accepted. + +## Context + +Two skills carried `/humanizer` (blader/humanizer, MIT, its catalogue derived +from Wikipedia's CC BY-SA "Signs of AI writing") as a hard prerequisite: +`/rhdh-pr-review` and `/rhdh-release-announce`, both instructing the agent to +never show pre-humanizer prose. A separate proposal added a Simplified Technical +English skill with a bundled linter. The two looked like complements — one +removes the machine register, the other tightens technical writing — and were +scoped to sit beside each other. + +Measuring them says otherwise. A sample containing seventeen distinct AI writing +tells scored 5.93 against the linter's 2.5 bar, so it failed. But every flagged +category was incidental — four contractions, passive voice, a nominalization, +one long paragraph — and **not one of the seventeen tells was detected**. An +agent told to repair every reported category fixes the contractions, re-lints +under the bar, and returns prose that is still obviously machine-written with a +passing score attached. That is worse than no score, because it certifies the +failure. + +The bar has a second problem: it rejects prose this repository already treats as +good. `README.md` scores 2.68, `skill-authoring/SKILL.md` 3.15, and the proposed +skill's own `SKILL.md` 4.85 against the 2.5 bar it was proposing. + +The two skills also claimed overlapping utterances. Both answer "clean up this +PR body" and "tighten this announcement", which +[ADR-0005](0005-one-skill-per-trigger-phrase.md) forbids. + +## Decision + +Merge them into one model-invoked reference skill, `/prose-editing`, with a +human-invoked wrapper, `/clean-prose`. The external dependency is removed. + +**One skill, because the split test is the verb.** "Remove the AI tells" and +"tighten this runbook" are the same verb applied to different document types, +and a document type is a noun. Splitting by noun is what ADR-0005 rejects, and +for its usual reason: the user often does not know which treatment they want, so +choosing it is the skill's job. ADR-0005 also weights the split by the cost of a +misroute, and a misroute here produces a wrong *write* — an announcement +flattened into aircraft-manual English. That is the case that merges. + +**The contradiction is about compression, not tells.** STE strips voice on +purpose; humanizer's personality guidance exists to keep it. That reads as +irreconcilable until you separate what the two systems actually score. They do +not disagree about tells; they disagree about compression. Three layers fall +out: mechanical tells, scored in every register; compression, scored only +where prose should be flat; voice, scored only where prose is allowed one. Those +give three registers — `strict`, `flavored`, `voiced` — inferred from document +purpose, plus a read-only `audit` route. A caller that knows names the register: +`/rhdh-pr-review` uses flavored, `/rhdh-release-announce` uses voiced. + +**Intent chooses editing versus audit.** An explicit rewrite wins even when the +caller also asks for a score. Only an explicit no-change request selects +`audit`. Document ownership does not silently cancel a requested edit, though +quoted third-party spans stay protected. + +**Mixed documents keep one primary score.** Document purpose outranks a byline. +A mixed document keeps one primary register and applies strict rules manually +to procedural or safety sections. It does not expose a numeric register map. + +**The score is a delta, not a gate.** Violation density is a function of +document type, so an absolute bar across arbitrary prose is a promise the metric +cannot keep — the README and `skill-authoring` numbers are that promise breaking +on curated text. The skill reports before and after and leaves the judgement +with the reader. The fixed bar survives only as a `--fail-over N` knob a human +points at their own corpus in CI, where the text is uniform enough for one +number to mean something. + +**A regex does not certify judgment.** High-confidence deterministic patterns +contribute to the density score. Context-sensitive patterns appear as markers +or manual checks and still block completion until the editor accounts for them. +Singleton transitions, curly quotes, em dashes, and short emphatic sentences +are weak evidence; repetition or a cluster can be scored. A supplied writing +sample governs the voiced register. + +**Meaning preservation is bidirectional.** The editor inventories claims, +conditions, scope qualifiers, and modal force before rewriting. Completion +requires both that every source proposition survives and that every output +proposition came from the source or the user. A lower score cannot excuse a +lost limit or an invented detail. + +**The capability comes in-pack.** Two skills hard-failing on a third-party +repository is a live failure mode on the path of every PR review, and owning the +capability removes it. It is also the extraction +[ADR-0006](0006-duplication-by-layer.md) asks for — a pattern catalogue is +prompt material, so it gets one home behind a named interface rather than a copy +in each caller, while the bundled linter is code and stays self-contained. +Owning it removes a licensing hazard too: reproducing humanizer's prose would +pull CC BY-SA text into an Apache-2.0 repository. The taxonomy is taken and +every rule restated independently, with a NOTICE at +`skills/reference/prose-editing/scripts/NOTICE` carrying the MIT terms of both +upstream projects. + +**The wrapper is safe because the router cannot see it.** +[ADR-0008](0008-skill-naming-and-namespace-isolation.md) establishes that +isolation is the description's job, and two skills about editing prose would +collide by description whatever they were named. +`disable-model-invocation: true` takes `/clean-prose` out of the routing set +entirely, so it cannot compete with the reference skill it delegates to. That is +a structural guarantee rather than a convention, which is why the human-invoked +rule is stated as a class of skill rather than a roster of names. Neither skill +takes the `rhdh-` prefix: editing prose is not about Red Hat Developer Hub. + +**Outbound prose is edited at the final composition seam.** A skill invokes +`/prose-editing` exactly once after it has assembled free-form GitHub, GitLab, +Jira, or Slack prose and before it shows, gates, or posts it. A helper does so +only when it directly returns the final prose; a transport layer never does. +Structured payloads, commands, checksums, generated reports, and local documents +with their own authoring skill are excluded from the automatic pass. + +## Consequences + +- One skill answers both utterances. `/rhdh-pr-review` and + `/rhdh-release-announce` name a register instead of declaring a prerequisite, + and no third-party repository sits on the path of a review. +- The pack maintains a pattern catalogue it previously rented, on a + taxonomy-only footing: whoever adds a pattern restates it rather than quoting + a source. The implementation does not promise compatibility with a pinned + upstream version; a later source audit can adopt new behavior, including a + breaking change, as one reviewed release. +- Three registers are more surface than two modes, and register inference is a + new failure mode: a caller that names none gets the register the skill guesses. + A wrong guess is a wrong write — the same cost that argued for merging. +- The reported score no longer answers "is this good enough" by itself. That + question moves to a human, reading the delta or setting `--fail-over N` + against a corpus they own. +- The human-invoked rule in `AGENTS.md` becomes a property of a class of skill + rather than a list of two names, so a future wrapper needs no amendment. +- Every external prose producer records `/prose-editing` as a named dependency, + while automation templates that cannot invoke a skill receive static lint + coverage instead. diff --git a/scripts/validate_skill_catalog.py b/scripts/validate_skill_catalog.py index da5b42c..8eb58c3 100644 --- a/scripts/validate_skill_catalog.py +++ b/scripts/validate_skill_catalog.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import difflib import hashlib import itertools import json @@ -15,14 +16,21 @@ CATALOG_PATH = Path("skills/meta/setup-rhdh-skills/assets/catalog.json") PROMOTED_CATEGORIES = ("jira", "plugins", "ci", "release", "reference", "meta") -# A named invocation reads as /rhdh-something in prose. It must follow a space, -# a line start, or an opening bracket or backtick, never another path segment, so -# `~/rhdh-local-setup` and `redhat-developer/rhdh-plugin-catalog` stay paths. +# A cited skill, as `/name`. It must follow a space, a line start, or an opening +# bracket or backtick, never another path segment, so `~/rhdh-local-setup` and +# `redhat-developer/rhdh-plugin-catalog` stay paths. Matching only the rhdh- +# prefixes missed every skill +# whose subject is not RHDH — prose-editing, clean-prose, mutation-gate, +# skill-authoring — so renaming one left its callers dangling silently. Every +# promoted name is kebab-case, but a skill can still be a single token. Route-like +# tokens are filtered against the promoted/external/retired name sets below. A +# trailing slash or dot means the token is a path, not a skill. NAMED_INVOCATION = re.compile( - r"(?:^|(?<=[\s(\[`]))/((?:rhdh|ask-rhdh|setup-rhdh)[\w-]*)(?![\w-])", + r"""(?:^|(?<=[\s(\[`'"]))/([a-z0-9]+(?:-[a-z0-9]+)*)(?![\w-])(?![/.])""", re.MULTILINE, ) -EXTERNAL_SKILLS = {"grilling", "humanizer", "handoff"} +EXTERNAL_SKILLS = {"grilling", "handoff"} +RETIRED_SKILLS = {"humanizer"} # A bundled script reading a file that ships *with it*, such as # `_DATA_DIR / "jql-release.md"`. Anchored to the handful of names that mean # "this script's own directory", because a bare `dir / "config.json"` is usually @@ -34,6 +42,8 @@ HOST_SKILL_PATHS = (".claude/skills", ".agents/skills", ".cursor/skills", ".codex/skills") SHIPPED_SUFFIXES = {".md", ".py", ".sh", ".mjs"} DUPLICATE_BLOCK_LINES = 25 +FENCE_OPEN = re.compile(r"^ {0,3}(?P`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)") +BLOCKQUOTE_MARKER = re.compile(r" {0,3}> ?") def _frontmatter(text: str) -> dict[str, Any]: @@ -76,14 +86,124 @@ def _body(text: str) -> str: return re.sub(r"^---\n.*?\n---(?:\n|$)", "", normalized, count=1, flags=re.DOTALL) +def _blockquote_layers(line: str) -> list[str]: + """Return the line after each valid nested CommonMark blockquote marker.""" + layers = [line] + remainder = line + while marker := BLOCKQUOTE_MARKER.match(remainder): + remainder = remainder[marker.end() :] + layers.append(remainder) + return layers + + +def _without_noninstructions(text: str) -> str: + """Remove fenced examples and comments that an agent does not follow.""" + instructions: list[str] = [] + fence_character: str | None = None + fence_length = 0 + fence_blockquote_depth = 0 + for line in text.splitlines(keepends=True): + blockquote_layers = _blockquote_layers(line) + blockquote_depth = len(blockquote_layers) - 1 + if fence_character is not None: + if blockquote_depth >= fence_blockquote_depth: + fence_line = blockquote_layers[fence_blockquote_depth] + closing = ( + rf" {{0,3}}{re.escape(fence_character)}{{{fence_length},}}" + r"[ \t]*(?:\r?\n)?" + ) + if re.fullmatch(closing, fence_line): + fence_character = None + fence_length = 0 + fence_blockquote_depth = 0 + continue + fence_character = None + fence_length = 0 + fence_blockquote_depth = 0 + + fence_line = blockquote_layers[-1] + opening = FENCE_OPEN.match(fence_line) + if opening: + fence = opening.group("fence") + info = fence_line[opening.end("fence") :].rstrip("\r\n") + if fence[0] == "`" and "`" in info: + instructions.append(line) + continue + fence_character = fence[0] + fence_length = len(fence) + fence_blockquote_depth = blockquote_depth + continue + instructions.append(line) + + text = "".join(instructions) + return re.sub(r"", " ", text, flags=re.DOTALL) + + +# A wrapper delegates and stops, so its body is one short sentence. Anything +# past that is substance, and substance owes a '## Completion' section. +WRAPPER_MAX_LINES = 1 +WRAPPER_MAX_WORDS = 15 + + +def _delegation_target(body: str) -> str | None: + """Return the one skill a delegating wrapper runs, or None if it is not one. + + A delegating wrapper is an entry point a person types that holds no substance + of its own: it delegates and stops. Recognising it by shape rather than by a + roster is what lets a new one arrive without amending a rule, so the shape has + to be narrow enough that a real skill cannot fall into it and thereby escape + the '## Completion' requirement. Three tests do that work: no heading of any + level, a body of at most a few lines, and exactly one skill named in prose. + + Fenced code and HTML comments are removed first. A skill named only inside + either one is not an instruction the agent follows, so it cannot be the + delegation, and a body whose visible text delegates to nothing must fail. + """ + body = _without_noninstructions(body) + if re.search(r"(?m)^#{1,6}\s+\S", body): + return None + lines = [line for line in body.splitlines() if line.strip()] + if len(lines) > WRAPPER_MAX_LINES: + return None + if len(re.findall(r"[A-Za-z0-9][\w'-]*", " ".join(lines))) > WRAPPER_MAX_WORDS: + return None + # A setext heading underlines its text, so it needs no '#' to be a section. + if any(re.fullmatch(r"\s*(?:=+|-{2,})\s*", line) for line in lines): + return None + targets = {match.group(1) for match in re.finditer(r"/([a-z0-9]+(?:-[a-z0-9]+)*)\b", body)} + if len(targets) != 1: + return None + return targets.pop() + + def _mentions(body: str, term: str) -> bool: """Report whether the body names a skill or artifact rather than a longer token. ``/rhdh-jira`` and `` `rhdh-jira` `` both count; ``rhdh-jira-legacy`` does not. """ + body = _without_noninstructions(body) return re.search(rf"(? str: + """Return instruction prose that can own a catalog dependency citation. + + A skill may route the relevant operation into a workflow or keep supporting + protocol in a reference. Requiring the parent SKILL.md to repeat that citation + would make the prompt say the same thing in two places. + """ + documents = [skill_body] + for directory in ("workflows", "references"): + instruction_root = skill_dir / directory + if not instruction_root.is_dir(): + continue + documents.extend( + path.read_text(encoding="utf-8", errors="replace") + for path in sorted(instruction_root.rglob("*.md")) + ) + return "\n".join(documents) + + def _shipped_files(skill_dir: Path) -> Iterator[Path]: """Yield the prose a skill ships, skipping build residue and implementation. @@ -134,6 +254,24 @@ def _duplicate_files(root: Path, skill_dirs: dict[str, Path]) -> list[tuple[str, return sorted(pairs) +def _resembles_a_skill(cited: str, known: set[str]) -> bool: + """Report whether an unknown ``/name`` is a stale skill citation or a URL path. + + Skill documentation writes a route the same way it writes a skill: ``/my-plugin`` + in backticks is a mount point, ``/prose-editing`` in backticks is an invocation. + Nothing in the syntax separates them, so use what a stale citation actually is — + the residue of a rename or a split, which leaves a name that still resembles the + skill that replaced it. ``/rhdh-jira`` survives as a prefix of ``rhdh-jira-create``; + ``/image-registry`` resembles nothing in the catalog. + """ + if cited in RETIRED_SKILLS: + return True + for name in known: + if cited.startswith(f"{name}-") or name.startswith(f"{cited}-"): + return True + return bool(difflib.get_close_matches(cited, sorted(known), n=1, cutoff=0.8)) + + def _validate_named_invocations( root: Path, skill_dirs: dict[str, Path], @@ -151,8 +289,11 @@ def _validate_named_invocations( for name, skill_dir in sorted(skill_dirs.items()): for path in _shipped_files(skill_dir): text = path.read_text(encoding="utf-8", errors="replace") + # A `/name` inside a fenced block is sample code — a React route, a + # URL path, a shell argument — not an instruction to invoke a skill. + text = _without_noninstructions(text) for cited in sorted(set(NAMED_INVOCATION.findall(text))): - if cited in known: + if cited in known or not _resembles_a_skill(cited, known): continue errors.append( { @@ -304,6 +445,7 @@ def validate_repository(root: Path) -> dict[str, Any]: promoted_names: list[str] = [] human_names: list[str] = [] + wrapper_delegations: dict[str, tuple[str, Path]] = {} entry_by_name: dict[str, dict[str, Any]] = {} external_entries = catalog.get("pack", {}).get("requiredExternalSkills", []) external_names = [item.get("name") for item in external_entries if isinstance(item, dict)] @@ -372,7 +514,14 @@ def validate_repository(root: Path) -> dict[str, Any]: } ) - if not re.search(r"(?m)^##\s+Completion\s*$", body): + delegates_to = _delegation_target(body) if invocation == "human" else None + if delegates_to is not None: + # A delegating wrapper is an entry point a person types that holds no + # substance of its own. Its completion is its delegate's completion, + # so restating one here would put the same rule in two places. What it + # owes instead is that the delegate exists and can be reached. + wrapper_delegations[name] = (delegates_to, skill_file) + elif not re.search(r"(?m)^##\s+Completion\s*$", body): errors.append( { "code": "MISSING_COMPLETION", @@ -406,15 +555,16 @@ def validate_repository(root: Path) -> dict[str, Any]: } ) + dependency_instructions = _dependency_instructions(skill_dir, body) for dependency in entry.get("requiresSkills") or []: - if isinstance(dependency, str) and not _mentions(body, dependency): + if isinstance(dependency, str) and not _mentions(dependency_instructions, dependency): errors.append( { "code": "DEPENDENCY_NOT_DOCUMENTED", "message": ( - f"{name}: requiresSkills declares {dependency} but SKILL.md never " - f"names it; document when to invoke {dependency} and what it returns, " - "or drop the dependency" + f"{name}: requiresSkills declares {dependency} but its SKILL.md, " + "workflows, and references never name it; document when to invoke " + f"{dependency} and what it returns, or drop the dependency" ), } ) @@ -509,11 +659,37 @@ def validate_repository(root: Path) -> dict[str, Any]: _validate_internal_skills(root, errors) + for wrapper, (target, wrapper_file) in sorted(wrapper_delegations.items()): + location = wrapper_file.relative_to(root).as_posix() + target_entry = entry_by_name.get(target) + if target_entry is None: + errors.append( + { + "code": "WRAPPER_TARGET_MISSING", + "message": ( + f"{location}: delegates to /{target}, which is not a promoted skill. " + "A wrapper holds no substance, so a dead delegate leaves it doing nothing" + ), + } + ) + elif target_entry.get("invocation") != "model": + errors.append( + { + "code": "WRAPPER_TARGET_NOT_MODEL", + "message": ( + f"{location}: delegates to /{target}, which is human-invoked. " + "A wrapper delegates to a model-invoked skill; chaining entry points " + "leaves neither one reachable by the router" + ), + } + ) + return { "valid": not errors, "errors": errors, "promotedSkills": sorted(promoted_names), "humanInvokedSkills": sorted(human_names), + "delegatingWrappers": {name: target for name, (target, _) in wrapper_delegations.items()}, "requiredExternalSkills": sorted(external_set), } diff --git a/skills/ci/rhdh-base-images/workflows/update-base-images.md b/skills/ci/rhdh-base-images/workflows/update-base-images.md index 7578b0c..cb53122 100644 --- a/skills/ci/rhdh-base-images/workflows/update-base-images.md +++ b/skills/ci/rhdh-base-images/workflows/update-base-images.md @@ -118,6 +118,11 @@ For **rhdh**, paths under `e2e-tests/` and `.ci/` are excluded from scans. 4. Review open PRs — each should include base image, `rpms.lock.yaml`, and (for rhdh) node header updates when applicable. 5. Human merges PRs; do **not** push directly to protected branches without review. +The bundled script's fixed automation PR title and body are authored artifacts, +not runtime agent prose. Keep them under the repository's static prose linter in +flavored mode. The `gh pr create` call transports those fixed strings and +must not invoke an editor during an automation run. + ## What each step does ### Base images (`updateBaseImages.sh`) diff --git a/skills/ci/rhdh-konflux-rpa/SKILL.md b/skills/ci/rhdh-konflux-rpa/SKILL.md new file mode 100644 index 0000000..d4da175 --- /dev/null +++ b/skills/ci/rhdh-konflux-rpa/SKILL.md @@ -0,0 +1,48 @@ +--- +name: rhdh-konflux-rpa +description: >- + Updates the RHDH ReleasePlanAdmission tag values in konflux-release-data for + one patch release while preserving stream tags and plugin version suffixes. + Use for "update the RHDH RPA tags for 1.9.7". +compatibility: "Git 2.x and Python 3.9+; glab authenticated to gitlab.cee.redhat.com for an approved GitLab merge request; tox is optional for schema validation." +--- + +# RHDH Konflux ReleasePlanAdmission updates + +Update one RHDH patch stream in a user-provided `konflux-release-data` checkout. +The deterministic script edits only the four hub/operator and plugin-catalog RPA +files for that stream. It never commits, pushes, or opens a merge request. + +## Route + +Load `workflows/update-rpa.md` and follow it end to end. Execute the bundled +`scripts/update_rpa_tags.py` by path relative to this installed skill; do not +copy its replacement logic into the conversation. + +## Boundaries + +- `/rhdh-konflux-tasks` owns Tekton task bundle digests, migrations, templates, + and PipelineRun regeneration. +- `/rhdh-plugin-midstream-propagate` owns a single plugin workspace and its + surgical PLR tag edits. +- This skill owns RHDH patch tags in release-data ReleasePlanAdmissions. It does + not edit FBC, `1.next`, `1-stage`, builder RPAs, tenant snapshots, or Konflux + components. + +## Write boundary + +Inspection, `--dry-run`, the requested local branch, `--local-only` file edits, +validation, and `git diff` stay in the user's checkout and need no write gate. +The gate starts at commit: before a commit, push, or merge-request creation, +invoke `/mutation-gate` with the exact target, command, payload preview, +verification, and failure behavior for each operation. The user's request to +update tags authorizes those named local edits, not publication. + +The merge-request body is a fixed repository template. Fill only its named +placeholders; do not invoke a runtime prose editor. + +## Completion + +Name the old and new patch tags, every changed RPA file, validation results, the +local branch and commit state, and the outcome or skipped state of the commit, +push, and merge-request operations. Report explicitly when nothing was pushed. diff --git a/skills/ci/rhdh-konflux-rpa/agents/openai.yaml b/skills/ci/rhdh-konflux-rpa/agents/openai.yaml new file mode 100644 index 0000000..09a12df --- /dev/null +++ b/skills/ci/rhdh-konflux-rpa/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "RHDH Konflux RPA" + short_description: "Update release-data RPA tags for one RHDH patch" diff --git a/skills/ci/rhdh-konflux-rpa/references/mr-body.md b/skills/ci/rhdh-konflux-rpa/references/mr-body.md new file mode 100644 index 0000000..5bd9055 --- /dev/null +++ b/skills/ci/rhdh-konflux-rpa/references/mr-body.md @@ -0,0 +1,13 @@ +Generated-by: cursor + +#### What + +Update RHDH ReleasePlanAdmission tags to {new_version} for the {stream} stream. + +Replaced {old_versions} tag references in the four stream RPA files. + +#### Why + +Prepare konflux-release-data for the upcoming RHDH {new_version} release. + +#### Tickets diff --git a/skills/ci/rhdh-konflux-rpa/scripts/update_rpa_tags.py b/skills/ci/rhdh-konflux-rpa/scripts/update_rpa_tags.py new file mode 100644 index 0000000..2216116 --- /dev/null +++ b/skills/ci/rhdh-konflux-rpa/scripts/update_rpa_tags.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +"""Update RHDH patch versions in release-data RPA ``tags`` values.""" + +# SPDX-License-Identifier: EPL-2.0 + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import stat +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +TAGS_KEY = re.compile(r"^(?P *)tags[ ]*:(?P.*)$") +LIST_ITEM = re.compile(r"^(?P *-[ ]*)(?P.*)$") +MAPPING_KEY = re.compile(r"^(?P *)(?P[A-Za-z0-9_.-]+)[ ]*:(?P.*)$") +BLOCK_SCALAR = re.compile(r"^[|>][0-9+-]*(?:[ \t]+#.*)?$") +RPA_RELATIVE_DIR = Path("config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh") +EXPECTED_REMOTES = frozenset( + { + "https://gitlab.cee.redhat.com/releng/konflux-release-data", + "git@gitlab.cee.redhat.com:releng/konflux-release-data", + "ssh://git@gitlab.cee.redhat.com/releng/konflux-release-data", + } +) + + +@dataclass(frozen=True) +class Edit: + text: str + count: int + old_versions: frozenset[str] + + +class UnsupportedYaml(ValueError): + """Raised when safe line-oriented editing cannot represent a tags value.""" + + +def _candidate(value: str, stream: str) -> Optional[tuple[str, str]]: + match = re.fullmatch(rf"({re.escape(stream)}\.[0-9]+)(--.+)?", value) + if not match: + return None + return match.group(1), match.group(2) or "" + + +def _quoted_end(fragment: str, start: int, quote: str) -> Optional[int]: + index = start + 1 + escaped = False + while index < len(fragment): + char = fragment[index] + if quote == "'" and char == "'": + if index + 1 < len(fragment) and fragment[index + 1] == "'": + index += 2 + continue + return index + if quote == '"' and char == "\\" and not escaped: + escaped = True + index += 1 + continue + if char == quote and not escaped: + return index + escaped = False + index += 1 + return None + + +def _scalar_span(fragment: str) -> Optional[tuple[int, int]]: + start = len(fragment) - len(fragment.lstrip(" ")) + if start == len(fragment) or fragment[start] == "#": + return None + quote = fragment[start] if fragment[start] in "\"'" else "" + if quote: + end = _quoted_end(fragment, start, quote) + return (start + 1, end) if end is not None else None + end = start + while end < len(fragment) and fragment[end] not in " \t,#]\r\n": + end += 1 + return (start, end) if end > start else None + + +def _replace_scalar(fragment: str, stream: str, target: str) -> Edit: + span = _scalar_span(fragment) + if span is None: + return Edit(fragment, 0, frozenset()) + start, end = span + value = fragment[start:end] + candidate = _candidate(value, stream) + if candidate is None: + return Edit(fragment, 0, frozenset()) + old, suffix = candidate + if old == target: + return Edit(fragment, 0, frozenset()) + replacement = f"{target}{suffix}" + return Edit( + fragment[:start] + replacement + fragment[end:], + 1, + frozenset({old}), + ) + + +def _reject_multiline_quoted_scalar(fragment: str) -> None: + start = len(fragment) - len(fragment.lstrip(" ")) + if ( + start < len(fragment) + and fragment[start] in "\"'" + and _quoted_end(fragment, start, fragment[start]) is None + ): + raise UnsupportedYaml("multiline quoted tags values are not supported") + + +def _replace_inline_list(fragment: str, stream: str, target: str) -> Edit: + start = len(fragment) - len(fragment.lstrip(" ")) + _reject_multiline_quoted_scalar(fragment) + if start < len(fragment) and fragment[start] in "\"'": + return _replace_scalar(fragment, stream, target) + + opening = fragment.find("[", start) + if opening < 0: + return _replace_scalar(fragment, stream, target) + + closing = -1 + index = opening + 1 + while index < len(fragment): + char = fragment[index] + if char in "\"'": + quoted_end = _quoted_end(fragment, index, char) + if quoted_end is None: + raise UnsupportedYaml("unterminated quoted value under tags") + index = quoted_end + 1 + continue + if char == "]": + closing = index + break + index += 1 + if closing < 0: + raise UnsupportedYaml("multiline flow tags values are not supported") + + body = fragment[opening + 1 : closing] + parts: list[str] = [] + current: list[str] = [] + index = 0 + while index < len(body): + char = body[index] + if char in "\"'": + quoted_end = _quoted_end(body, index, char) + if quoted_end is None: + raise UnsupportedYaml("unterminated quoted value under tags") + current.extend(body[index : quoted_end + 1]) + index = quoted_end + 1 + continue + if char == ",": + parts.extend(("".join(current), ",")) + current = [] + else: + current.append(char) + index += 1 + parts.append("".join(current)) + + count = 0 + old_versions: set[str] = set() + for index in range(0, len(parts), 2): + edited = _replace_scalar(parts[index], stream, target) + parts[index] = edited.text + count += edited.count + old_versions.update(edited.old_versions) + rewritten = "".join(parts) + return Edit( + fragment[: opening + 1] + rewritten + fragment[closing:], + count, + frozenset(old_versions), + ) + + +def update_text(text: str, stream: str, target: str) -> Edit: + output: list[str] = [] + tags_indent: Optional[int] = None + tags_item_indent: Optional[int] = None + annotations_indent: Optional[int] = None + block_scalar_indent: Optional[int] = None + count = 0 + old_versions: set[str] = set() + + for line in text.splitlines(keepends=True): + content = line.rstrip("\r\n") + newline = line[len(content) :] + indent = len(content) - len(content.lstrip(" ")) + stripped = content.strip() + + if block_scalar_indent is not None: + if not stripped or stripped.startswith("#") or indent > block_scalar_indent: + output.append(content + newline) + continue + block_scalar_indent = None + + if annotations_indent is not None: + if not stripped or stripped.startswith("#") or indent > annotations_indent: + output.append(content + newline) + continue + annotations_indent = None + + if tags_indent is not None and stripped and not stripped.startswith("#"): + item = LIST_ITEM.match(content) + if indent < tags_indent or (indent == tags_indent and not item): + tags_indent = None + tags_item_indent = None + else: + if stripped.startswith("["): + raise UnsupportedYaml("multiline flow tags values are not supported") + if tags_item_indent is None: + tags_item_indent = indent if item else -1 + if item and indent == tags_item_indent: + _reject_multiline_quoted_scalar(item.group("rest")) + edited = _replace_scalar(item.group("rest"), stream, target) + content = item.group("prefix") + edited.text + count += edited.count + old_versions.update(edited.old_versions) + + mapping = MAPPING_KEY.match(content) + if mapping: + visible = mapping.group("rest").strip() + if mapping.group("key") == "annotations" and (not visible or visible.startswith("#")): + annotations_indent = len(mapping.group("indent")) + if BLOCK_SCALAR.fullmatch(visible): + block_scalar_indent = len(mapping.group("indent")) + + key = TAGS_KEY.match(content) + if key: + rest = key.group("rest") + visible = rest.lstrip(" ") + if not visible or visible.startswith("#"): + tags_indent = len(key.group("indent")) + tags_item_indent = None + else: + edited = _replace_inline_list(rest, stream, target) + content = content[: key.start("rest")] + edited.text + count += edited.count + old_versions.update(edited.old_versions) + + output.append(content + newline) + + return Edit("".join(output), count, frozenset(old_versions)) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Update one RHDH patch stream in the four canonical konflux-release-data RPA files." + ) + ) + parser.add_argument("version", help="target MAJOR.MINOR.PATCH version") + parser.add_argument( + "--repo-dir", + type=Path, + help=( + "konflux-release-data root or canonical RPA directory " + "(default: KONFLUX_RELEASE_DATA_REPO or the current directory)" + ), + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--dry-run", + action="store_true", + help="report changes without writing files", + ) + mode.add_argument( + "--local-only", + action="store_true", + help="write only the local working tree (the default)", + ) + parser.add_argument( + "--validate", + action="store_true", + help="run tox -e test after writing", + ) + return parser + + +def _absolute(path: Path) -> Path: + return Path(os.path.abspath(path)) + + +def _git_environment() -> dict[str, str]: + """Keep ambient Git repository/config overrides from defeating ``-C``.""" + return {key: value for key, value in os.environ.items() if not key.upper().startswith("GIT_")} + + +def _git(directory: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(directory), *args], + check=False, + capture_output=True, + env=_git_environment(), + text=True, + ) + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "git command failed" + raise ValueError(detail) + return result.stdout.strip() + + +def _resolve_checkout(input_path: Path, stream: str) -> tuple[Path, Path, tuple[Path, ...]]: + if not input_path.is_dir(): + raise ValueError(f"directory not found: {input_path}") + supplied = input_path.resolve(strict=True) + repository = Path(_git(supplied, "rev-parse", "--show-toplevel")).resolve(strict=True) + rpa_dir = repository / RPA_RELATIVE_DIR + if supplied not in (repository, rpa_dir): + raise ValueError(f"use the repository root or its canonical RPA directory: {rpa_dir}") + + dashed = stream.replace(".", "-") + paths = ( + rpa_dir / f"rhdh-{dashed}-prod.yaml", + rpa_dir / f"rhdh-{dashed}-stage.yaml", + rpa_dir / f"rhdh-plugin-catalog-{dashed}-prod.yaml", + rpa_dir / f"rhdh-plugin-catalog-{dashed}-stage.yaml", + ) + return repository, rpa_dir, paths + + +def _ensure_repository_identity(repository: Path) -> None: + remote = _git(repository, "remote", "get-url", "origin").removesuffix(".git") + if remote not in EXPECTED_REMOTES: + raise ValueError("origin is not releng/konflux-release-data on gitlab.cee.redhat.com") + + +def _ensure_clean_checkout(repository: Path) -> None: + if _git(repository, "status", "--porcelain", "--untracked-files=all"): + raise ValueError( + "repository has tracked or untracked changes; commit, stash, or remove them" + ) + + +def _validate_targets(rpa_dir: Path, paths: list[Path]) -> tuple[Path, ...]: + directory = _absolute(rpa_dir) + directory_stat = os.lstat(directory) + if stat.S_ISLNK(directory_stat.st_mode) or not stat.S_ISDIR(directory_stat.st_mode): + raise ValueError("the canonical RPA directory must be a physical directory") + physical_directory = directory.resolve(strict=True) + + normalized = tuple(_absolute(path) for path in paths) + if len(normalized) != 4 or len(set(normalized)) != 4: + raise ValueError("exactly four distinct RPA files are required") + for path in normalized: + if path.parent != directory: + raise ValueError(f"target is not directly inside the canonical RPA directory: {path}") + target_stat = os.lstat(path) + if stat.S_ISLNK(target_stat.st_mode) or not stat.S_ISREG(target_stat.st_mode): + raise ValueError(f"target must be a physical regular file: {path}") + if path.resolve(strict=True).parent != physical_directory: + raise ValueError(f"physical target escapes the canonical RPA directory: {path}") + return normalized + + +def _stage(path: Path, content: bytes, mode: int, suffix: str) -> Path: + descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=suffix, + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temporary, mode) + except BaseException: + temporary.unlink(missing_ok=True) + raise + return temporary + + +def _write_atomically( + edits: dict[Path, Edit], originals: dict[Path, bytes], modes: dict[Path, int] +) -> None: + staged: dict[Path, Path] = {} + backups: dict[Path, Path] = {} + replaced: list[Path] = [] + preserve_backups: set[Path] = set() + try: + for path, edit in edits.items(): + staged[path] = _stage(path, edit.text.encode("utf-8"), modes[path], ".stage") + backups[path] = _stage(path, originals[path], modes[path], ".backup") + for path in edits: + os.replace(staged[path], path) + replaced.append(path) + except BaseException as replace_error: + preserve_backups.update(replaced) + rollback_errors: list[tuple[Path, BaseException]] = [] + for path in reversed(replaced): + try: + os.replace(backups[path], path) + preserve_backups.discard(path) + except BaseException as rollback_error: + rollback_errors.append((path, rollback_error)) + + if isinstance(replace_error, (KeyboardInterrupt, SystemExit)): + raise + rollback_interrupt = next( + ( + error + for _, error in rollback_errors + if isinstance(error, (KeyboardInterrupt, SystemExit)) + ), + None, + ) + if rollback_interrupt is not None: + raise rollback_interrupt from replace_error + if rollback_errors: + detail = "; ".join(f"{path}: {error}" for path, error in rollback_errors) + raise OSError(f"{replace_error}; rollback failed for {detail}") from replace_error + raise + finally: + for temporary in staged.values(): + temporary.unlink(missing_ok=True) + for path, temporary in backups.items(): + if path not in preserve_backups: + temporary.unlink(missing_ok=True) + + +def _update_files( + rpa_dir: Path, + paths: tuple[Path, ...], + stream: str, + target: str, + *, + write: bool, +) -> dict[str, object]: + edits: dict[Path, Edit] = {} + originals: dict[Path, bytes] = {} + modes: dict[Path, int] = {} + normalized = _validate_targets(rpa_dir, list(paths)) + for path in normalized: + original = path.read_bytes() + source = original.decode("utf-8") + originals[path] = original + modes[path] = stat.S_IMODE(os.lstat(path).st_mode) + edits[path] = update_text(source, stream, target) + + replacement_count = sum(edit.count for edit in edits.values()) + old_versions = sorted( + {version for edit in edits.values() for version in edit.old_versions}, + key=lambda version: tuple(int(part) for part in version.split(".")), + ) + if replacement_count == 0: + raise ValueError(f"no stale {stream}.PATCH tag values found") + + if write: + _write_atomically(edits, originals, modes) + + return { + "stream": stream, + "target": target, + "write": write, + "old_versions": old_versions, + "replacement_count": replacement_count, + "files": {str(path): edit.count for path, edit in edits.items()}, + } + + +def _run_validation(repository: Path) -> None: + tox = shutil.which("tox") + if tox is None: + raise ValueError("--validate requires tox on PATH") + result = subprocess.run([tox, "-e", "test"], cwd=repository, check=False) + if result.returncode != 0: + raise RuntimeError(f"tox -e test failed with exit code {result.returncode}") + + +def main(argv: Optional[list[str]] = None) -> int: + parser = _parser() + args = parser.parse_args(argv) + match = re.fullmatch(r"([0-9]+\.[0-9]+)\.[0-9]+", args.version) + if match is None: + parser.error("version must be MAJOR.MINOR.PATCH (for example, 1.9.7)") + if args.dry_run and args.validate: + parser.error("--validate cannot be combined with --dry-run") + + stream = match.group(1) + requested = args.repo_dir + if requested is None: + requested = Path(os.environ.get("KONFLUX_RELEASE_DATA_REPO", os.getcwd())) + + try: + repository, rpa_dir, paths = _resolve_checkout(requested, stream) + write = not args.dry_run + if write: + _ensure_repository_identity(repository) + _ensure_clean_checkout(repository) + report = _update_files( + rpa_dir, + paths, + stream, + args.version, + write=write, + ) + if args.validate: + _run_validation(repository) + except (OSError, UnicodeError, ValueError, RuntimeError) as error: + print(json.dumps({"error": f"{type(error).__name__}: {error}"}), file=sys.stderr) + return 2 + + print(json.dumps(report, sort_keys=True)) + state = "Dry run complete; the checkout is unchanged" + if report["write"]: + state = "Local-only update complete; nothing was staged, committed, pushed, or opened" + print(state, file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/ci/rhdh-konflux-rpa/workflows/update-rpa.md b/skills/ci/rhdh-konflux-rpa/workflows/update-rpa.md new file mode 100644 index 0000000..e086a44 --- /dev/null +++ b/skills/ci/rhdh-konflux-rpa/workflows/update-rpa.md @@ -0,0 +1,117 @@ +# Update RHDH release-data RPA tags + +Given `MAJOR.MINOR.PATCH`, update the matching stream's four +ReleasePlanAdmission files in a user-provided `konflux-release-data` checkout. + +## 1. Resolve the checkout and preflight + +Never assume a checkout path. Accept the repository root or its +`config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/` directory. +Resolve the owning repository with `git rev-parse --show-toplevel`. + +Before local branch or file mutation, require Git 2.x, Python 3.9+, and the +canonical repository identity. Run these checks without printing credentials: + +```bash +git --version +python3 --version +git -C "${REPO}" remote get-url origin +``` + +Stop before creating a branch when the required runtime is unavailable or +`origin` is not exactly the canonical `releng/konflux-release-data` repository. +Before proposing publication, also run `glab --version` and +`glab auth status --hostname gitlab.cee.redhat.com`. `tox` is optional and needed +only when the user requests repository schema validation. + +## 2. Inspect without changing the checkout + +Run the bundled script in dry-run mode first. This command performs no fetch, +checkout, branch, file, commit, push, browser, or merge-request operation: + +```bash +SKILL_DIR=/absolute/path/to/installed/rhdh-konflux-rpa +SCRIPT="${SKILL_DIR}/scripts/update_rpa_tags.py" +python3 "${SCRIPT}" 1.9.7 --repo-dir "${REPO}" --dry-run +``` + +Confirm the stream and replacement counts. The script must select exactly: + +- `rhdh-MAJOR-MINOR-prod.yaml` +- `rhdh-MAJOR-MINOR-stage.yaml` +- `rhdh-plugin-catalog-MAJOR-MINOR-prod.yaml` +- `rhdh-plugin-catalog-MAJOR-MINOR-stage.yaml` + +It preserves stream tags such as `1.9` and plugin suffixes such as `--1.20.2`. +It rejects a symlinked canonical directory, symlinked or non-regular target +files, physical targets outside that directory, and multiline flow-style +`tags` values before writing. A local update stages all four replacements in +their target directory and restores the original bytes and modes if a replace +fails; it must never report a partial update as successful. + +## 3. Prepare a local review branch and diff + +Require a clean checkout. Create a new local branch from the user's chosen base, +then run the script in local-only mode: + +```bash +test -z "$(git -C "${REPO}" status --porcelain --untracked-files=all)" +git -C "${REPO}" fetch origin main +git -C "${REPO}" switch -c "chore/rhdh-update-rpa-1.9.7" "origin/main" +python3 "${SCRIPT}" 1.9.7 --repo-dir "${REPO}" --local-only +git -C "${REPO}" diff --check +git -C "${REPO}" diff -- \ + config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/rhdh-1-9-prod.yaml \ + config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/rhdh-1-9-stage.yaml \ + config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/rhdh-plugin-catalog-1-9-prod.yaml \ + config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/rhdh-plugin-catalog-1-9-stage.yaml +``` + +`--local-only` edits only those files. It never stages, commits, fetches, pushes, +calls `glab`, or opens a browser. If validation is requested, add `--validate` or +run `tox -e test` after the edit. Show the diff and validation result before any +publication plan. + +The user's request to update these tags authorizes the local review branch and +the four named file edits after the dry run. Do not invoke `/mutation-gate` for +those local operations. Commit, push, and merge-request creation remain gated. + +## 4. Build the fixed merge-request payload + +Read `references/mr-body.md` and replace only `{new_version}`, `{stream}`, and +`{old_versions}`. Keep `Generated-by: cursor`, the headings, and the empty +Tickets section. The repository's static prose linter checks this fixed template +in flavored mode; do not invoke a prose editor at runtime. + +Use this title: + +```text +chore: update rhdh-MAJOR-MINOR-*.yaml RPAs for upcoming release MAJOR.MINOR.PATCH +``` + +Write the filled body to a unique temporary file for the preview and command. +Invoke `/rhdh-forge` with the GitLab host, `releng/konflux-release-data`, exact +base and head branches, title, and body file. It returns the exact merge-request +command and payload without executing them. Use that returned command unchanged +in the write plan. + +## 5. Gate commit, push, and merge request + +Invoke `/mutation-gate` once with three ordered operations: + +| Operation | Target and exact command | Preview and verification | Failure behavior | +|---|---|---|---| +| Commit | The four named RPA paths; exact `git add -- ` and `git commit -s -m ` commands | Show the diff and commit subject; verify `git show --stat --oneline HEAD` | Stop before push | +| Push | `origin`, exact branch, and `git push -u origin ` | Show remote URL, branch, and commit SHA; verify with `git ls-remote origin refs/heads/` | Stop before MR | +| Merge request | `releng/konflux-release-data`, exact command returned by `/rhdh-forge` | Show target branch, title, and the filled body from the temporary file; parse the returned URL with Python 3 and verify `changes_count` is nonzero | Report the pushed branch and leave MR uncreated | + +Wait for approval of that exact set. If the diff, branch, target, title, or body +changes, render and approve a new set. Execute in order and report every outcome, +including skipped operations. + +## 6. Report + +Report old and new versions, four file paths, replacement count, validation, +local commit SHA, pushed branch, merge-request URL, and the outcome of every +gated operation. When approval was withheld, state that nothing was pushed and +no merge request was opened. diff --git a/skills/ci/rhdh-konflux-tasks/scripts/update-rpa-tags.sh b/skills/ci/rhdh-konflux-tasks/scripts/update-rpa-tags.sh deleted file mode 100755 index 4ed4cc8..0000000 --- a/skills/ci/rhdh-konflux-tasks/scripts/update-rpa-tags.sh +++ /dev/null @@ -1,478 +0,0 @@ -#!/usr/bin/env bash -# -# Bump RHDH ReleasePlanAdmission tag versions in konflux-release-data. -# See the local rhdh-release workflow: workflows/konflux-rpa-update.md -# -# SPDX-License-Identifier: EPL-2.0 - -set -euo pipefail - -NEW_VERSION="" -REPO_DIR="" -RPA_DIR="" - -readonly PUSH_REMOTE="origin" -readonly GITLAB_PROJECT="releng/konflux-release-data" -readonly RPA_REL_DIR="config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh" - -DRY_RUN=0 -VALIDATE=0 -BASE_BRANCH="main" - -usage() { - cat <<'EOF' -Update RHDH ReleasePlanAdmission tags for a stream release in konflux-release-data. - -Usage: - update-rpa-tags.sh VERSION [OPTIONS] - -Arguments: - VERSION Target RHDH version (e.g. 1.9.7, 1.10.3) - -Options: - --repo-dir PATH konflux-release-data checkout (default: $PWD) - --base-branch BRANCH Target branch for merge request (default: main) - --dry-run Preview tag changes without writing, committing, pushing, or opening an MR - --validate Run `tox -e test` after editing (requires tox in repo) - -h, --help Show this help - -Examples: - cd /path/to/konflux-release-data && update-rpa-tags.sh 1.9.7 - update-rpa-tags.sh 1.9.7 --repo-dir /path/to/konflux-release-data - update-rpa-tags.sh 1.10.3 --dry-run -EOF -} - -die() { - echo "[ERROR] $*" >&2 - exit 1 -} - -log() { - echo "[INFO] $*" >&2 -} - -warn() { - echo "[WARN] $*" >&2 -} - -validate_version() { - local version="$1" - [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ - || die "Invalid version '${version}'. Expected MAJOR.MINOR.PATCH (e.g. 1.9.7)" -} - -version_stream() { - local version="$1" - echo "${version%.*}" -} - -version_stream_dashed() { - local stream - stream=$(version_stream "$1") - echo "${stream//./-}" -} - -is_rpa_directory() { - local dir="$1" - local matches=() - - [[ -d "${dir}" ]] || return 1 - shopt -s nullglob - matches=("${dir}"/rhdh-*.yaml "${dir}"/rhdh-plugin-catalog-*.yaml) - shopt -u nullglob - ((${#matches[@]} > 0)) -} - -resolve_paths() { - local input="${REPO_DIR:-${KONFLUX_RELEASE_DATA_REPO:-${PWD}}}" - local git_root - - [[ -d "${input}" ]] || die "Directory not found: ${input}" - input=$(cd "${input}" && pwd) - - if is_rpa_directory "${input}"; then - RPA_DIR="${input}" - git_root=$(git -C "${input}" rev-parse --show-toplevel 2>/dev/null) \ - || die "RPA directory is not inside a git repository: ${input}" - REPO_DIR="${git_root}" - log "Using RPA directory: ${RPA_DIR}" - return - fi - - if [[ -d "${input}/${RPA_REL_DIR}" ]] && is_rpa_directory "${input}/${RPA_REL_DIR}"; then - git_root=$(git -C "${input}" rev-parse --show-toplevel 2>/dev/null) \ - || die "Directory is not inside a git repository: ${input}" - REPO_DIR="${git_root}" - RPA_DIR="${input}/${RPA_REL_DIR}" - log "Using repository: ${REPO_DIR}" - log "Using RPA directory: ${RPA_DIR}" - return - fi - - die "Could not find RPA YAML files at ${input}/${RPA_REL_DIR}. Pass --repo-dir to the konflux-release-data root." -} - -collect_target_files() { - local rpa_dir="$1" - local stream_dashed="$2" - local file - - shopt -s nullglob - for file in \ - "${rpa_dir}/rhdh-${stream_dashed}-prod.yaml" \ - "${rpa_dir}/rhdh-${stream_dashed}-stage.yaml" \ - "${rpa_dir}/rhdh-plugin-catalog-${stream_dashed}-prod.yaml" \ - "${rpa_dir}/rhdh-plugin-catalog-${stream_dashed}-stage.yaml" - do - [[ -f "${file}" ]] || die "Expected RPA file not found: ${file}" - printf '%s\n' "${file}" - done - shopt -u nullglob -} - -collect_stale_versions() { - local stream="$1" - shift - local files=("$@") - local file - local matches=() - - for file in "${files[@]}"; do - while IFS= read -r match; do - [[ -n "${match}" ]] && matches+=("${match}") - done < <(grep -oE "\"${stream//./\\.}\\.[0-9]+\"" "${file}" \ - | tr -d '"' \ - | sort -u) - done - - if ((${#matches[@]} == 0)); then - die "Could not detect current patch version in target RPAs for stream ${stream}" - fi - - printf '%s\n' "${matches[@]}" | sort -uV | while IFS= read -r candidate; do - if [[ "${candidate}" != "${NEW_VERSION}" ]]; then - printf '%s\n' "${candidate}" - fi - done -} - -detect_from_version() { - local versions - mapfile -t versions < <(collect_stale_versions "$@") - - if ((${#versions[@]} == 0)); then - die "All tag versions already appear to be ${NEW_VERSION}. Nothing to update." - fi - - if ((${#versions[@]} > 1)); then - warn "Multiple stale patch versions found: ${versions[*]}. Replacing all with ${NEW_VERSION}." - fi - - printf '%s\n' "${versions[@]}" -} - -count_replacements() { - local from_version="$1" - local file="$2" - - if grep -q "${from_version}" "${file}" 2>/dev/null; then - grep -o "${from_version}" "${file}" | wc -l | tr -d ' ' - else - echo 0 - fi -} - -apply_replacements() { - local from_version="$1" - local file="$2" - local count - - count=$(count_replacements "${from_version}" "${file}") - if [[ "${count}" == "0" ]]; then - return 0 - fi - - log "Updating ${count} tag reference(s) in $(basename "${file}") (${from_version} -> ${NEW_VERSION})" - if [[ ${DRY_RUN} -eq 1 ]]; then - return 0 - fi - - sed -i "s/${from_version}/${NEW_VERSION}/g" "${file}" -} - -open_url_in_browser() { - local url="$1" - - if command -v brave-browser >/dev/null 2>&1; then - log "Opening merge request in Brave" - brave-browser "${url}" >/dev/null 2>&1 & - elif command -v google-chrome >/dev/null 2>&1; then - log "Opening merge request in Chrome" - google-chrome "${url}" >/dev/null 2>&1 & - elif command -v firefox >/dev/null 2>&1; then - log "Opening merge request in Firefox" - firefox "${url}" >/dev/null 2>&1 & - elif command -v xdg-open >/dev/null 2>&1; then - log "Opening merge request via xdg-open" - xdg-open "${url}" >/dev/null 2>&1 & - else - warn "No supported browser found. Open manually: ${url}" - fi -} - -ensure_clean_enough() { - local repo_dir="$1" - - if [[ ${DRY_RUN} -eq 1 ]]; then - return 0 - fi - - if ! git -C "${repo_dir}" diff --quiet || ! git -C "${repo_dir}" diff --cached --quiet; then - die "Repository has uncommitted changes. Commit or stash before running." - fi -} - -ensure_push_remote_is_upstream() { - local repo_dir="$1" - local url - - url=$(git -C "${repo_dir}" remote get-url "${PUSH_REMOTE}") - if [[ "${url}" == *"rhdh-bot/"* ]] || [[ "${url}" == *"rhdh-bot:"* ]]; then - die "origin remote points to fork (${url}). Expected releng/konflux-release-data." - fi - if [[ "${url}" != *"releng/konflux-release-data"* ]]; then - warn "origin remote is not releng/konflux-release-data: ${url}" - fi -} - -verify_branch_on_remote() { - local repo_dir="$1" - local branch="$2" - local local_sha - local remote_sha - - local_sha=$(git -C "${repo_dir}" rev-parse "${branch}") - remote_sha=$(git -C "${repo_dir}" ls-remote "${PUSH_REMOTE}" "refs/heads/${branch}" | awk '{print $1}') - - [[ -n "${remote_sha}" ]] || die "Branch ${branch} was not found on ${PUSH_REMOTE} after push" - [[ "${local_sha}" == "${remote_sha}" ]] \ - || die "Branch ${branch} on ${PUSH_REMOTE} (${remote_sha}) does not match local commit (${local_sha})" -} - -verify_merge_request_has_changes() { - local project="$1" - local branch="$2" - local changes_count - - changes_count=$(glab api "projects/${project//\//%2F}/merge_requests" \ - --method GET \ - -f source_branch="${branch}" \ - -f state=opened \ - -f per_page=1 2>/dev/null \ - | python3 -c 'import json,sys; data=json.load(sys.stdin); print(data[0].get("changes_count") if data else "")' 2>/dev/null || true) - - if [[ -z "${changes_count}" || "${changes_count}" == "0" || "${changes_count}" == "None" ]]; then - die "Merge request for ${branch} has no changes. Ensure the branch was pushed to origin (${project}), not a fork." - fi - - log "Merge request includes ${changes_count} changed file(s)" -} - -create_merge_request() { - local repo_dir="$1" - local branch="$2" - local stream_dashed="$3" - local title - local description - local mr_url - - command -v glab >/dev/null 2>&1 || die "glab is required to create a merge request" - - ensure_push_remote_is_upstream "${repo_dir}" - - title="chore: update rhdh-${stream_dashed}-*.yaml RPAs for upcoming release ${NEW_VERSION}" - description="Generated-by: cursor - -#### What: - -chore: update rhdh-${stream_dashed}-*.yaml RPAs for upcoming release ${NEW_VERSION} - -Bump ReleasePlanAdmission tags to ${NEW_VERSION} for the ${stream_dashed} stream." - - if ((${#FROM_VERSIONS[@]} == 1)); then - description="${description} -Replaced ${FROM_VERSIONS[0]} tag references." - else - description="${description} -Replaced stale patch tag references: ${FROM_VERSIONS[*]}." - fi - - description="${description} - -#### Why: - -Prepare konflux-release-data for the upcoming RHDH ${NEW_VERSION} release. - -#### Tickets:" - - log "Pushing branch ${branch} to ${PUSH_REMOTE} (${GITLAB_PROJECT})" - pushd "${repo_dir}" >/dev/null - git push -u "${PUSH_REMOTE}" "${branch}" - verify_branch_on_remote "${repo_dir}" "${branch}" - - log "Creating merge request in ${GITLAB_PROJECT} (${branch} -> ${BASE_BRANCH})" - mr_url=$(glab api --method POST "projects/${GITLAB_PROJECT//\//%2F}/merge_requests" \ - -f source_branch="${branch}" \ - -f target_branch="${BASE_BRANCH}" \ - -f title="${title}" \ - -f description="${description}" \ - -f remove_source_branch=true 2>/dev/null \ - | python3 -c 'import json,sys; m=json.load(sys.stdin); print(m["web_url"])') || { - popd >/dev/null - die "glab api merge_requests create failed (check glab auth: glab auth login -h gitlab.cee.redhat.com)" - } - - verify_merge_request_has_changes "${GITLAB_PROJECT}" "${branch}" - popd >/dev/null - - [[ -n "${mr_url}" ]] || die "Merge request created but URL was empty" - log "Merge request: ${mr_url}" - open_url_in_browser "${mr_url}" -} - -while [[ $# -gt 0 ]]; do - case "$1" in - -h | --help) - usage - exit 0 - ;; - --repo-dir) - REPO_DIR=$2 - shift 2 - ;; - --base-branch) - BASE_BRANCH=$2 - shift 2 - ;; - --dry-run) - DRY_RUN=1 - shift - ;; - --validate) - VALIDATE=1 - shift - ;; - -*) - die "Unknown option: $1" - ;; - *) - if [[ -z "${NEW_VERSION}" ]]; then - NEW_VERSION=$1 - else - die "Unexpected argument: $1" - fi - shift - ;; - esac -done - -[[ -n "${NEW_VERSION}" ]] || { - usage - exit 1 -} - -validate_version "${NEW_VERSION}" - -REPO_DIR="" -RPA_DIR="" -resolve_paths - -STREAM=$(version_stream "${NEW_VERSION}") -STREAM_DASHED=$(version_stream_dashed "${NEW_VERSION}") - -mapfile -t TARGET_FILES < <(collect_target_files "${RPA_DIR}" "${STREAM_DASHED}") - -mapfile -t FROM_VERSIONS < <(detect_from_version "${STREAM}" "${TARGET_FILES[@]}") - -if ((${#FROM_VERSIONS[@]} == 0)); then - die "No source versions to replace" -fi - -for FROM_VERSION in "${FROM_VERSIONS[@]}"; do - if [[ "${FROM_VERSION}" == "${NEW_VERSION}" ]]; then - die "Source and target versions are the same (${NEW_VERSION})" - fi -done - -log "Repository: ${REPO_DIR}" -log "Stream: ${STREAM} (${STREAM_DASHED})" -if ((${#FROM_VERSIONS[@]} == 1)); then - log "Updating tags: ${FROM_VERSIONS[0]} -> ${NEW_VERSION}" -else - log "Updating tags: ${FROM_VERSIONS[*]} -> ${NEW_VERSION}" -fi -log "Target files:" -for file in "${TARGET_FILES[@]}"; do - log " - $(basename "${file}")" -done - -TOTAL=0 -for FROM_VERSION in "${FROM_VERSIONS[@]}"; do - for file in "${TARGET_FILES[@]}"; do - count=$(count_replacements "${FROM_VERSION}" "${file}") - TOTAL=$((TOTAL + count)) - done -done - -if [[ "${TOTAL}" == "0" ]]; then - warn "No stale tag versions found in target RPAs; nothing changed" - exit 0 -fi - -if [[ ${DRY_RUN} -eq 1 ]]; then - for FROM_VERSION in "${FROM_VERSIONS[@]}"; do - for file in "${TARGET_FILES[@]}"; do - count=$(count_replacements "${FROM_VERSION}" "${file}") - if [[ "${count}" != "0" ]]; then - log "Would update ${count} tag reference(s) in $(basename "${file}") (${FROM_VERSION} -> ${NEW_VERSION})" - fi - done - done - log "Dry run complete (${TOTAL} replacement(s) would be made)" - exit 0 -fi - -ensure_clean_enough "${REPO_DIR}" - -BRANCH="chore/rhdh-update-rpa-${NEW_VERSION}" -COMMIT_SUBJECT="chore: update rhdh-${STREAM_DASHED}-*.yaml RPAs for upcoming release ${NEW_VERSION}" - -log "Creating branch ${BRANCH} from ${BASE_BRANCH}" -git -C "${REPO_DIR}" fetch origin "${BASE_BRANCH}" -git -C "${REPO_DIR}" checkout "${BASE_BRANCH}" -git -C "${REPO_DIR}" pull --ff-only origin "${BASE_BRANCH}" -git -C "${REPO_DIR}" checkout -B "${BRANCH}" - -for FROM_VERSION in "${FROM_VERSIONS[@]}"; do - for file in "${TARGET_FILES[@]}"; do - apply_replacements "${FROM_VERSION}" "${file}" - done -done - -REL_PATHS=() -for file in "${TARGET_FILES[@]}"; do - REL_PATHS+=("${file#"${REPO_DIR}"/}") -done - -git -C "${REPO_DIR}" add "${REL_PATHS[@]}" -git -C "${REPO_DIR}" commit -s -m "${COMMIT_SUBJECT}" - -if [[ ${VALIDATE} -eq 1 ]]; then - log "Running tox -e test" - (cd "${REPO_DIR}" && tox -e test) -fi - -create_merge_request "${REPO_DIR}" "${BRANCH}" "${STREAM_DASHED}" - -log "Done" diff --git a/skills/ci/rhdh-konflux-tasks/workflows/konflux-rpa-update.md b/skills/ci/rhdh-konflux-tasks/workflows/konflux-rpa-update.md deleted file mode 100644 index ba7b35a..0000000 --- a/skills/ci/rhdh-konflux-tasks/workflows/konflux-rpa-update.md +++ /dev/null @@ -1,128 +0,0 @@ -# Konflux release-data RPA updates - -## Goal - -Given a target RHDH version (for example `1.9.7`), update the matching stream -ReleasePlanAdmission files under -`config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/` so tag values -use the new patch version, then push a branch and open a GitLab merge request. - -## Prerequisites - -- Local clone of [konflux-release-data](https://gitlab.cee.redhat.com/releng/konflux-release-data) -- `git`, `glab` (GitLab CLI authenticated to `gitlab.cee.redhat.com`) -- Push access to `origin` (`git@gitlab.cee.redhat.com:releng/konflux-release-data.git`) -- Optional: `tox` when using `--validate` - -Run from the user's konflux-release-data checkout — **never assume a fixed path**. -Pass `--repo-dir` or `cd` into the target folder before invoking the script. - -Working directory resolution (in order): - -1. `--repo-dir PATH` when provided -2. `KONFLUX_RELEASE_DATA_REPO` when set -3. Current working directory (`$PWD`) - -`PATH` may be the **repository root**, or the **rhdh ReleasePlanAdmission folder** (the script resolves the standard path -`config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh/` from there). - -## Run the bundled script - -**Execute** [scripts/update-rpa-tags.sh](../scripts/update-rpa-tags.sh); do not -reimplement the workflow inline. Always run it against the folder the user -specified. - -```bash -SKILL_DIR=/absolute/path/to/installed/rhdh-konflux-tasks -SCRIPT="${SKILL_DIR}/scripts/update-rpa-tags.sh" -chmod +x "${SCRIPT}" -REPO=/path/to/konflux-release-data # user-provided checkout - -# From repo root -cd "${REPO}" && "${SCRIPT}" 1.9.7 - -# Or pass the checkout explicitly -"${SCRIPT}" 1.9.7 --repo-dir "${REPO}" - -# Preview only -"${SCRIPT}" 1.9.7 --repo-dir "${REPO}" --dry-run -``` - -## Which files change - -For version `MAJOR.MINOR.PATCH`, the script updates the `MAJOR-MINOR` stream only: - -| Version example | Stream | Files updated | -|-----------------|--------|---------------| -| `1.9.7` | `1.9` | `rhdh-1-9-prod.yaml`, `rhdh-1-9-stage.yaml`, `rhdh-plugin-catalog-1-9-prod.yaml`, `rhdh-plugin-catalog-1-9-stage.yaml` | -| `1.10.2` | `1.10` | `rhdh-1-10-*.yaml`, `rhdh-plugin-catalog-1-10-*.yaml` (prod + stage) | - -**Not** updated by this script: `-fbc-` RPAs, `1.next` / `1-stage` catalog files, -builder RPAs, or other streams. - -## Tag replacement rules - -1. Auto-detect stale patch versions from tag strings in the target files - (for example `1.9.6` when bumping to `1.9.7`). -2. Replace every occurrence of each stale patch with the target version. -3. Keep the stream tag unchanged (`"1.9"` stays `"1.9"`). -4. Composite plugin catalog tags are updated in the RHDH prefix only - (`1.9.6--1.20.2` → `1.9.7--1.20.2`); upstream plugin semver suffixes are - preserved unless a separate plugin-catalog bump MR is needed. - -Hub/operator RPAs (`rhdh-1-9-*.yaml`) only carry tags under `defaults.tags`. -Plugin catalog RPAs also update per-component `tags` lists. - -## Merge request workflow - -On success the script: - -1. Fetches and checks out `main` -2. Creates branch `chore/rhdh-update-rpa-` -3. Commits with signed-off message: - `chore: update rhdh--*.yaml RPAs for upcoming release ` -4. Pushes to `origin` (`releng/konflux-release-data`) -5. Creates a GitLab MR in **`releng/konflux-release-data`** via `glab api` on the upstream project (not `glab mr create`, which may route through the `rhdh-bot` fork and produce an empty diff) -6. MR description starts with **`Generated-by: cursor`** -7. Verifies the MR includes file changes before opening the browser -8. Opens the MR URL in the first available browser: **Brave → Chrome → Firefox** - -Use `--dry-run` to preview tag changes without modifying files or opening an MR. - -## Validation - -Run schema tests after editing when CI credentials are available: - -```bash -"${SCRIPT}" 1.9.7 --repo-dir "${REPO}" --validate -``` - -Or from the repo root: `tox -e test`. - -## Related manual steps - -This script handles **RHDH patch tag bumps** in konflux-release-data only. It -does **not**: - -- Bump upstream plugin semver suffixes in composite tags (see historical MRs - titled `chore(rhdh): update plugin catalog … RPAs to … tags`) -- Update Konflux tenant snapshots or components in `tenants-config/` -- Trigger releases in Konflux - -For Tekton digest bumps in midstream repositories, follow -[konflux-task-update.md](konflux-task-update.md) instead. - -## Anti-patterns - -- Assuming a hardcoded checkout path; always use the user's folder via `--repo-dir` or `cd`. -- Pushing to `rhdh-bot` or any fork remote; push is always to `origin`. -- Creating cross-project MRs from a fork when the branch exists only on `origin`. -- Editing FBC or `1.next` RPAs as part of a stream patch bump. -- Pushing directly to `main`; always use an MR. -- Running with a dirty working tree (uncommitted changes block the script). - -## Additional resources - -- Example hub bump: `8e378a51be` — `rhdh-1-9-*.yaml` to `1.9.7` -- Example catalog bump: `50445ad9f1` — all plugin catalog component tags -- Repo guide: `konflux-release-data` `AGENTS.md` and `.cursor/rules/konflux-release-data.mdc` diff --git a/skills/ci/rhdh-prow-release-branch/SKILL.md b/skills/ci/rhdh-prow-release-branch/SKILL.md index ee29b4b..731595d 100644 --- a/skills/ci/rhdh-prow-release-branch/SKILL.md +++ b/skills/ci/rhdh-prow-release-branch/SKILL.md @@ -10,7 +10,7 @@ description: >- "commission the 1.12 branch jobs", or "decommission 1.8". Prow configuration for a release branch only — the release itself is rhdh-release-status, rhdh-release-schedule, and rhdh-release-announce. -compatibility: "A local openshift/release checkout with make and git; Slack app admin and Vault access for the per-branch alert webhook." +compatibility: "A local openshift/release checkout with make and git; gh authenticated for pull-request creation; Slack app admin and Vault access for the per-branch alert webhook." --- # RHDH release-branch Prow configuration @@ -36,11 +36,16 @@ belong to `/rhdh-prow-jobs`. differences, and the Slack alert setup that both workflows use. Read it with whichever workflow you loaded. -## Writing rules +## Local preparation and publication -Copying the config, editing `_prowconfig.yaml`, deleting files, running -`make update`, committing, pushing, and opening a pull request are writes. Follow -`/mutation-gate`, naming each file path as the target of its operation. +Creating the local review branch, copying or deleting the config, editing +`_prowconfig.yaml`, and running `make update` are local preparation authorized by +the user's commission or decommission request. Make and validate those local +changes without a write gate. + +Follow `/mutation-gate` for commit, push, and pull request creation only. Treat +them as one ordered publication set, name every reviewed path and external +target, and stop before each later operation when an earlier one fails. - Never hardcode the branch-protection block or the release-branch adjustments. Read the latest existing release branch and copy its current shape; required diff --git a/skills/ci/rhdh-prow-release-branch/workflows/commission-release.md b/skills/ci/rhdh-prow-release-branch/workflows/commission-release.md index 5487089..d758b06 100644 --- a/skills/ci/rhdh-prow-release-branch/workflows/commission-release.md +++ b/skills/ci/rhdh-prow-release-branch/workflows/commission-release.md @@ -10,40 +10,78 @@ Read `../references/release-branch-config.md` for file paths, templates, and rel - If not provided, ask the user for the version (e.g. `1.11`) - Verify the config does NOT already exist: `ls ci-operator/config/redhat-developer/rhdh/redhat-developer-rhdh-release-{version}.yaml` -2. **Choose the source config**: +2. **Prepare a clean local branch**: + - Run `git status --porcelain --untracked-files=all` in the checkout. Stop if + it prints any tracked or untracked path; do not mix this work with an + existing change. + - From the user-selected base branch, run + `git switch -c "ci/rhdh-{version}-release-branch" ""`. + - The branch and file edits below are local preparation, not external + operations. Approval for publishing them happens only in the final step. + +3. **Choose the source config**: - List existing configs: `ls ci-operator/config/redhat-developer/rhdh/redhat-developer-rhdh-release-*.yaml` - Default to the latest existing release branch (highest version number) - Alternatively, use `main` as the source if the user prefers -3. **Copy and adjust the CI config**: +4. **Copy and adjust the CI config**: - Copy the source file to `redhat-developer-rhdh-release-{version}.yaml` - Read both the `main` config and the latest existing release branch config to understand the current patterns - Apply all structural adjustments described in `../references/release-branch-config.md` — compare main vs release branch to determine what to change (Slack channel, cron schedule, cleanup jobs, presubmit settings) - Set `zz_generated_metadata.branch` to `release-{version}` -4. **Confirm version-specific settings** with the user: +5. **Confirm version-specific settings** with the user: - OCP versions: which `e2e-ocp-v4-{VER}-helm-nightly` entries to include - K8s version (`MAPT_KUBERNETES_VERSION`) - OSD version - `build_root` tag - If copying from the latest release branch, these are often unchanged -5. **Set up Slack alerts** (see `../references/release-branch-config.md` > Slack Alert Setup): +6. **Set up Slack alerts** (see `../references/release-branch-config.md` > Slack Alert Setup): - Create Slack channel `#rhdh-e2e-alerts-{X}-{Y}` and incoming webhook - Add webhook URL to Vault secret `rhdh-send-alert` as key `SLACK_ALERTS_WEBHOOK_URL_{X}_{Y}` - Set `reporter_config.channel` to `#rhdh-e2e-alerts-{X}-{Y}` on every nightly test entry in the CI config -6. **Add branch protection** to `_prowconfig.yaml`: +7. **Add branch protection** to `_prowconfig.yaml`: - Read the latest existing release branch entry from `_prowconfig.yaml` to get the current structure and contexts - Add a `release-{version}:` entry under `branch-protection.orgs.redhat-developer.repos.rhdh.branches`, copying the structure from the latest release branch - Place the new entry in version order among existing entries -7. **Run `make update`** to regenerate Prow job configs +8. **Run `make update`** to regenerate Prow job configs -8. **Verify and summarize**: +9. **Validate and summarize**: + - Run `git diff --check` and stop on any whitespace error. - Confirm generated job files exist: `ls ci-operator/jobs/redhat-developer/rhdh/redhat-developer-rhdh-release-{version}-*.yaml` + - Inspect `git status --short` and the complete diff. Confirm every changed + path belongs to the release config, its generated jobs, or branch + protection; stop on an unrelated path. - Show a summary of what was created +10. **Author and publish the pull request**: + +- Use a direct title such as `ci: add RHDH {version} release branch jobs`. +- In the body, summarize the config, generated jobs, branch protection, and + Slack/Vault readiness. Include `make update` in the test plan and name every + generated job file. +- Invoke `/prose-editing` once on the completed title and body in the + **flavored** register. Preserve paths, job names, contexts, versions, + commands, and checklist state. +- Give the repository, base and head branches, edited title, and edited body + to `/rhdh-forge`. It returns the exact `gh pr create` command and payload; + it does not execute them. Keep that result as ``. +- Invoke `/mutation-gate` once with this ordered plan and the complete + previews. Each operation depends on the previous one succeeding: + 1. Commit the reviewed paths with + `git add -- && git commit -m "ci: add RHDH {version} release branch jobs"`. + 2. Push the commit with + `git push --set-upstream origin ci/rhdh-{version}-release-branch`. + 3. Verify the head exists with + `git ls-remote --exit-code origin refs/heads/ci/rhdh-{version}-release-branch`, + then open the pull request with ``. +- After approval, execute the three operations in order. Stop after a + failure, mark later operations skipped, and report each outcome. For the + pull request, report the returned URL or the exact failure. + ## Important Notes - Always confirm OCP/K8s/OSD versions with the user before finalizing diff --git a/skills/ci/rhdh-prow-release-branch/workflows/decommission-release.md b/skills/ci/rhdh-prow-release-branch/workflows/decommission-release.md index b4f1213..c5ae6bb 100644 --- a/skills/ci/rhdh-prow-release-branch/workflows/decommission-release.md +++ b/skills/ci/rhdh-prow-release-branch/workflows/decommission-release.md @@ -14,17 +14,54 @@ Read `../references/release-branch-config.md` for file paths and templates. - **Generated jobs** (removed by `make update`): `ci-operator/jobs/redhat-developer/rhdh/redhat-developer-rhdh-release-{version}-*.yaml` - **Branch protection**: `release-{version}:` block in `core-services/prow/02_config/redhat-developer/rhdh/_prowconfig.yaml` -3. **Delete the CI config file** +3. **Prepare a clean local branch**: + - Run `git status --porcelain --untracked-files=all` in the checkout. Stop if + it prints any tracked or untracked path; do not mix this work with an + existing change. + - From the user-selected base branch, run + `git switch -c "ci/rhdh-{version}-release-branch-removal" ""`. + - The branch and file edits below are local preparation, not external + operations. Approval for publishing them happens only in the final step. -4. **Remove branch protection configuration**: +4. **Delete the CI config file** + +5. **Remove branch protection configuration**: Edit `_prowconfig.yaml` to remove the entire `release-{version}:` block under `branch-protection.orgs.redhat-developer.repos.rhdh.branches`. Be careful to: - Only remove the block for the specified version - Preserve indentation and formatting of surrounding blocks - Not leave blank lines where the block was removed -5. **Run `make update`** to regenerate Prow job configs (this also removes the generated job files for the deleted config) +6. **Run `make update`** to regenerate Prow job configs (this also removes the generated job files for the deleted config) + +7. **Validate and summarize**: + - Run `git diff --check` and stop on any whitespace error. + - Inspect `git status --short` and the complete diff. Confirm every changed + path belongs to the removed release config, its generated jobs, or branch + protection; stop on an unrelated path. + - Summarize what was removed. -6. **Confirm completion**: Summarize what was removed +8. **Author and publish the pull request**: + - Use a direct title such as `ci: remove RHDH {version} release branch jobs`. + - In the body, name the removed config, generated jobs, and branch-protection + block. Include `make update` in the test plan and state the recovery path. + - Invoke `/prose-editing` once on the completed title and body in the + **flavored** register. Preserve paths, job names, contexts, versions, + commands, and checklist state. + - Give the repository, base and head branches, edited title, and edited body + to `/rhdh-forge`. It returns the exact `gh pr create` command and payload; + it does not execute them. Keep that result as ``. + - Invoke `/mutation-gate` once with this ordered plan and the complete + previews. Each operation depends on the previous one succeeding: + 1. Commit the reviewed paths with + `git add -- && git commit -m "ci: remove RHDH {version} release branch jobs"`. + 2. Push the commit with + `git push --set-upstream origin ci/rhdh-{version}-release-branch-removal`. + 3. Verify the head exists with + `git ls-remote --exit-code origin refs/heads/ci/rhdh-{version}-release-branch-removal`, + then open the pull request with ``. + - After approval, execute the three operations in order. Stop after a + failure, mark later operations skipped, and report each outcome. For the + pull request, report the returned URL or the exact failure. ## Important Notes diff --git a/skills/jira/rhdh-jira-create/workflows/create-issue.md b/skills/jira/rhdh-jira-create/workflows/create-issue.md index 4cc8cfc..0cb6802 100644 --- a/skills/jira/rhdh-jira-create/workflows/create-issue.md +++ b/skills/jira/rhdh-jira-create/workflows/create-issue.md @@ -188,6 +188,11 @@ note them; they become Epics in Step 10. ## Step 6 — Review before creating +Invoke `/prose-editing` once on the final summary and filled description in the +**flavored** register. `/rhdh-jira-authoring` supplies the template and challenge +rules but does not edit this caller's draft. Preserve template headings, field +values, acceptance-criteria checkboxes, and customer-identity restrictions. + Render the filled template and the inferred fields as a temporary markdown file and hand it to the user. Use a portable temp path (`$TMPDIR`, `%TEMP%`, or Python `tempfile`): @@ -306,6 +311,10 @@ Follow the comment suggestions behavior in `/rhdh-jira-authoring`: proactively offer the decision trail, elaboration, and abandoned approaches as comments, so the reasoning survives outside the description. +After choosing the comment set, invoke `/prose-editing` once on the complete set +of proposed comment bodies in the **flavored** register. Use those edited bodies +for the preview and write gate; the Jira adapter must not edit them again. + ```bash acli jira workitem comment create --key RHIDP-XXX --body "comment text" ``` diff --git a/skills/jira/rhdh-jira-link/SKILL.md b/skills/jira/rhdh-jira-link/SKILL.md index 565016c..b40aa10 100644 --- a/skills/jira/rhdh-jira-link/SKILL.md +++ b/skills/jira/rhdh-jira-link/SKILL.md @@ -35,6 +35,13 @@ lets this skill run its own scripts; it never calls into this directory by path. After the feature branch is committed: +Compose the reviewer-facing PR or MR title and body, then invoke +`/prose-editing` once on both in the **flavored** register. Preserve Jira keys, +closing syntax, headings, checklists, links, and the required `Generated-by` +line. Pass the edited strings to `create-pr-mr.js`; it is a transport layer and +must not edit them. The structured Jira link comment created later is a protocol +payload and does not receive this prose pass. + ```bash node "$SKILL/scripts/create-pr-mr.js" \ --issue RHIDP-12345 \ diff --git a/skills/jira/rhdh-jira-refine/workflows/refine-issues.md b/skills/jira/rhdh-jira-refine/workflows/refine-issues.md index 014c61d..aa1f2f7 100644 --- a/skills/jira/rhdh-jira-refine/workflows/refine-issues.md +++ b/skills/jira/rhdh-jira-refine/workflows/refine-issues.md @@ -245,8 +245,14 @@ After presenting the report, ask `Apply changes? [y/N/edit]`. **y** applies the uncontroversial fixes and prompts for each of the rest. **N** is report-only. **edit** steps through every change individually. -Whatever survives that selection is an external write. Invoke -`/mutation-gate` and follow it, with one row per issue key. +For every remediation that includes a Jira comment, compose the complete set of +comment bodies first, then invoke `/prose-editing` once on that set in the +**flavored** register. Preserve issue keys, resolutions, links, and quoted +rationales. The write adapter must not edit them again. + +Whatever survives the selection is an external write. After every comment body +is final, invoke `/mutation-gate` once and follow it, with one row per issue key +and the edited bodies included in the plan. **Applied without individual prompts:** diff --git a/skills/jira/rhdh-jira-update/SKILL.md b/skills/jira/rhdh-jira-update/SKILL.md index 18a4c46..5998fc7 100644 --- a/skills/jira/rhdh-jira-update/SKILL.md +++ b/skills/jira/rhdh-jira-update/SKILL.md @@ -32,6 +32,13 @@ the second workflow, not a new conversation. Invoke `/mutation-gate` and follow it, then read back the fields you claimed to set. +When another skill hands over a **caller-finalized** comment body and reports +that the user approved that exact body through `/mutation-gate`, use those bytes +for the write only when the current transcript contains that operation's plan +and approval. Do not compose, edit, present, or confirm the body again. A label +or an assertion of earlier approval is not proof; without the plan and approval +in this transcript, run the gate before writing. + A caller handing over a pull request usually wants three writes at once — a comment, a transition to `Review`, and a web link to the PR URL. Put all three in one stated set so one approval covers them, rather than asking three times. diff --git a/skills/jira/rhdh-jira-update/workflows/update-issue.md b/skills/jira/rhdh-jira-update/workflows/update-issue.md index 6054b61..e8e28f0 100644 --- a/skills/jira/rhdh-jira-update/workflows/update-issue.md +++ b/skills/jira/rhdh-jira-update/workflows/update-issue.md @@ -57,6 +57,14 @@ issue has a parent — if it does not, skip Step 6 entirely. ## Step 3 — Compose the status comment +A **caller-finalized** comment already passed its owning workflow's prose edit +and write gate. Verify the current transcript contains the exact operation plan +and the user's approval, then post that exact body without another prose pass or +confirmation and report the Jira result. If the transcript does not prove +approval of those exact bytes, return to `/mutation-gate` before posting them. + +For a direct update request, compose the body here. + Two to five sentences. What was done, where it stands, what is next. Factual, not a session log and not a self-assessment. @@ -69,6 +77,10 @@ a session log and not a self-assessment. | Abandoned approach | "Investigated {approach}. Abandoned because {reason}. Switching to {alternative}." | | Scope discovery | "Investigation revealed {finding}. Scope is {larger/smaller/different}. {recommendation}." | +Invoke `/prose-editing` once on the final comment body in the **flavored** +register. Preserve issue keys, links, states, and quoted error text. Use the +edited body for both the preview and the write; the Jira adapter does not edit it. + Confirm before posting: "Proposed comment: {comment}. Post this? [y/N/edit]" ```bash diff --git a/skills/meta/ask-rhdh/SKILL.md b/skills/meta/ask-rhdh/SKILL.md index 099c0c7..e320bef 100644 --- a/skills/meta/ask-rhdh/SKILL.md +++ b/skills/meta/ask-rhdh/SKILL.md @@ -41,6 +41,7 @@ competing inventory, which is what this table used to be. | Advises where a permanent automated test for the Red Hat Developer Hub dynamic-plugin ecosystem should live: which repository (rhdh, rhdh-plugins, or rhdh-plugin-export-overlays), which layer (L1 unit, L2 startTestBackend integration, L3 React Testing Library component, L4a cluster-free Playwright, or L4b cluster e2e), which harness, and which neighbouring file to mirror. Use for "where should this test live", "which repo and layer for this test", "does this need a cluster", "is this e2e too expensive", or a review comment that a test sits at the wrong layer. Advice only — it recommends a placement and names a template; it writes no test and changes no file. | `/rhdh-test-placement` | | Analyzes and updates the `FROM` base images, Node headers, Go toolchain, and `rpms.lock.yaml` files in rhdh, rhdh-operator, and rhdh-must-gather on `main` or a `release-1.10` branch. Use for weekly base-image maintenance, a UBI or RHEL bump, an RPM lockfile refresh, "which base images are out of date", or UBI minor skew inside a Containerfile. | `/rhdh-base-images` | | Bumps Konflux Tekton task bundle digests in `.tekton` and `.tekton-templates`, applies each task's MIGRATION.md, and regenerates PipelineRuns with `updateDigests.sh`, `generatePipelineRuns.sh`, and `updatePLRs.sh` on the RHDH midstream and rhdh-plugin-catalog trees. Use for "bump konflux task digests", "apply the tekton migration", `quay.io/konflux-ci/tekton-catalog/task-*` tag upgrades such as buildah-oci-ta, prefetch-dependencies-oci-ta, init, or build-image-index, and for build-definitions MIGRATION.md URLs that 404. | `/rhdh-konflux-tasks` | +| Updates the RHDH ReleasePlanAdmission tag values in konflux-release-data for one patch release while preserving stream tags and plugin version suffixes. Use for "update the RHDH RPA tags for 1.9.7". | `/rhdh-konflux-rpa` | | Lists, generates, and removes RHDH test entries and Hive ClusterPools in the openshift/release Prow ci-operator configuration. Covers OCP `e2e-ocp-vX-Y-helm-nightly` entries, `cluster_claim.version`, the `rhdh-cluster-pool` Hive pools, and AKS/EKS/GKE entries with `MAPT_KUBERNETES_VERSION`. Use for "add an OCP test entry", "which OCP versions are we testing", "list the cluster pools", "what AKS/EKS/GKE version is configured", or an OCP coverage-gap analysis. | `/rhdh-prow-jobs` | | Commissions or decommissions the openshift/release Prow configuration for a single RHDH release branch: the ci-operator config `redhat-developer-rhdh-release-{version}.yaml`, the jobs `make update` generates from it, the `release-{version}` branch-protection block in `_prowconfig.yaml`, and the `#rhdh-e2e-alerts-X-Y` Slack channel and `rhdh-send-alert` Vault webhook. Use for "set up CI for release 1.11", "commission the 1.12 branch jobs", or "decommission 1.8". Prow configuration for a release branch only — the release itself is rhdh-release-status, rhdh-release-schedule, and rhdh-release-announce. | `/rhdh-prow-release-branch` | | Runs an RHDH nightly ProwJob on demand through the OpenShift CI Gangway REST API, for `periodic-ci-redhat-developer-rhdh-*-nightly` and `periodic-ci-redhat-developer-rhdh-plugin-export-overlays-*-nightly` jobs, with optional image registry/repo/tag, catalog-index, Helm chart, Playwright, fork, and Slack-alert overrides. Use for "trigger the nightly", "run the e2e job now", "kick off the AKS operator job on 1.9", RC or GA image verification runs, or listing the available nightly jobs and quay.io tags. | `/rhdh-prow-trigger` | @@ -54,8 +55,9 @@ competing inventory, which is what this table used to be. | Reviews the platform and integration version tables and the key-date table in an RHDH test-plan Jira issue against vendor lifecycle data and the release schedule, then applies the accepted edits. Covers OCP, ARO, OSD, ROSA, AKS, EKS, GKE, PostgreSQL, RHBK, and Quay rows. Use for "review the test plan for 1.11", "which platform versions should the 1.10 test plan list", "update the test plan dates", or a test-plan Jira URL or key such as RHIDP-1234. | `/rhdh-test-plan-review` | | Names the breaking and notable changes between the early Backstage New Frontend System alpha and the current GA surface, for Red Hat Developer Hub plugins: NavItemBlueprint removed in favour of nav auto-discovery from PageBlueprint, config.schema replaced by top-level configSchema with direct zod/v4 imports, AppRootWrapperBlueprint Component renamed to component and moved to @backstage/plugin-app-react, defaultPath/defaultTitle/defaultGroup renamed, SubPageBlueprint added, useRouteRef returning undefined, and AppDrawerContentBlueprint taking element rather than loader. Use when NFS code that used to compile now fails, a Blueprint param produces a TypeScript error, or a plugin migrated against an older alpha needs bringing forward. | `/backstage-api-changes` | | Supplies the approval rule another RHDH skill applies when it is already about to change something outside the session: how to state each operation, what approval binds to, what to report afterwards, and how to keep credentials out of a plan preview. Cited by name from the skill doing the work. Not an entry point — it performs no forge, Jira, or repository action itself, and a request to open, comment, transition, push, or post belongs to the skill that owns that target. | `/mutation-gate` | +| Edits or audits prose that the caller supplies as text or a file, preserves its claims and voice, removes machine-writing patterns, and verifies the result with the bundled linter. Use for explicit requests such as "rewrite this draft", "edit this existing PR body", "make this sound human", or "audit this prose without changing it". It does not review code, decide what a pull request or Jira issue should say, or compose from a blank sheet. | `/prose-editing` | | Resolves the environment the other RHDH skills start from and hands back one JSON document: which RHDH repositories are checked out and where — rhdh, rhdh-operator, rhdh-plugins, rhdh-plugin-export-overlays, rhdh-plugin-catalog, rhdh-cli, rhdh-chart, rhdh-local, backstage and the rest — which tools are on PATH, and the target RHDH and Backstage versions with the source that produced them. Also owns the `rhdh` CLI behind workspace status, worklogs, and todos. Use for RHDH orientation, "where is my rhdh checkout", "which Backstage version goes with RHDH 1.10", `rhdh status`, `rhdh doctor`, `rhdh config`, `rhdh workspace`, `rhdh log`, `rhdh todo`, and for the read-only context another RHDH skill needs before it starts. Read-only — implementation work belongs to the domain skill that owns the requested outcome. | `/rhdh-context` | -| Reads GitHub and GitLab on behalf of the other RHDH skills: parse an issue, pull request, or merge request reference, fetch issue detail as JSON, resolve the plugin workspace an issue belongs to, read check or pipeline status and failed run logs, and read repository files through the API. Use for a GitHub or GitLab issue URL, a bare #number, a !number merge request, "which workspace is this issue in", a stale statusCheckRollup, "why did that check fail", gh, glab, or jq syntax for a forge read, and the exact payload behind a comment, label, assignee, approval, or /publish write. | `/rhdh-forge` | +| Reads GitHub and GitLab and constructs unexecuted forge payloads for the other RHDH skills: parse an issue, pull request, or merge request reference, fetch issue detail, resolve a plugin workspace, inspect checks or pipelines, read repository files, or build the exact command for a GitHub pull request, GitLab merge request, comment, label, assignee, approval, or /publish write. Use for a forge URL, a bare #number, a !number merge request, "which workspace is this issue in", a stale statusCheckRollup, "why did that check fail", or safe gh, glab, and jq command construction. | `/rhdh-forge` | | Reads RHDH Jira and owns the mechanics every other RHDH Jira skill needs: `acli` flags and their traps, JQL for RHIDP, RHDHPLAN, RHDHBUGS and RHDHSUPP, custom field IDs, GraphQL and REST fallbacks, board and sprint IDs, the component catalog, and the workflow states with their exit criteria. Use to look up an issue such as RHIDP-1234, to write or debug a JQL query, to answer "which field is Story Points", "why is my search returning 30 rows", or "what does Release Pending require". Reading and query mechanics only — deciding what to file, judging readiness, or changing an issue belongs to the Jira skill that owns that verb. | `/rhdh-jira-api` | | Supplies the craft of writing good RHDH Jira work in RHIDP, RHDHPLAN, RHDHBUGS, and RHDHSUPP: the Feature, Epic, Story, Task, and Bug description templates with filled examples, the RHDH challenge matrix that stress-tests scope, sizing, acceptance criteria, and Epic independence, the T-shirt and Fibonacci sizing scales, keyword duplicate detection, and the tracer-bullet rules for breaking a Feature into Epics or an Epic into Stories. Use when drafting or judging the text and shape of an issue such as RHIDP-1234 — how big is this, is this AC testable, should this be one Epic or three, does this already exist. Prose and estimation craft only; it runs no Jira command. | `/rhdh-jira-authoring` | | Assesses and improves a git repository's readiness for AI coding agents with the agentready tool, and reports the score, the certification level, the failing findings, and where the report was written. Covers one repository or every RHDH repository under a directory, and applies the fixes the report supports. Use for "assess agent readiness", "run agentready", "improve our agent readiness score", "prepare this repository for coding agents", or "assess all the RHDH repositories". | `/agent-readiness` | diff --git a/skills/meta/clean-prose/SKILL.md b/skills/meta/clean-prose/SKILL.md new file mode 100644 index 0000000..5d3055a --- /dev/null +++ b/skills/meta/clean-prose/SKILL.md @@ -0,0 +1,9 @@ +--- +name: clean-prose +description: >- + Human-only entry point that passes supplied prose or a file path to the + pack's prose editor. Use only when a person explicitly invokes /clean-prose. +disable-model-invocation: true +--- + +With supplied prose or a file path, run a `/prose-editing` pass; otherwise ask for one. diff --git a/skills/meta/clean-prose/agents/openai.yaml b/skills/meta/clean-prose/agents/openai.yaml new file mode 100644 index 0000000..2e19197 --- /dev/null +++ b/skills/meta/clean-prose/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Clean Prose" + short_description: "Pass a supplied draft to the prose editor" + default_prompt: "Use $clean-prose to edit the prose or file I supply." +policy: + allow_implicit_invocation: false diff --git a/skills/meta/setup-rhdh-skills/SKILL.md b/skills/meta/setup-rhdh-skills/SKILL.md index fa99e81..b2162b3 100644 --- a/skills/meta/setup-rhdh-skills/SKILL.md +++ b/skills/meta/setup-rhdh-skills/SKILL.md @@ -53,7 +53,7 @@ content. ## Completion A branch is complete when the doctor reports every promoted skill in `assets/catalog.json` plus -`grilling` and `humanizer` as installed, the branch's own capability reads as present in that same +`grilling` and `handoff` as installed, the branch's own capability reads as present in that same doctor report, and the branch reference's smoke check has been run with its output shown. An install branch additionally requires one reported outcome for every operation in the approved plan, and the user told to restart or rescan the agent. When a model skill sent the user here because a diff --git a/skills/meta/setup-rhdh-skills/assets/catalog.json b/skills/meta/setup-rhdh-skills/assets/catalog.json index 61a1506..7e6aaa2 100644 --- a/skills/meta/setup-rhdh-skills/assets/catalog.json +++ b/skills/meta/setup-rhdh-skills/assets/catalog.json @@ -12,10 +12,6 @@ { "name": "handoff", "source": "mattpocock/skills" - }, - { - "name": "humanizer", - "source": "blader/humanizer" } ] }, @@ -35,7 +31,8 @@ "requiresSkills": [ "rhdh-jira-api", "rhdh-jira-authoring", - "mutation-gate" + "mutation-gate", + "prose-editing" ], "requiresExternalSkills": [ "grilling" @@ -46,7 +43,8 @@ "category": "jira", "invocation": "model", "requiresSkills": [ - "mutation-gate" + "mutation-gate", + "prose-editing" ], "requiresExternalSkills": [] }, @@ -56,7 +54,8 @@ "invocation": "model", "requiresSkills": [ "rhdh-jira-api", - "rhdh-jira-authoring" + "rhdh-jira-authoring", + "prose-editing" ], "optionalSkills": [ "mutation-gate" @@ -87,7 +86,8 @@ "invocation": "model", "requiresSkills": [ "rhdh-jira-api", - "mutation-gate" + "mutation-gate", + "prose-editing" ], "requiresExternalSkills": [] }, @@ -112,7 +112,8 @@ "category": "plugins", "invocation": "model", "requiresSkills": [ - "mutation-gate" + "mutation-gate", + "prose-editing" ], "optionalSkills": [ "rhdh-local", @@ -132,7 +133,9 @@ "category": "plugins", "invocation": "model", "requiresSkills": [ - "rhdh-forge" + "rhdh-forge", + "mutation-gate", + "prose-editing" ], "requiresExternalSkills": [ "grilling" @@ -149,7 +152,10 @@ "name": "rhdh-plugin-midstream-propagate", "category": "plugins", "invocation": "model", - "requiresSkills": [], + "requiresSkills": [ + "mutation-gate", + "prose-editing" + ], "requiresExternalSkills": [] }, { @@ -174,7 +180,8 @@ "invocation": "model", "requiresSkills": [ "mutation-gate", - "rhdh-forge" + "rhdh-forge", + "prose-editing" ], "optionalSkills": [ "rhdh-jira-api", @@ -188,14 +195,13 @@ "category": "plugins", "invocation": "model", "requiresSkills": [ - "mutation-gate" + "mutation-gate", + "prose-editing" ], "optionalSkills": [ "rhdh-forge" ], - "requiresExternalSkills": [ - "humanizer" - ] + "requiresExternalSkills": [] }, { "name": "rhdh-test-placement", @@ -219,7 +225,19 @@ "name": "rhdh-konflux-tasks", "category": "ci", "invocation": "model", - "requiresSkills": [], + "requiresSkills": [ + "mutation-gate" + ], + "requiresExternalSkills": [] + }, + { + "name": "rhdh-konflux-rpa", + "category": "ci", + "invocation": "model", + "requiresSkills": [ + "mutation-gate", + "rhdh-forge" + ], "requiresExternalSkills": [] }, { @@ -233,7 +251,11 @@ "name": "rhdh-prow-release-branch", "category": "ci", "invocation": "model", - "requiresSkills": [], + "requiresSkills": [ + "mutation-gate", + "prose-editing", + "rhdh-forge" + ], "requiresExternalSkills": [] }, { @@ -268,10 +290,10 @@ "name": "rhdh-release-announce", "category": "release", "invocation": "model", - "requiresSkills": [], - "requiresExternalSkills": [ - "humanizer" - ] + "requiresSkills": [ + "prose-editing" + ], + "requiresExternalSkills": [] }, { "name": "rhdh-release-schedule", @@ -300,7 +322,8 @@ "invocation": "model", "requiresSkills": [ "rhdh-platform-lifecycle", - "rhdh-release-schedule" + "rhdh-release-schedule", + "prose-editing" ], "requiresExternalSkills": [] }, @@ -318,6 +341,13 @@ "requiresSkills": [], "requiresExternalSkills": [] }, + { + "name": "prose-editing", + "category": "reference", + "invocation": "model", + "requiresSkills": [], + "requiresExternalSkills": [] + }, { "name": "rhdh-context", "category": "reference", @@ -345,7 +375,9 @@ "name": "rhdh-jira-authoring", "category": "reference", "invocation": "model", - "requiresSkills": [], + "requiresSkills": [ + "prose-editing" + ], "requiresExternalSkills": [] }, { @@ -364,6 +396,15 @@ "requiresSkills": [], "requiresExternalSkills": [] }, + { + "name": "clean-prose", + "category": "meta", + "invocation": "human", + "requiresSkills": [ + "prose-editing" + ], + "requiresExternalSkills": [] + }, { "name": "setup-rhdh-skills", "category": "meta", diff --git a/skills/meta/setup-rhdh-skills/references/install.md b/skills/meta/setup-rhdh-skills/references/install.md index bf26bef..5213a17 100644 --- a/skills/meta/setup-rhdh-skills/references/install.md +++ b/skills/meta/setup-rhdh-skills/references/install.md @@ -28,5 +28,5 @@ source of truth for the promoted skill set and its two required external depende The script validates every operation before running the first one and executes argument arrays directly without a command shell. If validation fails, no installation operation runs. -Completion requires all promoted skills, `grilling`, `humanizer`, and `handoff` to be discovered in a +Completion requires all promoted skills, `grilling`, and `handoff` to be discovered in a supported host layout. diff --git a/skills/plugins/rhdh-overlay/workflows/draft-notification.md b/skills/plugins/rhdh-overlay/workflows/draft-notification.md index e2b56a4..e4e9b6c 100644 --- a/skills/plugins/rhdh-overlay/workflows/draft-notification.md +++ b/skills/plugins/rhdh-overlay/workflows/draft-notification.md @@ -138,9 +138,12 @@ Could you take a look when you have a moment? ## Phase 5: Review and Send -1. **Review each draft** — adjust tone/details as needed -2. Keep the result as a draft unless the user requests delivery. -3. For delivery, follow the write gate in `SKILL.md`. State one operation per +1. **Review each draft** — adjust tone/details as needed. +2. Invoke `/prose-editing` once on the complete set of final messages in the + **voiced** register. Preserve Slack handles, channels, PR numbers, statuses, + priorities, and URLs. The send adapter must not edit them again. +3. Keep the result as a draft unless the user requests delivery. +4. For delivery, follow the write gate in `SKILL.md`. State one operation per recipient or channel, in send order, each with its final message body. Get approval for that stated set, send, then report the actual message and channel identifiers for every operation. An edited body or recipient is a new diff --git a/skills/plugins/rhdh-overlay/workflows/onboard-plugin.md b/skills/plugins/rhdh-overlay/workflows/onboard-plugin.md index 3ff8582..170dce6 100644 --- a/skills/plugins/rhdh-overlay/workflows/onboard-plugin.md +++ b/skills/plugins/rhdh-overlay/workflows/onboard-plugin.md @@ -211,6 +211,10 @@ git push -u origin add--workspace Once the pushed head SHA and full body are known, state PR creation as a new operation and get approval for it before running `gh pr create`. +Invoke `/prose-editing` once on the final PR title and body in the **flavored** +register before showing that operation. Preserve repository URLs, package and +plugin names, headings, and checklist state. `gh` only transports the result. + ```bash gh pr create \ --title "Add workspace" \ @@ -315,7 +319,10 @@ Plugins in these lists become "required" — release gates fail if they're incom phase. 4. Take back its per-check results and preserve skipped checks with their reasons. -5. To add those results to the overlay PR, state the comment as an operation with +5. Compose the reviewer-facing verification comment, then invoke + `/prose-editing` once on it in the **flavored** register. Preserve check names, + exact results, artifact references, commands, and quoted errors. +6. To add those results to the overlay PR, state the edited comment as an operation with its exact body and target, get approval, and report the resulting comment URL. Authentication failures inside the plugin card are acceptable only when installation, boot, and UI wiring are otherwise proven. diff --git a/skills/plugins/rhdh-overlay/workflows/triage-prs.md b/skills/plugins/rhdh-overlay/workflows/triage-prs.md index 5fa7e83..cd388ab 100644 --- a/skills/plugins/rhdh-overlay/workflows/triage-prs.md +++ b/skills/plugins/rhdh-overlay/workflows/triage-prs.md @@ -133,14 +133,8 @@ a candidate is free; assigning them is a write and goes through the gate. ### Draft a Slack ping -See `workflows/draft-notification.md` for structured drafting, or compose -manually: - -``` -Hey @handle - PR #1234 needs your attention. -Status: Smoke tests passing, awaiting review. -Priority: Mandatory workspace for RHDH catalog. -``` +Load and follow `workflows/draft-notification.md` for every Slack draft. Use the +final messages it returns; this triage workflow does not compose a fallback. diff --git a/skills/plugins/rhdh-overlay/workflows/update-plugin.md b/skills/plugins/rhdh-overlay/workflows/update-plugin.md index 32b3d5e..dbbe639 100644 --- a/skills/plugins/rhdh-overlay/workflows/update-plugin.md +++ b/skills/plugins/rhdh-overlay/workflows/update-plugin.md @@ -51,6 +51,10 @@ Prepare the local branch and commit, then follow the mutation contract in known, plan the exact PR title and body, obtain approval of the new material hash, and only then create the PR. Return a receipt for each batch. +Invoke `/prose-editing` once on the final PR title and body in the **flavored** +register before presenting that material hash. Preserve plugin names, versions, +commit refs, links, headings, and checklists. `gh` only transports the result. + ```bash git checkout -b update-- git add . @@ -72,7 +76,9 @@ then comment `/publish` and verify success from the returned check URL. Read `references/rhdh-local.md`, then invoke `/rhdh-local` by name with the exact PR artifact references, plugin config, environment variable names, and checks. Take back its per-check results. Add them to the PR only through an approved -comment operation. State review requests, feedback comments, re-publish +comment operation. Compose the final verification comment and invoke +`/prose-editing` once on it in the **flavored** register; preserve check names, +results, artifact references, and quoted errors. State review requests, feedback comments, re-publish triggers, and merge as their own operations once their exact targets and payloads are known. Merge only after local verification and the current-head publish check pass, then report the outcome of every operation alongside the workspace changes. diff --git a/skills/plugins/rhdh-plugin-bug-fix/workflows/fix-bug.md b/skills/plugins/rhdh-plugin-bug-fix/workflows/fix-bug.md index bd88065..0c29016 100644 --- a/skills/plugins/rhdh-plugin-bug-fix/workflows/fix-bug.md +++ b/skills/plugins/rhdh-plugin-bug-fix/workflows/fix-bug.md @@ -60,9 +60,12 @@ Require reproducible steps, expected behavior, identifiable component or workspace, and target repository. If any field is missing: 1. Report exactly which fields are missing. -2. When issue mutation is in scope, add `not-ready-for-agent` and a checklist - comment through the issue's own tool. -3. Stop before changing code. +2. When issue mutation is in scope, compose the checklist comment and invoke + `/prose-editing` once on it in the **flavored** register. Preserve field names, + labels, issue keys, reproduction steps, and quoted errors. +3. Add `not-ready-for-agent` and the edited checklist comment through the issue's + own tool; the transport layer must not edit it again. +4. Stop before changing code. When triage passes, report the extracted issue, repository, and workspace. diff --git a/skills/plugins/rhdh-plugin-midstream-propagate/SKILL.md b/skills/plugins/rhdh-plugin-midstream-propagate/SKILL.md index 5b1a751..a598f4e 100644 --- a/skills/plugins/rhdh-plugin-midstream-propagate/SKILL.md +++ b/skills/plugins/rhdh-plugin-midstream-propagate/SKILL.md @@ -75,7 +75,13 @@ Goal: same file set `sync-midstream.sh --force-clone ''` would refresh for * - `DESCRIPTION` plugin version fragment - `UPSTREAM_REPO` overlays tree SHA when known - Or regenerate the affected PLRs via `.tekton/updatePLRs.sh` (see reference: nested `--path '/plugins/'` vs flat `--package`; gate on `source.json` `repo-flat`) -5. Open the catalog MR by invoking the named skill `/rhdh-pr-create`, or with `glab` against CEE GitLab. Cite sibling package versions in the body. +5. Open the catalog MR by invoking the named skill `/rhdh-pr-create`, or with + `glab` against CEE GitLab. Cite sibling package versions in the body. When + this skill composes a body for direct `glab` submission, invoke + `/prose-editing` once on the complete title and body in the **flavored** + register before the write gate. Preserve versions, SHAs, package names, + headings, links, and commands. When `/rhdh-pr-create` is the final composer, + hand it the facts without editing; that skill owns the single prose pass. **Who owns `.tekton`:** this skill owns only the per-workspace PLR and Containerfile tag bumps that ride the surgical catalog MR for the workspace being promoted. Full-stream PipelineRun regeneration across the catalog belongs to `/rhdh-konflux-tasks`; invoke it by name rather than regenerating the stream from here. diff --git a/skills/plugins/rhdh-pr-create/workflows/create-pull-request.md b/skills/plugins/rhdh-pr-create/workflows/create-pull-request.md index 2561e62..7a7608d 100644 --- a/skills/plugins/rhdh-pr-create/workflows/create-pull-request.md +++ b/skills/plugins/rhdh-pr-create/workflows/create-pull-request.md @@ -364,7 +364,12 @@ git push -u origin HEAD - **`## Test Plan`** — include only if the handoff supplied a test plan. Insert the markdown checklist as-is. - **`## Checklist`** — always present. - **`## Note`** — include when a bug-fix change handoff was supplied. Omit for a standalone publication request. -5. Create the PR using `gh pr create` with the repo-appropriate template. Use the upstream repo value for `--repo` and `main` for `--base`. Pass the body via HEREDOC: +5. Invoke `/prose-editing` once on the completed reviewer-facing title and body + in the **flavored** register. Preserve issue-closing syntax, headings, + checkboxes, links, image URLs, and supplied test-plan text exactly. Use the + edited result for the approval preview and `gh pr create`; the command is a + transport layer and must not edit it again. +6. Create the PR using `gh pr create` with the repo-appropriate template. Use the upstream repo value for `--repo` and `main` for `--base`. Pass the body via HEREDOC: ``` gh pr create --repo --base main --title "" --body "$(cat <<'EOF' @@ -373,8 +378,8 @@ EOF )" ``` -6. Capture and store the PR URL for Step 11. -7. Display the PR URL. +7. Capture and store the PR URL for Step 11. +8. Display the PR URL. --- diff --git a/skills/plugins/rhdh-pr-review/SKILL.md b/skills/plugins/rhdh-pr-review/SKILL.md index 00760ae..7b63b65 100644 --- a/skills/plugins/rhdh-pr-review/SKILL.md +++ b/skills/plugins/rhdh-pr-review/SKILL.md @@ -24,7 +24,7 @@ independently. | Outcome | Workflow sequence | |---|---| | Code review and post | `workflows/fetch-github.md` → `workflows/review-code.md` → `workflows/post-to-github.md` | -| Analysis only | `workflows/fetch-github.md` → `workflows/review-code.md`; stop after the humanized draft | +| Analysis only | `workflows/fetch-github.md` → `workflows/review-code.md`; stop after the edited draft | | Test an rhdh-operator PR | `workflows/fetch-github.md` → `workflows/review-operator-pr.md` | | Full review | fetch → review code → confirm and post → operator cluster test | @@ -41,11 +41,7 @@ can diverge, but respect an explicit route. - Ask which installed specialist skills, if any, the user wants applied after fetch and before deep analysis. Invoke chosen skills by name and give them the fetched PR context; never load their files. -- `/humanizer` is required before any review draft is shown, including - analysis-only. If unavailable, say that `humanizer` is missing, name - `/setup-rhdh-skills install`, and stop the draft path. Do not implement a local - locator or substitute prose rewriting. -- Present the complete humanized draft and review event for confirmation before +- Present the complete edited draft and review event for confirmation before stating any post operation. An explicit request to post is intent, not approval of the exact write. - For cluster testing, deploy the full PR bundle or manifests, not only the @@ -72,7 +68,7 @@ defined once, where they are produced: | Stage | Result | Defined in | |---|---|---| | Fetch | PR context: repository, changeRequest, files, diff, linkedIssues, jiraKeys, existingComments, existingReviews, ciStatus | `workflows/fetch-github.md` | -| Analysis | Review draft: changeRequest, summary, verdict, findings, humanized | `workflows/review-code.md` | +| Analysis | Review draft: changeRequest, summary, verdict, findings, edited | `workflows/review-code.md` | | Operator testing | Subject, per-check results, verdict, cluster state, cleanup | `workflows/review-operator-pr.md` | ## Scripts and references @@ -80,12 +76,11 @@ defined once, where they are produced: - `scripts/fetch_pr_context.py` deterministically builds the PR context as one JSON object with no envelope. - `references/review-perspectives.md` routes optional specialist review lenses. -- `references/humanizer.md` defines the named `/humanizer` gate. - `references/operator-pr-images.md` defines operator bundle/image extraction. ## Completion -Complete when the report names the head SHA reviewed, presents the humanized +Complete when the report names the head SHA reviewed, presents the edited draft, gives the outcome of every approved write with its target, includes the cluster check results when operator testing ran, and states every skipped check or cleanup action with its reason. diff --git a/skills/plugins/rhdh-pr-review/references/humanizer.md b/skills/plugins/rhdh-pr-review/references/humanizer.md deleted file mode 100644 index 03a9f3f..0000000 --- a/skills/plugins/rhdh-pr-review/references/humanizer.md +++ /dev/null @@ -1,17 +0,0 @@ -# Humanizer Gate - -`/humanizer` is required for every review draft, including analysis-only. -Cluster-only routes that produce no review prose do not need it. - -Before drafting, check whether the named skill is available through the host's -skill inventory. Do not scan installation directories and do not implement a -local substitute. - -If unavailable, stop the draft branch, say that `humanizer` is missing, and name -`/setup-rhdh-skills install` as the human's next step. Do not draft review prose -without it. - -After the top-level summary and inline bodies exist, invoke `/humanizer` on all -of them. Preserve technical meaning, severity, file paths, line numbers, -suggestion fences, and review event. Present only the humanized draft to the -user. diff --git a/skills/plugins/rhdh-pr-review/workflows/post-to-github.md b/skills/plugins/rhdh-pr-review/workflows/post-to-github.md index e420f61..0b2f688 100644 --- a/skills/plugins/rhdh-pr-review/workflows/post-to-github.md +++ b/skills/plugins/rhdh-pr-review/workflows/post-to-github.md @@ -9,7 +9,7 @@ Takes the review draft from `review-code.md` and posts it as an inline review vi ## Step 1: Finalize the draft -If the findings have not been shown yet, present the full humanized draft first: +If the findings have not been shown yet, present the full edited draft first: ``` ## Review for PR #<number> diff --git a/skills/plugins/rhdh-pr-review/workflows/review-code.md b/skills/plugins/rhdh-pr-review/workflows/review-code.md index 04761da..9473889 100644 --- a/skills/plugins/rhdh-pr-review/workflows/review-code.md +++ b/skills/plugins/rhdh-pr-review/workflows/review-code.md @@ -8,12 +8,6 @@ Work from that context. The one exception is reading full file contents at HEAD You are a senior team member reviewing a contribution. Your goal is to help the author ship confidently, not demonstrate expertise. Every comment should either prevent a real problem or teach something useful — if it does neither, don't leave it. -## Step 0: Humanizer prerequisite - -Load `../references/humanizer.md`. If the named `/humanizer` skill is absent, say -that `humanizer` is missing, name `/setup-rhdh-skills install` as the human's next -step, and stop. This applies to every draft path, including analysis-only. - ## Step 1: Ask which specialist skills to invoke Read `../references/review-perspectives.md`. After fetch/context is available and **before deep analysis**, always ask the user which installed skills (if any) to invoke for this review. "None" is valid. Do not invent a hardcoded specialist roster — use whatever the user names, then follow those skills for domain knowledge. @@ -83,13 +77,15 @@ Write each comment as natural prose — a short paragraph explaining the issue a **GitHub `suggestion` blocks:** use them **only** when the fix is small and obvious — one clear replacement hunk the author can apply as-is. Otherwise leave a question or guidance without a `suggestion` block. -### Humanize before show-user +### Edit before show-user + +After drafting top-level + inlines, invoke `/prose-editing` on the whole draft — top-level comment and every inline body — in the **flavored** register. A review is a document, not a procedure, and the caller names the register so the editor does not have to guess it. -After drafting top-level + inlines, follow `../references/humanizer.md` → When to invoke. Run humanizer on the full draft, then present the humanized draft. Never show pre-humanizer prose as the review draft. Applies to posting and analysis-only routes. +Preserve technical meaning, severity, file paths, line numbers, `suggestion` fences, and the review event. Present only what comes back. Never show the unedited prose as the review draft. Applies to posting and analysis-only routes. ## Step 5: Choose event type -Present the **humanized** draft to the user. For posting routes, ask which event type to use: +Present the **edited** draft to the user. For posting routes, ask which event type to use: | Event | When | |-------|------| @@ -97,7 +93,7 @@ Present the **humanized** draft to the user. For posting routes, ask which event | `APPROVE` | No issues, or only minor nits. | | `REQUEST_CHANGES` | Critical issues that must be fixed. Use sparingly. | -For analysis-only (route 2), present the humanized draft and stop — no event type, no post. +For analysis-only (route 2), present the edited draft and stop — no event type, no post. ## What this workflow hands on @@ -107,7 +103,7 @@ Carry the finished review forward as: changeRequest: {repository: "owner/repo", number: 123, headSha: "abc123..."} summary: "top-level review text" verdict: "COMMENT" | "APPROVE" | "REQUEST_CHANGES" -humanized: true +edited: true findings[] ├── path: "src/file.ts" ├── line: 42 @@ -118,4 +114,4 @@ findings[] `type` is the finding kind for triage. A GitHub `suggestion` fence inside `body` is separate and only allowed for small obvious hunks (see Step 4). -**Do not post the review.** If the router selected a posting workflow, hand that draft to it. If analysis-only, stop after presenting the humanized draft (Step 5). +**Do not post the review.** If the router selected a posting workflow, hand that draft to it. If analysis-only, stop after presenting the edited draft (Step 5). diff --git a/skills/plugins/rhdh-pr-review/workflows/review-operator-pr.md b/skills/plugins/rhdh-pr-review/workflows/review-operator-pr.md index 06b3eb2..2343ce4 100644 --- a/skills/plugins/rhdh-pr-review/workflows/review-operator-pr.md +++ b/skills/plugins/rhdh-pr-review/workflows/review-operator-pr.md @@ -595,6 +595,11 @@ oc wait csv -n $OPERATOR_NS -l "operators.coreos.com/$PACKAGE_NAME.$OPERATOR_NS= oc apply -f /tmp/rollback-install.yaml ``` +Before presenting the completed assessment, invoke `/prose-editing` once on the +whole reviewer-facing draft in the **flavored** register. Preserve raw evidence, +tables, commands, resource names, and rollback payloads exactly; the posting or +handoff layer must not edit it again. + </process> <action_triggers> diff --git a/skills/reference/prose-editing/SKILL.md b/skills/reference/prose-editing/SKILL.md new file mode 100644 index 0000000..9fcd296 --- /dev/null +++ b/skills/reference/prose-editing/SKILL.md @@ -0,0 +1,216 @@ +--- +name: prose-editing +description: >- + Edits or audits prose that the caller supplies as text or a file, preserves + its claims and voice, removes machine-writing patterns, and verifies the + result with the bundled linter. Use for explicit requests such as "rewrite + this draft", "edit this existing PR body", "make this sound human", or + "audit this prose without changing it". It does not review code, decide what + a pull request or Jira issue should say, or compose from a blank sheet. +compatibility: "Python 3.9+; the bundled linter is stdlib-only" +--- + +# Prose editing + +Edit an existing draft without changing what it claims. The caller supplies +the prose and, when it knows, the register. Return only the prose when another +skill invokes this one. + +## Choose the operation + +Explicit edit intent wins. A request to rewrite, tighten, or humanize remains +an edit even when it also asks for findings or a score. + +Use `audit` only when the caller explicitly asks for no changes, an audit, or +findings without a rewrite. Text written by somebody else is not automatically +read-only. Protect quoted third-party passages, but offer or make a requested +rewrite of the surrounding draft. + +## Choose the primary register + +A caller-provided register wins. Otherwise, use the document's primary purpose: + +| Register | Primary purpose | Loaded references | +|---|---|---| +| `strict` | procedure, runbook, safety text, operator-facing error | [mechanical](references/mechanical.md) and all of [compression](references/compression.md) | +| `flavored` | README, technical docs, PR or MR body, Jira prose, code comment | [mechanical](references/mechanical.md) and the shared part of [compression](references/compression.md) | +| `voiced` | announcement, community message, blog post, personal prose | [mechanical](references/mechanical.md) and [voice](references/voice.md) | +| `audit` | explicit no-change inspection | all three references; recommendations follow the document's primary purpose | + +Purpose outranks a byline. A technical README with an author's name remains +`flavored`. When the purpose is genuinely ambiguous and choosing wrong would +materially change the rewrite, ask one focused question. Otherwise use +`voiced`, which makes the least structural change. + +Use one numeric primary score. Do not invent a register map. In a mixed +document, keep its primary register and manually apply `strict` rules to +procedural and safety sections. This local override does not produce a second +score. + +If the user supplies a sample of their own writing, read it before the draft. +Its stable sentence rhythm, vocabulary, punctuation, transitions, and quirks +govern `voiced` edits. A sample can justify em dashes, curly quotes, repeated +openings, or other deliberate habits that are weak evidence by themselves. + +## Prepare a safe working copy + +Never write beside this installed skill. Create a unique directory with the +system temporary-directory API. Keep these paths inside it: + +- `source.md`: an unchanged snapshot used for the meaning check. +- `rewrite.md`: the only bytes scored before and after; edit this file. +- `before.json`: the baseline report for that exact normalized `rewrite.md` + path. +- `voice.md`: an unchanged snapshot of a supplied writing sample, when present. + +For pasted text, write the supplied bytes to `source.md`, then copy it to +`rewrite.md`. For a user file, copy the file to both paths and do not touch the +original until the rewrite passes every guard. Use process argument arrays +where the host supports them. If a shell is unavoidable, quote every path, +including the trusted skill directory and all temporary paths. Never paste a +user-controlled path into an unquoted command. + +When the user supplies a writing sample, copy its exact bytes to `voice.md` and +pass `--voice-sample "<unique-temp-dir>/voice.md"` on both linter runs. Omit the +option on both runs when there is no sample. A different or changed sample makes +the baseline incompatible. + +Resolve `scripts/lint.py` relative to this `SKILL.md`. The equivalent shell +shape without a writing sample is: + +```bash +python "<skill-dir>/scripts/lint.py" --json --register <register> \ + "<unique-temp-dir>/rewrite.md" > "<unique-temp-dir>/before.json" +``` + +When a sample exists, insert +`--voice-sample "<unique-temp-dir>/voice.md"` after the register argument. + +Use the host's UTF-8 file APIs or redirection in the temporary directory. Do +not use a fixed `before.json`. Delete the unique directory after delivery. + +## Edit loop + +Skip to [Audit](#audit) for `audit`. For an edit: + +1. Load the references named by the register. Read every rule that applies. +2. Score `rewrite.md` and save the complete JSON as `before.json`. +3. Read all `violations`, `samples`, `markers`, and manual checks. A zero score + does not clear a marker or a rule that needs judgment. +4. Build a private source inventory. Record every proposition, condition, + exception, scope qualifier, modal force, name, number, version, date, quote, + citation, identifier, and safety consequence. +5. Rewrite `rewrite.md`. Change the smallest useful span, except when several + patterns cluster in one paragraph; then rewrite that paragraph around its + concrete point. +6. Score the same normalized `rewrite.md` path against `before.json`: + + ```bash + python "<skill-dir>/scripts/lint.py" --json --register <register> \ + --baseline "<unique-temp-dir>/before.json" \ + "<unique-temp-dir>/rewrite.md" + ``` + + When the baseline used a writing sample, insert the same + `--voice-sample "<unique-temp-dir>/voice.md"` argument after the register. + + A baseline metadata or path mismatch is a failed verification, not a score. + Correct the invocation and rerun it. Never reuse a report from another + register, quote policy, file, or linter score version. +7. Make at most one more rewrite-and-score pass. +8. Compare `source.md` with `rewrite.md` in both directions. Every source + proposition and qualifier must survive, and every output proposition must + come from the source or an explicit user instruction. Preserve `must`, + `should`, `may`, `can`, uncertainty, negation, and exceptions unless the + source itself licenses the change. +9. Run the manual checklist and an adversarial final read. Ask: "What still + sounds generated?", "What fact or limit disappeared?", and "What did this + rewrite add?" Repair any defect those questions expose. +10. Only now copy the final prose to the requested destination. For a user + file, replace its prose while preserving protected spans. For pasted text, + return the rewrite. + +If Python cannot run, perform the same reference, inventory, and adversarial +checks by eye. Say **not linted** and report no estimated number. + +## Audit + +Do not rewrite or create an output document. + +1. Load all three references and score the supplied working copy once with + `--register audit`. +2. Infer the primary register from document purpose. Classify each hit as + governing, out of register, or a false positive. +3. Check every marker and every manual rule, including claim preservation, + voice-sample conflicts, repeated names/openings, article use, paragraph + focus, headings that restate themselves, and hollow paragraphs. +4. Return the single score, the source claim/condition/scope inventory, and a + compact findings table: rule, source span, classification, and suggested + edit. Make no change. + +## Protected spans and Markdown + +Never change fenced or inline code, commands, identifiers, part numbers, +units, error strings used for search, YAML frontmatter, data, or link targets. +Link text is prose. Preserve quoted third-party language when context identifies +it as a quotation, title, example under discussion, or externally owned text. + +Blockquotes, callouts, and table cells are not inherently quotations. Lint and +edit their first-party prose. Use `--quote-safe` only when the caller explicitly +identifies the running prose as examples or third-party quotation that must be +excluded. It suppresses findings only in those protected regions. The option +must not hide ordinary first-party prose elsewhere in the document. + +## Read the report + +Consume the full JSON object. Never decide from the exit code alone. + +| Key | Meaning | +|---|---| +| `violations` and `samples` | high-confidence counts that contribute to the score, with examples | +| `markers` | possible issues that require contextual judgment and do not contribute to the score | +| `by_layer` | the reference layer that owns each scored category | +| `manual_checks` | rules the linter cannot certify | +| `file_identity`, `voice_sample_identity` | canonical inputs that make baseline comparison safe | +| `delta` | compatible baseline `before`, `after`, and `improved` values | +| `total_per100w` | one primary density score, not a quality verdict | +| `fail_over`, `over_fail_over` | optional caller-supplied finite, nonnegative CI threshold and its result; absent without `--fail-over N` | + +Singleton transitions, curly quotes, em dashes, short emphatic sentences, and +deliberate repetition are markers, not proof of machine writing. Score them +only when they form the repeated or clustered pattern documented by the +reference. A supplied voice sample outranks those style defaults in `voiced`. + +## Delivery + +| Caller | Return | +|---|---| +| another skill editing final outbound prose | final prose only; keep register and score metadata in the transcript | +| person who pasted prose | final rewrite, register, score delta, and unresolved findings | +| person who supplied a file | edit the file, then report register, score delta, and unresolved findings | +| explicit `audit` | findings and one score; no rewrite | + +Another skill invokes this pass exactly once, after it has composed all +free-form prose and before it shows, gates, or posts that prose. A transport +layer does not invoke it. Do not apply it automatically to structured payloads, +commands, checksums, generated reports, ADRs, AGENTS files, skills, PRDs, or +other local documents with an owning authoring skill. An explicit user request +can still edit any prose document. + +## Completion + +Work is complete only when: + +- the operation and primary register match the caller's intent; +- the before and after reports came from the same canonical `rewrite.md`, voice + sample, score version, register, and quote policy, or the response says + **not linted**; +- every scored violation, marker, and applicable manual rule is fixed, rejected + with a concrete false-positive reason, classified out of register in an + audit, or reported as unresolved; +- the two-way inventory proves that no source claim, scope limit, condition, or + modal force was lost and no unsupported claim was added; +- protected bytes are unchanged; +- an adversarial final read found no remaining unreported defect; +- delivery follows the table above; and +- the unique temporary directory is removed. diff --git a/skills/reference/prose-editing/agents/openai.yaml b/skills/reference/prose-editing/agents/openai.yaml new file mode 100644 index 0000000..ddf7ada --- /dev/null +++ b/skills/reference/prose-editing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Prose Editing" + short_description: "Edit or audit supplied prose without changing its claims" + default_prompt: "Use $prose-editing to edit this supplied draft, preserve every claim and qualifier, and verify the result." diff --git a/skills/reference/prose-editing/references/compression.md b/skills/reference/prose-editing/references/compression.md new file mode 100644 index 0000000..bfff619 --- /dev/null +++ b/skills/reference/prose-editing/references/compression.md @@ -0,0 +1,265 @@ +# Compression layer + +Loaded in `strict` and `flavored`. Not loaded in `voiced`, where fixed sentence +caps would remove the voice that register exists to protect. + +This is Simplified Technical English discipline: short sentences, plain words, +named actors, one name per thing. It shapes how a sentence is built. The +machine tells in `mechanical.md` are a separate problem and apply on top of +everything here. + +**This file has two parts.** Everything from here down to the end of +[Structure](#structure) applies in `strict` and in `flavored`. Everything under +[`strict` only](#strict-only) applies in `strict` alone. In `flavored`, read to +the end of Structure and stop there. + +## Sentences + +### `long_sentence` + +An instruction has at most 20 words. A descriptive sentence has at most 25. +Classify by function, not by punctuation: an imperative step or a sentence +that tells the reader what to do is an instruction. Split at the natural joint, +usually a conjunction or a relative pronoun. + +> When the plugin export overlay is regenerated after an upstream release, the +> workflow opens a pull request against the overlays repository so that the +> plugin catalog can pick up the new version on the next build. + +> The workflow regenerates the plugin export overlay after an upstream release. +> It then opens a pull request against the overlays repository. The plugin +> catalog picks up the new version on the next build. + +Split by adding a period, never by deleting a qualifier. A condition, version, +or scope word that was in the draft stays even when the sentence remains over +its limit. Keep the long sentence and report the reason. + +### `semicolon` + +Replace it with a period. The two halves were already two sentences. + +### `contraction` + +Expand it. `it is`, `does not`, `cannot`, `will not`. + +### `long_paragraph` + +Over six sentences. Split at the point where the topic changes. When no such +point exists, the paragraph is probably saying one thing six times, and the +edit is to cut rather than to split. + +### Connectors + +Compression produces staccato when it is applied without care. Short sentences +are the goal; disconnected ones are not. Join related steps with a plain +connector: `then`, `but`, `so`, `thus`, `as a result`, `after that`. + +> Push the tag. The Konflux pipeline starts. A Snapshot appears. The release +> plan runs. The bundle lands in the catalog. + +> Push the tag. The Konflux pipeline then starts and produces a Snapshot. When +> the release plan runs, the bundle lands in the catalog. + +The voice layer scores this rhythm as `staccato_drama`, where it reads as +manufactured drama rather than as over-compression. That layer does not load in +`strict` or in `flavored`, so nothing scores the rhythm here. Fix it anyway. + +## Verbs + +### `passive_voice` + +Name the actor and put it in front. + +> The dynamic-plugins ConfigMap is mounted by the operator. + +> The operator mounts the dynamic-plugins ConfigMap. + +Two cases stay as they are. Where the participle describes a state rather than +reporting an action, nothing is being done to anything and there is no actor to +restore: `the field is required`, `the route is disabled`, `the plugin is +installed`. And a passive whose actor the draft never named stays passive. + +### `complex_tense` + +Use the infinitive, the imperative, the simple present, the simple past, or the +simple future. Drop the perfect and the progressive from the spine of the +sentence. + +> The overlay has been regenerated and the catalog is now rebuilding. + +> The workflow regenerated the overlay. The catalog rebuild is running. + +### `ing_main_verb` + +A progressive form where a simple tense says the same thing. + +> The operator is watching the ConfigMap for changes. + +> The operator watches the ConfigMap for changes. + +### `nominalization` + +An action hidden inside a noun, propped up by an empty verb. + +> Perform a validation of the `app-config.yaml` file before the deployment of +> the chart. + +> Validate `app-config.yaml` before you deploy the chart. + +### `phrasal_verb` + +| Do not write | Write | +|---|---| +| spin up | start | +| spin down, tear down | stop, remove | +| kick off | start | +| roll out | release | +| reach out | contact | +| dive into | read | +| drill down | inspect | +| circle back | return | +| ramp up | increase | +| stand up | deploy | + +## Words + +### `verbose_word` + +The long form of a word that has a short one. Words that are wrong in every +register live in `mechanical.md` under `ai_vocabulary`, so they are not +repeated here. + +| Do not write | Write | +|---|---| +| begin, commence, initiate | start | +| prior to | before | +| subsequent to | after | +| obtain, acquire | get | +| provide | give, or the concrete verb | +| perform, conduct | do, or the action verb | +| in order to | to | +| additionally, furthermore, moreover | start a new sentence, or delete | +| regarding, concerning | about | +| demonstrate | show | +| facilitate | name the action | +| ensure | make sure, or state the requirement outright | +| aforementioned | this | +| whilst, amongst | while, among | +| numerous, myriad, plethora | the count the source gives, or `many` | + +### One name per thing + +Pick one word for each concept and reuse it for the whole document. Do not +rotate `check`, `verify`, `validate`, and `confirm` for the same action. Do not +call the same object the operator, the controller, and the reconciler in three +consecutive paragraphs. + +Technical nouns the source already uses stay: `PipelineRun`, `Snapshot`, +`overlay`, `dynamic plugin`, `ConfigMap`. An abbreviation gets spelled out the +first time it appears, and is the only form used from then on. + +This rule is not lintable. Read for it. + +### One meaning per word + +Use a word with the same meaning throughout the document. Do not use `fall` for +both physical movement and a decrease, or `follow` for both sequence and +obedience. Keep established technical meanings and replace only the ambiguous +use. This rule requires judgment. + +### American spelling + +Use American spelling in compressed technical prose: `color`, `behavior`, +`catalog`, and `license` as the noun. Do not alter product names, quotations, +identifiers, or a repository's explicit house style. + +## Structure + +### Keep the articles + +Do not drop `a`, `an`, or `the` to make a sentence shorter. Telegraphic prose +is not compressed prose. + +> Remove secret from namespace and restart deployment. + +> Remove the secret from the namespace, then restart the deployment. + +General statements about an abstract idea take no article, and that is correct +English rather than a dropped word. `Dynamic plugins load at startup.` stays as +it is. + +### A list item may stay a label + +Some list items name a thing rather than assert something about it: steps in a +sequence, entries in a changelog, cells in a table. Those are labels, and a +label is allowed to be a fragment. Padding one out into a full sentence so that +it can carry an article makes it longer and no clearer. + +> - Frontend receives the session JWT +> - Backend validates the token against Keycloak +> - Catalog returns the entity + +Rewriting those three into full sentences adds words and no meaning. Leave +them. The `inline_header_list` tell in `mechanical.md` is a different thing: a +label plus a sentence that restates the label. A bare label is fine. + +### Condition before command + +Put the condition first and separate it from the command with a comma. + +> Read the task log if the PipelineRun fails. + +> If the PipelineRun fails, read the task log. + +Give each sentence a single instruction. Two actions share one sentence only +when the reader has to perform them together. + +## `strict` only + +**Everything below applies in `strict` and in no other register.** In +`flavored`, the file ended at the section above. + +### `strict_banned_word` + +Ambiguous words carry real risk in a procedure. A reader who guesses wrong runs +the wrong command. + +| Do not write | Write | +|---|---| +| however | but, or start a new sentence | +| since | because for a reason, after for a time | +| should | must, or the bare command: `Stop the pod.` | +| shall | must, or the bare command | +| using | with, or `use` as the verb | +| follow, follows, followed | do, or `do the steps in` | +| may | can for ability, might for possibility | +| press | push, for a physical control | + +The match on `may` is case-sensitive, so the month is not flagged. + +> You should follow the runbook using the listed steps. + +> Do the steps in the runbook. + +### Safety labels + +Three labels, three meanings, and they are not interchangeable. + +| Label | Means | Rule | +|---|---|---| +| `WARNING` | risk of injury to a person | rare in software prose. Keep it for text that touches hardware or a physical operation. | +| `CAUTION` | risk of damage to equipment, data, or a running system | carries most of the traffic in a runbook | +| `NOTE` | information only | never an instruction. A `NOTE` that tells the reader to do something is a step in disguise. | + +Put the label immediately before the step it protects, not in a block at the +top of the procedure. State the condition or the command first, then the +consequence. + +> **CAUTION:** The next command deletes the PostgreSQL PVC. The catalog +> database cannot be recovered after it runs. +> +> 4. Run `oc delete pvc data-rhdh-postgresql-0`. + +Safety text and error strings take no hedge. Name the condition and the action. +Write `If the reconcile fails, check the operator log.` rather than `you might +want to consider checking the logs`. diff --git a/skills/reference/prose-editing/references/mechanical.md b/skills/reference/prose-editing/references/mechanical.md new file mode 100644 index 0000000..746d907 --- /dev/null +++ b/skills/reference/prose-editing/references/mechanical.md @@ -0,0 +1,407 @@ +# Mechanical layer + +Loaded in every register. These are patterns to inspect, not proof that a +machine wrote the text. Some are strong enough to score from one occurrence; +others become a tell only when they repeat or cluster. Apply the documented +false-positive test before changing them. + +## Punctuation and decoration + +| Category | The tell | The edit | +|---|---|---| +| `em_dash` | repeated `—`, `–`, or spaced ` -- ` used for a sales-like rhythm | use the writer's sample; otherwise use a period, comma, colon, or parentheses. One dash is a marker, not a tell. | +| `curly_quote` | curly quotation marks clustered with other machine patterns when the target style uses straight quotes | follow the writer's sample or target format. One curly quotation is a marker, not a tell. Never change code or shell strings. | +| `emoji` | a rocket, a check mark, or a warning sign decorating a heading or a bullet | delete it. The heading already carries the meaning. | +| `title_case_heading` | `## Installing Dynamic Plugins On OpenShift` | sentence case: `## Installing dynamic plugins on OpenShift`. Proper nouns keep their capitals: OpenShift, Backstage, Red Hat Developer Hub, Konflux. | + +A heading that is a literal identifier stays exactly as written. `## app-config.yaml` +is a filename, not a title. + +### `boldface_overuse` + +Bold applied repeatedly to ordinary nouns in running prose is decoration, not +structure. Remove it. Keep bold on a defined term at first use, a definition +label, or the one phrase a warning must emphasize. One bold term is a marker; +several decorative spans form the tell. + +### `inline_header_list` + +A bulleted list where each item opens with a bolded generic label, a colon, +then a sentence restating the label. The label carries no information and the +sentence carries almost none. + +> - **Performance:** Performance has been improved in this release. +> - **Security:** Security has been strengthened for plugin loading. +> - **Usability:** The user experience of the header is now better. + +Fix it by moving the fact into the item, not by deleting the list. + +> - The backend starts in half the time it took in 1.9. +> - The backend verifies a dynamic plugin's integrity hash before it loads it. +> - The global-header plugin reads its layout from `app-config.yaml`. + +A real term followed by its definition is a definition list, and it is fine. +`**RHIDP**: the Jira project for Developer Hub engineering work` is not this +tell. + +## Chatbot residue and hedging + +### `chatbot_residue` + +Conversation with the assistant, pasted into the document as if it were +content. Delete the whole sentence and keep what surrounded it. + +> Here is an overview of the bulk-import plugin. I hope this helps! Let me know +> if you would like the API reference as well. + +> The bulk-import plugin adds repositories from a GitHub organization to the +> software catalog. + +Praise that exists only to agree with the reader is the same residue. Remove +`great question`, `you are absolutely right`, `that is an excellent point`, and +similar approval before an answer. Keep praise when evaluating somebody's work +is the document's actual purpose. + +### `modal_hedge` + +A sentence that announces the importance of the next clause instead of stating +it. Delete the announcement and keep the clause. + +> It is important to note that the operator does not restart the Deployment on +> every reconcile. + +> The operator does not restart the Deployment on every reconcile. + +The same edit applies to `it should be noted`, `it is worth noting`, `please +note that`, `as mentioned above`, and `as previously discussed`. + +### `qualifier_pile` + +Keep uncertainty and scope that the source supports, but do not stack repairs +such as `could potentially possibly`, `might arguably`, `to be fair`, and `in +some cases it may`. Choose the one qualifier that states the actual confidence +or scope. Never strengthen `may` to `will` merely to shorten a sentence. + +### `filler_phrase` + +| Do not write | Write | +|---|---| +| due to the fact that | because | +| at this point in time | now | +| in the event that | if | +| has the ability to | can | +| a number of | the count from the source, or `some` | +| for the purpose of | to | + +### `vague_attribution` + +A claim credited to an authority that is never named. + +> Industry reports show that platform teams prefer a single developer portal. + +Name the source when the draft has one, and cut the sentence when it does not. + +### `notability_padding` + +A list of famous publications, experts, or follower counts used only to prove +that a subject matters. Keep a citation when the draft says what the source +reported. Otherwise keep only the useful context and do not invent it. + +> Her work has received independent coverage from leading national media +> outlets, and she has an active social media presence. + +> The New York Times reported her position on the 2025 policy change. + +### `knowledge_gap` + +A knowledge-cutoff disclaimer followed by a plausible guess is not a fact. +Watch for `up to my last update`, `based on available information`, `not +publicly available`, `likely`, `it is believed`, and claims that missing public +detail proves somebody is private. State only what the supplied sources show, +or remove the passage. Never turn absence of evidence into biography. + +### Unsupported defenses and fake alternatives + +`unsupported_objection` answers an objection nobody raised: `this is not +really about`, `I am not arguing that`, `to be clear`, `some might say`, or a +similar defense with no named source or developed response. Remove the defense +and state its surviving claim directly. Keep legal limits, safety disclaimers, +FAQ answers, named objections, and corrections. + +`fake_alternative` introduces an option no reader would choose, rejects it in a +clause, and never uses it again: `a tempting approach would be`, `you might +think`, `it would be easy to just`. Remove drafting residue. Keep real design +alternatives that inform a decision or argument. + +## Inflation + +### `ai_vocabulary` + +Words that are wrong in every register. A word that is merely long is a +compression problem rather than a machine tell, so it is not listed here. + +| Do not write | Write | +|---|---| +| leverage, utilize | use | +| delve into | read, examine | +| crucial, pivotal, vital | say what breaks without it, or cut | +| seamless, effortless | cut | +| robust, powerful, cutting-edge | cut, or keep a measure the source already gives | +| showcase | show, list | +| underscore, highlight (as a verb) | say the point directly | +| landscape, tapestry, ecosystem (as abstract nouns) | name the actual set of things | +| testament to | cut | +| align with | match, follow | +| intricate, nuanced | cut, or say what the complication is | +| actually, key, valuable, enduring, fostering, garner | use the concrete claim, or cut | +| quietly | keep only when it literally describes sound or secrecy | +| gate, gated, gating | keep established technical use; replace figurative use | + +> The marketplace plugin leverages a robust catalog to showcase the plugin +> landscape. + +> The marketplace plugin reads the catalog and lists the available plugins. + +### `promotional` + +Advertisement register in a document nobody is buying. + +| Do not write | Write | +|---|---| +| boasts a, features a | has | +| vibrant, thriving, rich | cut | +| nestled in, at the heart of | the actual location, or cut | +| renowned, industry-leading, best-in-class | cut | +| breathtaking, stunning | cut | +| commitment to, dedication to | the thing that was actually done | + +> Red Hat Developer Hub boasts a vibrant plugin ecosystem and a deep commitment +> to developer productivity. + +> Red Hat Developer Hub supports dynamic plugins. + +### `authority_trope` + +A ceremonial run-up that promises a deeper truth and then delivers an ordinary +point. Watch for `the real question is`, `at its core`, `what really matters`, +`fundamentally`, `the deeper issue`. + +> At its core, what really matters about dynamic plugins is load order. + +> Dynamic plugins depend on load order. + +### `aphorism` + +An ordinary claim reshaped into a portable saying. Watch for `X is the Y of Z`, +`the currency of`, `the architecture of`, `X becomes a trap`. + +> Configuration is the tax you pay for flexibility. + +> Every option in `app-config.yaml` is one more thing to keep working. + +### `generic_conclusion` + +A closing paragraph made of good feeling and no content. The usual shapes are a +bright future, an invitation to build something, and a thank-you to a community +that was never described. A `## Conclusion` heading over one of them counts too. + +> The future of the platform is bright, and we cannot wait to see what you +> build with it. Exciting things are ahead. + +> In conclusion, the possibilities with dynamic plugins are endless. + +The second one is the reason this sits in the mechanical layer: a README ends +that way as readily as an announcement does. + +Delete the paragraph and stop wherever the draft last said something specific: +the release date, the upgrade path, the docs link, the tracker that takes +feedback. When the draft states real plans, use those. Rewriting one send-off +into a better send-off leaves the same defect in place. + +### `formulaic_section` + +A stock `Challenges`, `Legacy`, or `Future outlook` section often repeats +vague constraints and optimism. Keep concrete facts such as named shortages, +deadlines, and planned actions. Remove the section when it adds none. + +### `significance_inflation` + +A sentence whose only job is to say that the topic matters. It usually claims a +turning point, a broader trend, or an ongoing commitment. + +> The 1.10 release marks a pivotal moment in the evolution of the platform and +> underscores our ongoing commitment to the developer community. + +> Red Hat Developer Hub 1.10 is generally available today. + +Watch for `marks a turning point`, `represents a shift`, `reflects a broader`, +`sets the stage for`, `is a testament to`, `plays a key role in`, `leaves an +indelible mark`. + +The test is subtraction. Delete the sentence and read the paragraph again. When +nothing factual is missing, the sentence was inflation. When the draft has a +real reason the release matters, such as a deprecation deadline or a supported +upgrade path, state that reason instead of the feeling. + +| Do not write | Write | +|---|---| +| a pivotal moment in the evolution of | what changed | +| reflects a broader shift toward | the change itself, when the draft names one | +| underscores our commitment to | what was shipped | +| sets the stage for | what happens next, with its date, when the draft has one | + +### `previous_version_frame` + +Documentation and comments describe the current behavior. Do not anchor them +to the diff with `was added`, `now`, `new`, `previously`, or a discarded +implementation unless the document is a changelog, release note, migration +guide, or other intentionally version-scoped text. State the current behavior. + +## Sentence shapes + +### `copula_avoidance` + +An elaborate verb standing in for `is` or `has`. + +> The dynamic-plugins ConfigMap serves as the source of truth for enabled +> plugins. + +> The dynamic-plugins ConfigMap lists the enabled plugins. + +`serves as`, `stands as`, `represents`, `marks`, and `boasts` all collapse to +`is` or `has`, or to a verb that names the real job. + +### `negative_parallelism` + +Three shapes, one habit: defining a thing by what it is not. + +> It is not just a plugin registry, it is a marketplace. + +> The marketplace plugin lists plugins and installs them. + +> Not only does the operator create the Deployment, it also creates the Route. + +> The operator creates the Deployment and the Route. + +The third shape is a negation fragment tacked onto a sentence in place of a +real clause. + +> The overlay reads the version from the plugin's `package.json`, no manual +> bumps. + +> The overlay reads the version from the plugin's `package.json`, so nobody +> edits it by hand. + +### `ing_analysis` + +A trailing participial phrase bolted onto a finished sentence to add depth it +does not have. Split it into its own sentence when it carries a fact, and cut +it when it does not. + +> The operator now watches the ConfigMap, ensuring that the backend restarts +> whenever the configuration changes and reflecting a tighter reconcile loop. + +> The operator watches the ConfigMap. The backend restarts when the +> configuration changes. + +Watch for a comma followed by `highlighting`, `underscoring`, `ensuring`, +`reflecting`, `contributing to`, `showcasing`, `enabling`, `allowing`. + +### `false_range` + +A `from X to Y` frame whose endpoints do not sit on a scale. + +> The plugin catalog covers everything from authentication to observability, +> from CI status to cost insights. + +> The plugin catalog includes authentication, observability, CI status, and +> cost plugins. + +### `signposting` + +Announcing the next paragraph instead of writing it. Watch for `let us dive +in`, `here is what you need to know`, `now let us look at`, `in this section we +will`. + +> Let us look at how the operator mounts the ConfigMap. Here is what you need +> to know. + +> The operator mounts the app-config ConfigMap into the backend container. + +A heading already does this job, which is why the sentence under it is +redundant. + +Casual announcements such as `heads up`, `quick note`, `before I forget`, and +`one thing that bit me` have the same defect when they only announce the next +sentence. Keep them when they carry real voice or context rather than serving +as an empty signpost. + +### `rhetorical_opener` + +A staged pause before an ordinary answer. Watch for a standalone `Honestly?`, +`Look,`, `Here is the thing`, `The thing is`, `Let us be honest`. + +> Is the 1.10 upgrade safe? Honestly? It depends on which dynamic plugins you +> enabled. + +> Whether the 1.10 upgrade is safe depends on which dynamic plugins you +> enabled. + +The word inside a sentence is ordinary English. The tell is the theatrical +one-word opener. + +### Active subjects and stable names + +Use active voice when it makes the actor and action clearer. Restore a missing +subject in fragments such as `No configuration file needed`. Keep a passive +when the actor is unknown or irrelevant and keep participles that describe a +state, such as `the field is required`. In `strict`, a known actor must be +active. In other registers this is a contextual marker. + +Use one clear name for one subject. Do not cycle through synonyms to avoid a +useful repetition. Also inspect several consecutive sentences that begin with +the same subject. Merge or vary them only when the repetition is accidental; +deliberate anaphora and a supplied voice sample win. + +### `predicate_hyphenation` + +Keep a compound modifier hyphenated before its noun: `a high-quality report`. +Do not automatically carry that hyphen into predicate position: `the report is +high quality`. Product terms and established technical compounds keep their +spelling. + +### Heading restatement and hollow paragraphs + +A heading followed by a sentence that merely repeats it has not begun the +section. Remove the repeated sentence. A paragraph that adds no proposition, +condition, name, number, or command beyond the preceding paragraph is hollow; +delete it or merge its one useful clause. + +## Markers + +Reported in `markers`, never added to the score. Account for them with context; +do not let one drive a rewrite on its own. + +### `noun_train` + +Four or more nouns stacked with nothing between them. Keep a multi-word noun to +three words at most. Unpack the rest with `of`, `that`, or a hyphen. + +> the dynamic plugin registry cache invalidation handler + +> the handler that invalidates the dynamic-plugin registry cache + +A product name is one noun no matter how many words it holds. Red Hat Developer +Hub is not a noun train. + +### `rule_of_three` + +Three abstract nouns in a row, arranged for rhythm rather than for content. + +> The 1.10 release brings speed, stability, and simplicity. + +> The 1.10 release cuts backend startup time and adds RBAC for dynamic plugins. + +A list with three real members is a fact, not a tell. Three supported OpenShift +versions, three Jira projects, and three failing tasks in a PipelineRun all +stay as they are. diff --git a/skills/reference/prose-editing/references/voice.md b/skills/reference/prose-editing/references/voice.md new file mode 100644 index 0000000..6e68934 --- /dev/null +++ b/skills/reference/prose-editing/references/voice.md @@ -0,0 +1,63 @@ +# Voice layer + +Loaded in `voiced`: release announcements, engineering blog posts, community +messages, and personal prose. Short steps are right in a runbook, so apply this +layer only where the document's purpose permits a voice. + +`mechanical.md` still applies in full. Nothing compresses sentences here. + +## What this register protects + +A release announcement is supposed to sound like a person wrote it for other +people. Compression rules would flatten exactly what makes that work, so this +register runs none. In `voiced`, leave these alone: + +- Contractions. `it's`, `doesn't`, `we've`. +- Sentences over twenty words, when the length is doing something. +- Semicolons and colons. +- First person, singular or plural. +- Uneven rhythm, asides, parentheticals, and a writer who admits uncertainty. + +Voice is stance, not fact. A writer may say the upgrade was harder than +expected. A writer may not gain a benchmark, a customer count, or a date that +the draft never contained. + +## A writing sample wins + +When the user supplies their own prior writing, match its stable sentence +length, word choice, openings, punctuation, repeated phrases, and transitions. +Do not replace a deliberate casual word with a formal one or flatten a quirk. +The sample outranks default cleanup rules for em dashes, curly quotes, +transitions, and repeated openings. Match a habit at roughly the sample's rate; +do not use one occurrence as permission to multiply it. + +## Preserve human detail + +Keep specific and unusual details, mixed feelings, unresolved tension, dated +references, deliberate first-person choices, sentence-length variety, genuine +asides, and self-correction. Add none of these when the source lacks them. +Technical, legal, safety, and reference prose stays neutral even when it is +inside a voiced document. + +## `staccato_drama` + +A run of short fragments stacked to manufacture momentum. Each one lands like a +closing line, and after three of them the paragraph is performing rather than +reporting. + +> Then the marketplace shipped. No more editing ConfigMaps by hand. No more +> waiting on a rebuild. No more guessing which version you had. The old +> workflow was gone. + +> The marketplace plugin removed the manual ConfigMap edits that installing a +> plugin used to require, and it shows the installed version directly. + +The trigger is the run, not the length. One short sentence placed for emphasis +is ordinary writing and stays. Three or more in a row, especially when they +share an opening word, is the tell. + +The same rhythm also arrives by accident, out of a rewrite that split every +sentence of a procedure until the steps stopped connecting. Here it usually +arrives on purpose, out of a draft reaching for drama. The edit is the same +either way: join the fragments with a plain connector such as `then`, `but`, +`so`, or `after that`. diff --git a/skills/reference/prose-editing/scripts/NOTICE b/skills/reference/prose-editing/scripts/NOTICE new file mode 100644 index 0000000..25886b1 --- /dev/null +++ b/skills/reference/prose-editing/scripts/NOTICE @@ -0,0 +1,77 @@ +NOTICE + +lint.py in this directory adapts heuristics from two MIT-licensed projects. The +word lists, the sentence and paragraph segmentation, and the participle and +tense patterns come from the first. The catalogue of AI writing tells that the +mechanical checks score comes from the second. + +Neither project's prose is reproduced here. The rules in ../SKILL.md and +../references/ are written independently for this repository. + +-------------------------------------------------------------------------------- +Original work: + Project: The cure for AI slop - the kit (ste-lint.py) + Author: Ege Çelebi + Repository: https://github.com/woosal1337/blog + License: MIT + +MIT License + +Copyright (c) 2026 Ege Çelebi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- +Original work: + Project: humanizer + Author: Siqi Chen + Repository: https://github.com/blader/humanizer + License: MIT + +MIT License + +Copyright (c) 2025 Siqi Chen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- +humanizer draws its catalogue of AI writing tells from the English Wikipedia +page "Wikipedia:Signs of AI writing", maintained by WikiProject AI Cleanup and +licensed CC BY-SA 4.0. This repository takes the taxonomy only. No text from +that page, and no text from humanizer, is reproduced here. + +ASD-STE100 is a registered EU trademark (No. 017966390). The rules here are an +unofficial flavour of Simplified Technical English. This is not a certified +ASD-STE100 checker and is not affiliated with ASD. diff --git a/skills/reference/prose-editing/scripts/lint.py b/skills/reference/prose-editing/scripts/lint.py new file mode 100644 index 0000000..2c862bd --- /dev/null +++ b/skills/reference/prose-editing/scripts/lint.py @@ -0,0 +1,2406 @@ +#!/usr/bin/env python3 +"""Score prose for AI tells and Simplified Technical English discipline. + +Three layers. Mechanical checks run in every register, compression checks run +in the two rewriting registers, voice checks run only where prose is allowed a +voice. See the register table in --help. Standard library only. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import re +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +SCORE_VERSION = 11 + +REGISTERS = ("strict", "flavored", "voiced", "audit") +DEFAULT_REGISTER = "flavored" + +INSTRUCTION_MAX_WORDS = 20 +DESCRIPTIVE_MAX_WORDS = 25 +LONG_PARAGRAPH_SENTENCES = 6 +# The documented example stacks fragments of four to seven words, so a cap of +# five saw three of its five fragments and never fired. The run is the tell, +# not the length, so the cap is loose and STACCATO_RUN does the deciding. +STACCATO_MAX_WORDS = 7 +STACCATO_RUN = 3 +BOLD_PER_PARAGRAPH = 1 +NOUN_TRAIN_MIN = 4 +SAMPLE_LIMIT = 6 + +MECHANICAL = ( + "em_dash", + "transition_stack", + "chatbot_residue", + "copula_avoidance", + "negative_parallelism", + "emoji", + "curly_quote", + "title_case_heading", + "inline_header_list", + "boldface_overuse", + "ai_vocabulary", + "promotional", + "authority_trope", + "aphorism", + "signposting", + "rhetorical_opener", + "ing_analysis", + "false_range", + "vague_attribution", + "modal_hedge", + "filler_phrase", + # A send-off made of good feeling and a sentence claiming the topic matters + # are tells in every register. A README closing on "in conclusion, the + # possibilities are endless" is the same defect as a blog post doing it. + "generic_conclusion", + "significance_inflation", +) +COMPRESSION = ( + "long_sentence", + "semicolon", + "contraction", + "passive_voice", + "complex_tense", + "ing_main_verb", + "nominalization", + "phrasal_verb", + "verbose_word", + "long_paragraph", + "strict_banned_word", +) +# Only the two tells that a register can legitimately want. Short steps are +# correct in a runbook and a bold defined term is correct in a README, so +# neither is scored outside prose that is allowed a voice. +VOICE = ("staccato_drama",) +MARKERS = ( + "singleton_em_dash", + "singleton_curly_quote", + "singleton_transition", + "singleton_bold", + "american_spelling", + "condition_before_command", + "copula_candidate", + "qualifier_phrase", + "noun_train", + "rule_of_three", + "notability_padding", + "knowledge_gap", + "unsupported_objection", + "fake_alternative", + "formulaic_section", + "previous_version_frame", + "repeated_opening", + "predicate_hyphenation", + "heading_restatement", + "missing_subject", + "watched_vocabulary", + "casual_signposting", +) +MANUAL_CHECKS = ( + "claim_preservation", + "voice_fidelity", + "terminology_consistency", + "word_meaning_consistency", + "active_subject_context", + "one_instruction_per_sentence", + "article_use", + "abbreviation_definition", + "paragraph_focus", + "safety_labels", + "heading_restatement", + "hollow_paragraph", + "quotation_ownership", + "objection_context", + "alternative_relevance", + "american_spelling", + "condition_before_command", +) + +LAYERS = {"mechanical": MECHANICAL, "compression": COMPRESSION, "voice": VOICE} +REGISTER_LAYERS = { + "strict": ("mechanical", "compression"), + "flavored": ("mechanical", "compression"), + "voiced": ("mechanical", "voice"), + # audit reports every rewriting layer and applies none of it. + "audit": ("mechanical", "compression", "voice"), +} +STRICT_ONLY = ("strict_banned_word",) +# --------------------------------------------------------------------------- +# Phrase lists. Data only. Every list is matched in one pass and the longest +# match over a span wins, so a longer phrase in one list may contain a shorter +# phrase in another: `marks a pivotal moment` scores inflation, not inflation +# plus vocabulary. Two lists must still never hold the same phrase, because +# then no rule decides which one owns it. +# --------------------------------------------------------------------------- + +AI_VOCABULARY = ( + "actionable insights", + "aforementioned", + "align with", + "aligns with", + "amongst", + "bolster", + "bolsters", + "bolstered", + "bolstering", + "comprehensive", + "comprehensively", + "cornerstone", + "crucial", + "deep dive", + "deep dives", + "deep-dive", + "delve", + "delves", + "delved", + "delving", + "enduring", + "enhance", + "enhances", + "enhanced", + "ever-evolving", + "foster", + "fosters", + "fostered", + "garner", + "garners", + "garnered", + # "harness" alone is a test harness or a dev harness in any engineering + # repository, so only the transitive marketing shape scores. + "harness the", + "harnesses the", + "harnessing", + "henceforth", + "highlight", + "highlights", + "highlighted", + "holistic", + "interplay", + "intricate", + "intricacies", + "landscape", + "leverage", + "leverages", + "leveraged", + "leveraging", + "multifaceted", + "myriad", + "navigate the complexities", + "navigating the complexities", + "nuanced", + "paradigm shift", + "pivotal", + "plethora", + "realm", + "robust", + "seamless", + "seamlessly", + "showcase", + "showcases", + "showcased", + "streamline", + "streamlines", + "streamlined", + "streamlining", + "synergies", + "synergy", + "tapestry", + "testament", + "therein", + "transformative", + "underscore", + "underscores", + "underscored", + "unprecedented", + "utilization", + "utilize", + "utilizes", + "utilized", + "utilizing", + "whilst", +) +PROMOTIONAL = ( + "battle-tested", + "best-in-class", + "blazing", + "bleeding-edge", + "boasts", + "breathtaking", + "commitment to", + "cutting-edge", + "delightful", + "effortless", + "effortlessly", + "elegant", + "elevate", + "elevates", + "empower", + "empowers", + "enterprise-grade", + "exemplifies", + "fast-paced", + "first-class", + "future-proof", + "game changer", + "game-changer", + "game-changing", + "groundbreaking", + "in the heart of", + "industry-leading", + "lightning-fast", + "must-visit", + "natural beauty", + "nestled", + "next-generation", + "powerful", + "profound", + "renowned", + "revolutionary", + "robust and scalable", + "rock-solid", + "state-of-the-art", + "stunning", + "supercharge", + "to the next level", + "turnkey", + "unleash", + "unlock", + "unmatched", + "unparalleled", + "vibrant", + "world-class", +) +AUTHORITY_TROPE = ( + "at its core", + "fundamentally", + "in reality", + "make no mistake", + "the bottom line is", + "the deeper issue", + "the heart of the matter", + "the real question is", + "what really matters", +) +APHORISM = ( + "becomes a trap", + "is not a tool but", + "is not a tool, but", + "the currency of", + "the language of", + "the price you pay", + "the tax you pay", +) +SIGNPOSTING = ( + "here is what you need to know", + "here's what you need to know", + "in this article", + "in this section we", + "let us dive in", + "let us get started", + "let's break this down", + "let's get started", + # "let's dive into" is deliberately absent: phrasal_verb already owns + # "dive into", and one span must not score in two categories. + "let's dive in", + "let's explore", + "let's take a look", + "now let's look at", + "we'll explore", + "without further ado", +) +CHATBOT_RESIDUE = ( + "certainly!", + "excellent point", + "great question", + "here is a", + "here is an", + "here's a", + "here's an", + "i hope this helps", + "let me know if", + "of course!", + "should i continue", + "want me to", + "would you like", + "you're absolutely right", +) +COPULA_AVOIDANCE = ( + "features a", + "features an", + "offers a", + "offers an", + "serve as", + "serves as", + "serving as", + "stand as", + "standing as", + "stands as", +) +VAGUE_ATTRIBUTION = ( + "analysts say", + "critics argue", + "experts argue", + "experts believe", + "experts say", + "industry reports", + "it has been suggested", + "it is believed", + "it is widely", + "many believe", + "observers have", + "research suggests", + "some argue", + "some critics", + "some say", + "sources say", + "studies show", + "widely considered", + "widely regarded", +) +MODAL_HEDGE = ( + "as mentioned", + "as noted above", + "as previously discussed", + "could potentially", + "it can be argued", + "it could be argued", + "it is important to note", + "it is worth noting", + "it should be noted", + "it's important to note", + "it's worth noting", + "might potentially", + "please note that", +) +FILLER_PHRASE = ( + "a number of", + "at the end of the day", + "at this point in time", + "find themselves", + "finds themselves", + "first and foremost", + "for all intents and purposes", + "for the purpose of", + "found themselves", + "has the ability to", + "have the ability to", + "in terms of", + "in today's fast-paced", + "in today's world", + "it goes without saying", + "last but not least", + "needless to say", + "one of the most", + "when it comes to", +) +ING_ANALYSIS = ( + "cementing", + "contributing", + "cultivating", + "demonstrating", + "emphasizing", + "embodying", + "encompassing", + "ensuring", + "fostering", + "highlighting", + "illustrating", + "reflecting", + "showcasing", + "signaling", + "solidifying", + "symbolizing", + "underscoring", +) + +VERBOSE_WORD = ( + "a variety of", + "acquire", + "acquires", + "ascertain", + "attempt to", + "begin", + "begins", + "commence", + "commences", + "demonstrate", + "demonstrates", + "due to the fact that", + "endeavor", + "ensure", + "ensures", + "ensured", + "facilitate", + "facilitates", + "in order to", + "in the event that", + "initiate", + "initiates", + "numerous", + "obtain", + "obtains", + "originate", + "prior to", + "provide", + "provides", + "provided", + "subsequent to", + "terminate", + "with regard to", +) +STRICT_BANNED = ( + "however", + "since", + "should", + "shall", + "using", + "follow", + "follows", + "followed", +) +PHRASAL_VERB = ( + "circle back", + "dive into", + "dives into", + "diving into", + "drill down", + "kick off", + "kicks off", + "ramp up", + "reach out", + "reaching out", + "roll out", + "rolls out", + "spin down", + "spin up", + "spun up", + "tear down", +) + +GENERIC_CONCLUSION = ( + "a step in the right direction", + "as we look ahead", + "can't wait to see", + "cannot wait to see", + "continues to evolve", + # covers "exciting times ahead" too + "exciting times", + "in conclusion", + "in summary", + "one thing is clear", + "only time will tell", + "stay tuned", + "the journey ahead", + "the possibilities are endless", + "the road ahead", + "to sum up", + "to wrap up", + "watch this space", +) +SIGNIFICANCE_INFLATION = ( + "a pivotal moment", + "at a crossroads", + "cements its place", + "deeply rooted", + "in the annals of", + "indelible mark", + "key turning point", + "lasting impact", + "marks a shift", + "marks a turning point", + "plays a key role", + "plays a vital role", + "plays an important role", + "reflects a broader", + "reflects broader", + "represents a shift", + "set the stage for", + "sets the stage for", + "setting the stage for", + "solidifies its place", +) + +PHRASE_LISTS = { + "ai_vocabulary": AI_VOCABULARY, + "promotional": PROMOTIONAL, + "authority_trope": AUTHORITY_TROPE, + "aphorism": APHORISM, + "signposting": SIGNPOSTING, + "chatbot_residue": CHATBOT_RESIDUE, + "copula_avoidance": COPULA_AVOIDANCE, + "vague_attribution": VAGUE_ATTRIBUTION, + "modal_hedge": MODAL_HEDGE, + "filler_phrase": FILLER_PHRASE, + "verbose_word": VERBOSE_WORD, + "strict_banned_word": STRICT_BANNED, + "phrasal_verb": PHRASAL_VERB, + "generic_conclusion": GENERIC_CONCLUSION, + "significance_inflation": SIGNIFICANCE_INFLATION, +} + +# Title case capitalizes the function words that a name never does, so a +# capitalized minor word is the one piece of evidence that does not need a +# lexicon of every product on earth. Compared lowercase. +MINOR_WORDS = ( + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "but", + "by", + "for", + "from", + "in", + "into", + "is", + "nor", + "of", + "off", + "on", + "or", + "out", + "over", + "per", + "than", + "that", + "the", + "to", + "up", + "via", + "vs", + "with", +) +# A "from X to Y" governed by one of these is a real transformation or a real +# scale, not the AI habit of pairing two things that share no axis. +RANGE_LOOKBACK = 4 +# Characters of context read behind a candidate range. Four words never sit +# further back than this, and a fixed window keeps the check linear. +CLAUSE_WINDOW = 200 +# A range endpoint is a noun phrase. One of these inside it means the regex ran +# past the end of the phrase and caught a clause. +RANGE_STOP_WORDS = ( + "are", + "be", + "been", + "did", + "do", + "does", + "had", + "has", + "have", + "if", + "is", + "not", + "rather", + "than", + "was", + "were", + "when", + "which", + "while", +) +RANGE_VERBS = ( + "back", + "backed", + "bump", + "bumped", + "change", + "changed", + "changes", + "clone", + "cloned", + "convert", + "converted", + "converts", + "copied", + "copies", + "copy", + "deploy", + "deployed", + "deploys", + "download", + "downloaded", + "downgrade", + "downgraded", + "export", + "exported", + "extract", + "extracted", + "fetch", + "fetched", + "fetches", + "go", + "goes", + "going", + "grew", + "grow", + "grows", + "import", + "imported", + "increase", + "increased", + "inherit", + "inherits", + "jump", + "jumped", + "migrate", + "migrated", + "migrates", + "migrating", + "move", + "moved", + "moves", + "moving", + "port", + "ported", + "promote", + "promoted", + "publish", + "published", + "pull", + "pulled", + "push", + "pushed", + "range", + "ranged", + "ranges", + "ranging", + "read", + "reads", + "rebase", + "rebased", + "rename", + "renamed", + "run", + "runs", + "scale", + "scaled", + "scales", + "send", + "sent", + "step", + "stepped", + "switch", + "switched", + "switches", + "sync", + "synced", + "transition", + "transitioned", + "translate", + "translated", + "update", + "updated", + "upgrade", + "upgraded", + "upgrades", + "upgrading", + "upload", + "uploaded", + "vary", + "varies", + "went", + "write", + "writes", +) +FUNC_WORDS = frozenset( + """a an the this that these those of for to in on at by with from as and or but if +when then than not no is are was were be been being am do does did has have had will would can could +may might must should shall it its their your our his her they we you i""".split() +) + +TRANSITIONS = ("additionally", "consequently", "furthermore", "however", "moreover") + +# An imperative has no grammatical subject, so the first word is the public +# signal available to a deterministic checker. Keep this list to common +# technical-writing commands. Ambiguous verbs such as "change" still need the +# same 20-word cap when they open a sentence because that is the imperative +# reading in procedures. +INSTRUCTION_VERBS = frozenset( + """add apply attach build change check choose click close compare configure +connect copy create delete disable download edit enable enter examine export +extract fetch follow get give import install keep load make move open paste +press provide publish pull push read remove rename replace restart restore run +save select send set start stop supply switch test type uninstall update upgrade +upload use validate verify wait write""".split() +) +CONDITION_OPENERS = frozenset("after before if once unless until when whenever while".split()) + +# --------------------------------------------------------------------------- +# Patterns +# --------------------------------------------------------------------------- + +BE = r"(?:am|is|are|was|were|be|been|being)" +PP_IRREG = ( + r"(?:done|made|sent|read|built|kept|held|set|put|run|written|shown|" + r"given|taken|found|got|gotten|seen|known|thrown|drawn)" +) +# Predicate adjectives. `The field is required` describes a state, not an +# action somebody performed, so it is not the passive the compression layer is +# looking for. The `X is <stative> by Y` shape still scores through +# STATIVE_BY_RE, which is where a real agent shows up. +STATIVE = ( + r"(?:closed|opened?|damaged|completed?|installed|connected|required|" + r"expected|configured|enabled|disabled|deprecated|supported|allowed|" + r"needed|defined|undefined|named|set|limited|related|unrelated|" + r"documented|detailed|dedicated|unchanged)" +) +# Words that end in `ed` without being a participle, so `is indeed` and +# `is agreed` do not both read as passive voice. +NOT_PARTICIPLE = frozenset( + """indeed need speed seed feed deed breed greed weed embed exceed proceed +succeed hundred sacred wicked naked red bed shed""".split() +) +# `is missing` is a state, not a progressive. These adjectives end in `ing` and +# take a copula the way `is empty` does. +ING_STATE = frozenset( + """missing existing remaining pending outstanding ongoing willing unwilling +interesting exciting promising surprising confusing misleading encouraging +challenging demanding binding corresponding conflicting matching upcoming +incoming outgoing""".split() +) +# Nouns naming an action somebody performs. A positive list is the only safe +# shape here: `\\w+(tion|ment|ance|ence) of` also matches `an instance of`, `the +# distance of` and `the sentence of`, which name no action at all. A miss costs +# a tell; a stop list of every non-action noun costs a false positive on every +# noun nobody thought of. +NOMINALIZATION_NOUNS = ( + "allocation", + "approval", + "cancellation", + "classification", + "completion", + "configuration", + "confirmation", + "conversion", + "creation", + "deletion", + "deployment", + "detection", + "distribution", + "enforcement", + "evaluation", + "execution", + "expansion", + "generation", + "identification", + "implementation", + "initialization", + "inspection", + "installation", + "integration", + "introduction", + "invocation", + "management", + "migration", + "modification", + "normalization", + "notification", + "observation", + "operation", + "optimization", + "orchestration", + "preparation", + "presentation", + "prevention", + "promotion", + "propagation", + "publication", + "reconciliation", + "reduction", + "registration", + "removal", + "replacement", + "resolution", + "restoration", + "retention", + "retrieval", + "selection", + "separation", + "simulation", + "submission", + "synchronization", + "transformation", + "transmission", + "transition", + "translation", + "validation", + "verification", +) +# A clause opening with one of these is a conditional, and the negation that +# follows it is the condition's consequence rather than an antithesis. +SUBORDINATORS = frozenset( + """if when unless while because once after before whenever where whereas +although though since until as""".split() +) + +FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})") +BACKTICK_FENCE_CANDIDATE_RE = re.compile(r"^[ \t]*`{3,}") +TABLE_ROW_RE = re.compile(r"^\s{0,3}\|") +BLOCKQUOTE_RE = re.compile(r"^\s{0,3}>") +LINK_DEF_RE = re.compile(r"^\s{0,3}\[[^\]\n]+\]:\s*\S+") +TABLE_DELIMITER_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") +IDENTIFIER_RE = re.compile( + r"(?<![A-Za-z0-9])(?:[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+|" + r"[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*)(?![A-Za-z0-9])" +) +IMAGE_RE = re.compile(r"!\[[^\]\n]*\]\([^)\n]*\)") +LINK_RE = re.compile(r"\[([^\]\n]*)\]\([^)\n]*\)") +REF_LINK_RE = re.compile(r"\[([^\]\n]*)\]\[[^\]\n]*\]") +AUTOLINK_RE = re.compile(r"<https?://[^>\s]+>|\bhttps?://\S+") +INLINE_QUOTATION_RE = re.compile(r'(?:"[^"\n]+"|“[^”\n]+”)') + +HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$", re.M) +LIST_ITEM_RE = re.compile(r"^(?:[-*+]|\d+[.)])\s") +INLINE_HEADER_LIST_RE = re.compile( + r"^[ \t]*(?:[-*+]|\d+[.)])[ \t]+\*\*(?P<label>[^*\n]+?)(?::\*\*|\*\*[ \t]*:)" + r"[ \t]*(?P<value>.*)$", + re.M, +) +BOLD_RE = re.compile(r"\*\*[^*\n]+\*\*|__[^_\n]+__") + +# An em dash, an en dash used as punctuation, or a spaced double hyphen +# standing in for one. An en dash between two word characters is a range — +# `3–9`, `5.1–5.6`, `L1–L4b` — and the en dash is the correct character there, +# so only a free-standing one scores. +EM_DASH_RE = re.compile("[—―]|(?<!\\w)–(?!\\w)|(?<=\\s)--(?=\\s)") +CURLY_QUOTE_RE = re.compile("[‘’‚‛“”„‟]") +TRANSITION_RE = re.compile(r"(?<![a-z0-9-])(?:" + "|".join(TRANSITIONS) + r")(?![a-z0-9-])", re.I) +PRESS_CONTROL_RE = re.compile( + r"\bpress\s+(?:(?:the|a|an|this|that)\s+)?(?:[\w-]+\s+){0,2}(?:button|key|control|switch)\b", + re.I, +) +NON_AMERICAN_SPELLING_RE = re.compile( + r"\b(?:colours?|coloured|colouring|behaviours?|catalogues?|licence)\b", re.I +) +COPULA_CANDIDATE_RE = re.compile( + r"\b(?:represents?|marks?)\s+" + r"(?!(?:(?:a|an|the)\s+)?(?:pivotal moment|shift|(?:key\s+)?turning point)\b)" + r"(?:a|an|the|[A-Za-z][\w’'-]*)\b", + re.I, +) +QUALIFIER_PHRASE_RE = re.compile( + r"\b(?:to be fair|it['’]s also possible|might arguably|in some cases it may|" + r"this is an inference)\b", + re.I, +) +# Pictographic ranges only. Arrows, dashes, quotes and ellipses are not emoji, +# and neither are the check and cross marks that carry the data in a support +# matrix: U+2713-U+2718 and the arrow block at U+2B00 are left out on purpose. +EMOJI_RE = re.compile( + "[" + "⌚⌛⏩-⏳⏸-⏺Ⓜ" + "▪▫▶◀◻-◾" + "☀-⛿✀-✒✙-➓⤴⤵⬛⬜⭐⭕" + "〰〽㊗㊙" + "\U0001f004\U0001f0cf\U0001f170-\U0001f19a\U0001f1e6-\U0001f1ff" + "\U0001f201-\U0001f251\U0001f300-\U0001f5ff\U0001f600-\U0001f64f" + "\U0001f680-\U0001f6ff\U0001f7e0-\U0001f7eb\U0001f900-\U0001f9ff" + "\U0001fa70-\U0001faff" + "]" +) + +# Every multi-clause pattern is bounded to one paragraph: (?!\n\s*\n) stops the +# match at a blank line so a tell cannot be assembled out of two paragraphs. +PARA = r"(?:(?!\n\s*\n).)" +NOT_JUST_RE = re.compile( + r"\b(?:it|this|that)(?:'s|’s| is)\s+not\s+(?:just|merely|only|simply)\b" + + PARA + + r"{0,300}?\b(?:it|this|that)(?:'s|’s| is)\b", + re.I | re.S, +) +# The second half of a "not only" is usually "but"; the documented example +# lands it with a bare "it also", which is the same construction. +NOT_ONLY_RE = re.compile( + r"\bnot only\b" + PARA + r"{0,300}?\b(?:but|(?:it|they|this|that|we)\s+also)\b", + re.I | re.S, +) +# The antithesis fragment: a finished clause, a comma, then a negated noun +# phrase of two or three words closing the sentence. One word after `no` is a +# list fact (`, no lockfile`), and a second `no` in the same sentence makes it +# an enumeration rather than an antithesis. +TAILING_NEGATION_RE = re.compile( + r",\s+no\s+(?!longer|more|less|fewer|one|matter|doubt|such)" + r"(?:[\w'’-]+\s+){1,2}[\w'’-]+\s*(?=[.!?]|$)", +) +BARE_NO_RE = re.compile(r"(?<![\w'’-])no(?![\w'’-])", re.I) +FIRST_WORD_RE = re.compile(r"[A-Za-z][A-Za-z'’-]*") + +RHETORICAL_PUNCTUATED_RE = re.compile( + r"^(?:honestly|look|frankly|obviously|real talk)\s*[,?!.]", re.I +) +RHETORICAL_OPENER_RE = re.compile( + r"^(?:here(?:'s|’s) the thing|the thing is|let(?:'s|’s) be honest|truth be told)\b", + re.I, +) +ING_ANALYSIS_RE = re.compile(r",\s*(?:" + "|".join(ING_ANALYSIS) + r")\b", re.I) +# The shape, not one wording: "the future looks bright" and "the future of the +# platform is bright" are the same send-off. +FUTURE_BRIGHT_RE = re.compile( + r"\bthe future\s+(?:of\s+(?:[\w'’-]+\s+){0,4})?(?:is|looks|remains|seems)\s+bright\b", + re.I, +) +COMMITMENT_RE = re.compile( + r"\bunderscor(?:e|es|ed|ing)\s+(?:our|its|their|the)\s+" + r"(?:[\w'’-]+\s+){0,2}(?:commitment|dedication)\b", + re.I, +) +FALSE_RANGE_RE = re.compile( + r"\bfrom\s+((?:[\w'’-]+\s+){0,7}[\w'’-]+)\s+to\s+" + r"((?:[\w'’-]+\s+){0,7}[\w'’-]+)", + re.I, +) +GENERIC_HEADING_RE = re.compile( + r"^\s{0,3}#{1,6}\s*(?:conclusion|final thoughts|closing thoughts|" + r"in closing|wrapping up|the road ahead)\s*$", + re.I | re.M, +) +NOTABILITY_PADDING_RE = re.compile( + r"\b(?:independent coverage|(?:local|regional|national) media outlets?|" + r"leading expert|active social media presence)\b", + re.I, +) +KNOWLEDGE_GAP_RE = re.compile( + r"\b(?:up to my last (?:training )?update|based on available information|" + r"not publicly available|maintains? a low profile|keeps? (?:their|his|her) " + r"personal details private|likely\b|it is believed that)\b", + re.I, +) +UNSUPPORTED_OBJECTION_RE = re.compile( + r"\b(?:this (?:isn't|is not) (?:mainly |really )?about|i(?:'m| am) not " + r"(?:saying|arguing|trying to)|to be clear|don't get me wrong|" + r"this is not to say|some might say)\b", + re.I, +) +FAKE_ALTERNATIVE_RE = re.compile( + r"\b(?:a tempting (?:option|approach) would be|one might be tempted to|" + r"an obvious approach would be|you might think|it would be easy to just|" + r"some would suggest)\b", + re.I, +) +FORMULAIC_SECTION_RE = re.compile( + r"^\s{0,3}#{1,6}\s*(?:challenges(?: and legacy)?|future outlook|legacy)\s*$", + re.I | re.M, +) +PREVIOUS_VERSION_RE = re.compile( + r"\b(?:was added|newly added|previous(?:ly| implementation| approach)|" + r"former implementation|now (?:uses|supports|returns|reads|writes))\b", + re.I, +) +PREDICATE_HYPHEN_RE = re.compile( + rf"\b{BE}\s+[a-z]+-[a-z]+(?:-[a-z]+)*\b", + re.I, +) +MISSING_SUBJECT_RE = re.compile( + r"^(?:no|not)\s+[^.!?]{0,80}\b(?:needed|required|available|configured)\b", + re.I, +) +WATCHED_VOCABULARY_RE = re.compile( + r"(?<![a-z0-9-])(?:actually|key|valuable|quietly|gate|gated|gating)(?![a-z0-9-])", + re.I, +) +CASUAL_SIGNPOSTING_RE = re.compile( + r"\b(?:heads up|quick note|before i forget|one thing that bit me)\b", + re.I, +) + +# `'s` is a possessive far more often than it is a contraction, so the general +# branches cover the endings that only ever contract and the `'s` case is an +# explicit set. Without this, `the skill's own reference` scored a contraction. +CONTRACTION_RE = re.compile( + r"\b\w+['’](?:t|re|ve|ll|d|m)\b" + r"|\b(?:it|that|what|let|here|there|he|she|who)['’]s\b", + re.I, +) +PASSIVE_RE = re.compile(rf"\b{BE}\s+(\w+ed|{PP_IRREG})\b", re.I) +STATIVE_RE = re.compile(STATIVE, re.I) +STATIVE_BY_RE = re.compile(rf"\b{BE}\s+{STATIVE}\s+by\b", re.I) +COMPLEX_TENSE_RE = re.compile( + rf"\b(?:(?:may|might|could|would|should|must|will|shall|can)\s+)?" + rf"(?:have|has|had)\s+(?:been\s+)?(?:\w+ed|{PP_IRREG})\b", + re.I, +) +ING_MAIN_VERB_RE = re.compile(rf"\b{BE}\s+(\w+ing)\b", re.I) +NOMINALIZATION_VERB_RE = re.compile( + r"\b(?:perform(?:s|ed)?|conduct(?:s|ed)?|carry out|carries out|" + r"make use of|makes use of)\b", + re.I, +) +NOMINALIZATION_NOUN_RE = re.compile(r"\b(?:" + "|".join(NOMINALIZATION_NOUNS) + r")s?\s+of\b", re.I) +MAY_RE = re.compile(r"(?<![A-Za-z])may(?![a-z])") +WORD_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9'\-/]*") +TRAIN_WORD_RE = re.compile(r"[A-Za-z][A-Za-z'\-]*") +RULE_OF_THREE_RE = re.compile( + r"\b([\w'’-]+(?:\s+[\w'’-]+){0,2}),\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})" + r",?\s+(?:and|or)\s+([\w'’-]+(?:\s+[\w'’-]+){0,2})\b", + re.I, +) +SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?:])\s+(?=[A-Z0-9\"'\-])") +# Staccato counts whole sentences only. A colon does not end one, and a +# fragment left behind by a stripped code span is not a punchline. +STACCATO_SPLIT_RE = re.compile(r"(?<=[.!?])\s+") +STACCATO_SHAPE_RE = re.compile(r"^[A-Z0-9][^\n]*[.!?]$") +HEADING_PREFIX_RE = re.compile(r"^\s*#{1,6}\s*") +LIST_PREFIX_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+") + +# Categories that own a shape as well as a word list. They join the same +# single pass, so the longest match still wins across all of them. +PHRASE_PATTERNS = { + "generic_conclusion": (FUTURE_BRIGHT_RE, GENERIC_HEADING_RE), + "significance_inflation": (COMMITMENT_RE,), + "ing_analysis": (ING_ANALYSIS_RE,), +} +# A phrase boundary rejects a trailing hyphen as well as a trailing letter, so +# the STE ban on `follow` does not fire on `Follow-up`. +PHRASE_RES = { + name: tuple( + ( + phrase, + re.compile(r"(?<![a-z0-9-])" + re.escape(phrase.replace("’", "'")) + r"(?![a-z0-9-])"), + ) + for phrase in phrases + ) + for name, phrases in PHRASE_LISTS.items() +} + + +# --------------------------------------------------------------------------- +# Text preparation +# --------------------------------------------------------------------------- + + +FRONTMATTER_KEY_RE = re.compile(r"^[A-Za-z_][\w-]*\s*:") + + +def _frontmatter_end(lines: list[str]) -> int: + """Where the leading frontmatter block ends, or 0 when there is none. + + A leading `---` is only frontmatter when what follows it looks like YAML. A + document may legally open on a thematic break, and treating that break as + frontmatter deletes everything down to the next one. + """ + if not lines or lines[0].strip() not in ("---", "+++"): + return 0 + marker = lines[0].strip() + opening = next((line.strip() for line in lines[1:] if line.strip()), "") + if opening == marker or not FRONTMATTER_KEY_RE.match(opening): + return 0 + for index in range(1, len(lines)): + if lines[index].strip() == marker: + return index + 1 + return 0 + + +def _fenced_lines(lines: list[str]) -> set[int]: + """Line numbers inside a complete fence pair. + + An unterminated fence is a typo, not a code block. Blanking everything + after it would silently delete the rest of the document from the score. + """ + inside: set[int] = set() + index = 0 + while index < len(lines): + marker = _fence_marker(lines[index]) + if marker is None: + index += 1 + continue + for close in range(index + 1, len(lines)): + candidate = re.fullmatch(r" {0,3}(`{3,}|~{3,})[ \t]*", lines[close]) + if ( + candidate + and candidate.group(1)[0] == marker[0] + and len(candidate.group(1)) >= len(marker) + ): + inside.update(range(index, close + 1)) + index = close + 1 + break + else: + index += 1 + return inside + + +def _fence_marker(line: str) -> str | None: + """Return a valid CommonMark fence marker from an opening line.""" + opened = FENCE_RE.match(line) + if not opened: + return None + marker = opened.group(1) + info = line[opened.end() :] + if marker[0] == "`" and "`" in info: + return None + return marker + + +def _invalid_backtick_fence(line: str) -> bool: + return bool(BACKTICK_FENCE_CANDIDATE_RE.match(line) and _fence_marker(line) is None) + + +def _strip_inline_code(text: str) -> str: + """Blank code spans whose closing backtick run matches the opener.""" + out: list[str] = [] + cursor = 0 + while cursor < len(text): + opened = None + for candidate in re.finditer(r"`+", text[cursor:]): + start = cursor + candidate.start() + slash_count = 0 + before = start - 1 + while before >= 0 and text[before] == "\\": + slash_count += 1 + before -= 1 + if slash_count % 2 == 0: + opened = candidate + break + if opened is None: + out.append(text[cursor:]) + break + start = cursor + opened.start() + marker = opened.group(0) + out.append(text[cursor:start]) + # Backslashes do not escape a closing code-span delimiter. Only the + # opening delimiter follows Markdown's odd/even backslash rule. + close_re = re.compile(r"(?<!`)" + re.escape(marker) + r"(?!`)") + closed = close_re.search(text, start + len(marker)) + if closed is None: + out.append(text[start : start + len(marker)]) + cursor = start + len(marker) + continue + out.append(" ") + cursor = closed.end() + return "".join(out) + + +def _table_prose(line: str) -> str: + if TABLE_DELIMITER_RE.match(line): + return "" + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + return ". ".join(cell for cell in cells if cell) + + +def _has_unescaped_pipe(line: str) -> bool: + backslashes = 0 + for character in line: + if character == "|" and backslashes % 2 == 0: + return True + backslashes = backslashes + 1 if character == "\\" else 0 + return False + + +def _gfm_table_lines(lines: list[str]) -> set[int]: + """Find pipe-table rows from their required delimiter row in one pass.""" + found: set[int] = set() + index = 1 + while index < len(lines): + if not ( + TABLE_DELIMITER_RE.fullmatch(lines[index]) and _has_unescaped_pipe(lines[index - 1]) + ): + index += 1 + continue + found.update((index - 1, index)) + index += 1 + while index < len(lines) and lines[index].strip() and _has_unescaped_pipe(lines[index]): + found.add(index) + index += 1 + return found + + +def strip_quoted(text: str, quote_safe: bool = False) -> str: + """Remove every zone that is not the author's own prose. + + Fenced code, inline code, YAML frontmatter, identifiers and link targets are + not prose. Blockquotes, callouts and table cells can be first-party prose, + so the default mode keeps their words. Quote-safe mode blanks blockquotes + because it is the caller's explicit signal that quoted material is not the + author's voice. + """ + lines = text.split("\n") + frontmatter_end = _frontmatter_end(lines) + fenced = _fenced_lines(lines) + gfm_table_lines = _gfm_table_lines(lines) + kept: list[str] = [] + for index, line in enumerate(lines): + if index < frontmatter_end or index in fenced: + kept.append("") + continue + if LINK_DEF_RE.match(line): + kept.append("") + continue + if index in gfm_table_lines or TABLE_ROW_RE.match(line): + # A row is one prose unit. The extra newline prevents adjacent + # rows from becoming one artificial typography cluster. + kept.append(_table_prose(line) + "\n") + continue + if BLOCKQUOTE_RE.match(line): + if quote_safe: + kept.append("") + continue + unquoted = re.sub(r"^\s{0,3}>\s?", "", line) + kept.append("" if re.fullmatch(r"\[![A-Za-z]+\]", unquoted.strip()) else unquoted) + continue + if _invalid_backtick_fence(line): + kept.append(line.replace("`", " ")) + continue + kept.append(line) + body = "\n".join(kept) + body = _strip_inline_code(body) + if quote_safe: + body = INLINE_QUOTATION_RE.sub(" ", body) + body = IDENTIFIER_RE.sub(" ", body) + body = IMAGE_RE.sub(" ", body) + body = LINK_RE.sub(r"\1", body) + body = REF_LINK_RE.sub(r"\1", body) + return AUTOLINK_RE.sub(" ", body) + + +def _starts_a_unit(line: str) -> bool: + """A line that opens its own unit and never continues the line above it.""" + return bool( + HEADING_PREFIX_RE.match(line) + or LIST_ITEM_RE.match(line) + or TABLE_ROW_RE.match(line) + or BLOCKQUOTE_RE.match(line) + or _fence_marker(line) is not None + ) + + +def logical_lines(text: str) -> list[str]: + """Rejoin hard-wrapped prose so a sentence is one string again. + + Markdown wrapped at eighty columns splits most sentences across lines. Read + line by line, a wrapped paragraph reports as many short sentences: the long + ones vanish and a two-sentence paragraph counts as seven. Continuation + lines therefore join the line above, and a blank line, a heading, a list + item, a table row or a fence starts a new unit. A list item is a unit of + its own, so it never merges with the paragraph before it. + """ + out: list[str] = [] + buffer: list[str] = [] + for raw in text.split("\n"): + stripped = raw.strip() + if not stripped: + if buffer: + out.append(" ".join(buffer)) + buffer = [] + continue + if _starts_a_unit(stripped): + if buffer: + out.append(" ".join(buffer)) + # A stray fence marker left by an unterminated block is punctuation, + # not the opening of a sentence. + buffer = [] if _fence_marker(stripped) is not None else [stripped] + continue + buffer.append(stripped) + if buffer: + out.append(" ".join(buffer)) + return out + + +def sentences(text: str) -> list[str]: + out: list[str] = [] + for line in logical_lines(text): + stripped = HEADING_PREFIX_RE.sub("", line) + stripped = LIST_PREFIX_RE.sub("", stripped) + if not WORD_RE.search(stripped): + continue + for part in SENTENCE_SPLIT_RE.split(stripped): + part = part.strip() + if part: + out.append(part) + return out + + +def paragraphs(text: str) -> list[str]: + return [block for block in re.split(r"\n\s*\n", text) if block.strip()] + + +def prose_lines(block: str) -> list[str]: + """Lines that are running prose: no headings, list items or table leftovers.""" + out: list[str] = [] + for line in block.split("\n"): + stripped = line.strip() + if not stripped or stripped.startswith(("#", "|", ">")): + continue + if LIST_ITEM_RE.match(stripped): + continue + out.append(stripped) + return out + + +def word_count(sentence: str) -> int: + return len(WORD_RE.findall(sentence)) + + +def is_instruction(sentence: str) -> bool: + """Return whether a sentence has the deterministic imperative shape.""" + words = WORD_RE.findall(sentence) + lowered = [word.lower() for word in words] + if not lowered: + return False + + def command_at(sequence: list[str], index: int) -> bool: + while index < len(sequence) and ( + sequence[index].endswith("ly") or sequence[index] in {"always", "never"} + ): + index += 1 + return index < len(sequence) and sequence[index] in INSTRUCTION_VERBS + + if command_at(lowered, 0): + return True + # A declarative requirement remains an instruction when an adverb or a + # passive construction separates `must` from the functional verb. + if "must" in lowered: + return True + if lowered[0] not in CONDITION_OPENERS: + return False + if "," in sentence: + command = [word.lower() for word in WORD_RE.findall(sentence.split(",", 1)[1])] + return command_at(command, 0) + condition_predicates = { + "change", + "changes", + "complete", + "completed", + "completes", + "differ", + "differs", + "exist", + "exists", + "fail", + "failed", + "fails", + "finish", + "finished", + "finishes", + "match", + "matches", + "occur", + "occurs", + "pass", + "passed", + "passes", + "return", + "returns", + "start", + "starts", + "stop", + "stops", + "succeed", + "succeeded", + "succeeds", + } + condition_states = { + "available", + "complete", + "empty", + "invalid", + "missing", + "present", + "ready", + "successful", + "unavailable", + "valid", + } + for index, word in enumerate(lowered[1:], start=1): + if word in condition_predicates and command_at(lowered, index + 1): + return True + if word in {"do", "does", "did", "has", "have", "had"}: + predicate = index + 1 + if predicate < len(lowered) and lowered[predicate] == "not": + predicate += 1 + if ( + predicate < len(lowered) + and lowered[predicate] in condition_predicates + and command_at(lowered, predicate + 1) + ): + return True + if word in {"am", "is", "are", "was", "were", "be", "been"}: + state = index + 1 + while state < len(lowered) and ( + lowered[state] in {"not", "no", "longer"} or lowered[state].endswith("ly") + ): + state += 1 + if ( + state < len(lowered) + and lowered[state] in condition_states + and command_at(lowered, state + 1) + ): + return True + return False + + +def _normalize(text: str) -> str: + return text.replace("’", "'").replace("‘", "'") + + +def _folded(text: str) -> str: + """Lowercase without moving any offset, so a span means the same in both.""" + lowered = _normalize(text).lower() + if len(lowered) == len(text): + return lowered + return "".join( + character.lower() if len(character.lower()) == 1 else character + for character in _normalize(text) + ) + + +def _provided_is_a_verb(text: str, start: int, end: int) -> bool: + """`Provided the token is valid` is a conjunction, not a verbose verb.""" + before = text[:start].rstrip(" \t") + if not before or before[-1] in ".!?;:\n": + return False + return not text[end:].lstrip(" \t").lower().startswith("that ") + + +PHRASE_FILTERS = {"provided": _provided_is_a_verb} + + +def phrase_hits(text: str) -> dict[str, list[str]]: + """Score every phrase list and phrase shape in one pass, longest match first. + + A span belongs to one category. Matching each list on its own let + `utilization of` score vocabulary and nominalization, and + `marks a pivotal moment` score inflation and vocabulary, so the same words + counted twice. Sorting the candidates by position and keeping the longest + non-overlapping match gives every span exactly one owner. + """ + lowered = _folded(text) + candidates: list[tuple[int, int, str, str]] = [] + for name, compiled in PHRASE_RES.items(): + for phrase, pattern in compiled: + allowed = PHRASE_FILTERS.get(phrase) + for match in pattern.finditer(lowered): + if allowed and not allowed(lowered, match.start(), match.end()): + continue + candidates.append((match.start(), match.end(), name, phrase)) + for name, patterns in PHRASE_PATTERNS.items(): + for pattern in patterns: + for match in pattern.finditer(text): + sample = " ".join(match.group(0).split()) + candidates.append((match.start(), match.end(), name, sample)) + + hits: dict[str, list[str]] = {name: [] for name in PHRASE_RES} + for name in PHRASE_PATTERNS: + hits.setdefault(name, []) + last_end = -1 + for start, end, name, sample in sorted(candidates, key=lambda item: (item[0], -item[1])): + if start < last_end: + continue + last_end = end + hits[name].append(sample) + return hits + + +def _hits(pattern: "re.Pattern[str]", text: str) -> tuple[int, list[str]]: + found = [match.group(0).strip() for match in pattern.finditer(text)] + return len(found), found + + +# --------------------------------------------------------------------------- +# Detectors +# --------------------------------------------------------------------------- + + +def title_case_headings(text: str) -> tuple[int, list[str]]: + """Count headings written in Title Case, on the one piece of hard evidence. + + A heading of capitalized words is either Title Case or a name, and no list + of proper nouns can tell them apart: `Amazon Elastic Kubernetes Service` and + `Red Hat Advanced Cluster Security` are products, and the product namespace + has no end. Title Case has one habit a name never has, which is + capitalizing the function words, so a capitalized `And`, `On` or `Of` is + the evidence and nothing else is. This trades recall for precision on + purpose: heading case is a house style, not an AI tell, and a markdown + linter is the better place to enforce it. + """ + hits: list[str] = [] + for match in HEADING_RE.finditer(text): + heading = re.sub(r"[*_`]", "", match.group(1)).strip() + words = TRAIN_WORD_RE.findall(heading) + if len(words) < 3: + continue + rest = words[1:] + if any(word[0].islower() and word.lower() not in MINOR_WORDS for word in rest): + continue + if not any(word[0].isupper() and word.lower() in MINOR_WORDS for word in rest): + continue + loud = [ + word + for word in rest + if word[0].isupper() and not word.isupper() and word.lower() not in MINOR_WORDS + ] + if len(loud) >= 2: + hits.append(heading) + return len(hits), hits + + +def inline_header_lists(text: str) -> tuple[int, list[str]]: + """Count bolded list labels followed by a sentence that restates the label. + + A bolded label carrying a datum is a definition list and is good structure: + ``- **Milestone:** 2026-03-01``. A bolded label carrying a sentence that says + the label again is the padding tell: ``- **Performance:** Performance has + been enhanced.`` The difference is the value, so the value decides. + """ + hits: list[str] = [] + for match in INLINE_HEADER_LIST_RE.finditer(text): + label = re.sub(r"[*_`]", "", match.group("label")).strip() + value = re.sub(r"[*_`]", "", match.group("value")).strip() + words = TRAIN_WORD_RE.findall(value) + if len(words) < 4 or not value.endswith((".", "!", "?")): + continue + lowered = value.lower() + echo = [word for word in TRAIN_WORD_RE.findall(label) if len(word) >= 4] + if echo and any(word.lower() in lowered for word in echo): + hits.append(f"{label}: {value}"[:80]) + return len(hits), hits + + +def tailing_negations(text: str) -> list[str]: + """The negation fragment tacked onto a finished clause. + + `The overlay reads the version from `package.json`, no manual bumps.` is + the tell. A conditional (`If validation fails, no installation operation + runs.`), a one-word list fact (`, no lockfile`), a run of them (`no + imports, no host layout probing`) and any list item are not: they state + what is there, or absent, rather than defining a thing by what it is not. + """ + hits: list[str] = [] + for line in logical_lines(text): + if _starts_a_unit(line): + continue + for sentence in SENTENCE_SPLIT_RE.split(line): + opener = FIRST_WORD_RE.match(sentence.strip()) + if opener and opener.group(0).lower() in SUBORDINATORS: + continue + if len(BARE_NO_RE.findall(sentence)) != 1: + continue + match = TAILING_NEGATION_RE.search(sentence) + if match: + hits.append(" ".join(match.group(0).split())) + return hits + + +def negative_parallelisms(text: str) -> tuple[int, list[str]]: + hits: list[str] = [] + for pattern in (NOT_JUST_RE, NOT_ONLY_RE): + hits.extend(" ".join(match.group(0).split()) for match in pattern.finditer(text)) + hits.extend(tailing_negations(text)) + return len(hits), hits + + +def em_dashes(text: str) -> tuple[int, list[str]]: + hits = [ + match.group(0) + for match in EM_DASH_RE.finditer(text) + if match.group(0) != "--" or _double_hyphen_is_a_dash(text, match.end()) + ] + return len(hits), hits + + +def _double_hyphen_is_a_dash(text: str, end: int) -> bool: + """A spaced `--` is a dash unless it is the POSIX end-of-options marker. + + `npm test -- --watch` and `git log -- src/` both put a flag or a path after + it. A dash standing in for punctuation is followed by a word. + """ + rest = text[end:].split() + if not rest: + return False + word = rest[0] + return not word.startswith("-") and not any(character in word for character in "/\\=") + + +def rhetorical_openers(found: list[str]) -> tuple[int, list[str]]: + hits = [ + sentence + for sentence in found + if RHETORICAL_PUNCTUATED_RE.match(sentence) or RHETORICAL_OPENER_RE.match(sentence) + ] + return len(hits), hits + + +def false_ranges(text: str) -> tuple[int, list[str]]: + hits: list[str] = [] + for match in FALSE_RANGE_RE.finditer(text): + left, right = match.group(1).strip(), match.group(2).strip() + if any(character.isdigit() for character in left + right): + continue + if len(left.split()) < 2 or len(right.split()) < 2: + continue + sides = {word.lower() for word in (left + " " + right).split()} + if sides & set(RANGE_STOP_WORDS): + continue + # Only the few words before the match decide, so read a fixed window + # instead of re-splitting the whole document per candidate. On a large + # file the old prefix split turned this check quadratic. + window = text[max(0, match.start() - CLAUSE_WINDOW) : match.start()] + clause = re.split(r"[.!?;:]", window)[-1].split() + lead = [re.sub(r"[^A-Za-z]", "", word).lower() for word in clause[-RANGE_LOOKBACK:]] + if any(word in RANGE_VERBS for word in lead): + continue + hits.append(f"from {left} to {right}") + return len(hits), hits + + +def passive_voices(text: str) -> tuple[int, list[str]]: + """Passive voice, minus the spans another category already owns. + + `has been deployed` is one verb phrase. It scored `complex_tense` for the + perfect and `passive_voice` for the participle, so a single edit had to + remove two violations. The compound tense keeps it. + """ + claimed = [(match.start(), match.end()) for match in COMPLEX_TENSE_RE.finditer(text)] + + def unclaimed(matches: list["re.Match[str]"]) -> list["re.Match[str]"]: + kept: list["re.Match[str]"] = [] + claimed_index = 0 + for match in matches: + while claimed_index < len(claimed) and claimed[claimed_index][1] <= match.start(): + claimed_index += 1 + overlaps = ( + claimed_index < len(claimed) + and claimed[claimed_index][0] < match.end() + and claimed[claimed_index][1] > match.start() + ) + if not overlaps: + kept.append(match) + return kept + + candidates = [ + match + for match in PASSIVE_RE.finditer(text) + if match.group(1).lower() not in NOT_PARTICIPLE and not STATIVE_RE.fullmatch(match.group(1)) + ] + hits = [match.group(0) for match in unclaimed(candidates)] + hits.extend(match.group(0) for match in unclaimed(list(STATIVE_BY_RE.finditer(text)))) + return len(hits), hits + + +def ing_main_verbs(text: str) -> tuple[int, list[str]]: + hits = [ + match.group(0) + for match in ING_MAIN_VERB_RE.finditer(text) + if match.group(1).lower() not in ING_STATE + ] + return len(hits), hits + + +def curly_quotes(text: str) -> tuple[int, list[str]]: + """Curly quotes, minus the apostrophes a contraction already scored. + + One `’` in `doesn’t` is one defect, not a curly quote plus a contraction. + A possessive apostrophe is no longer a contraction, so it still scores here. + """ + claimed: set[int] = set() + for match in CONTRACTION_RE.finditer(text): + claimed.update(range(match.start(), match.end())) + hits = [ + match.group(0) for match in CURLY_QUOTE_RE.finditer(text) if match.start() not in claimed + ] + return len(hits), hits + + +def clustered_tell( + current: tuple[int, list[str]], + sample: tuple[int, list[str]] | None, + target_words: int, + sample_words: int, + singleton_allowance: int = 1, +) -> tuple[int, list[str]]: + """Count style evidence only above a singleton or a supplied voice rate.""" + count, hits = current + allowance = singleton_allowance + if sample is not None: + allowance = max( + singleton_allowance, + math.ceil(sample[0] * target_words / max(sample_words, 1)), + ) + excess = max(0, count - allowance) + return excess, hits[allowance : allowance + excess] + + +def local_clustered_tell( + body: str, + detector: Callable[[str], tuple[int, list[str]]], + voice_body: str | None, + singleton_allowance: int = 1, +) -> tuple[tuple[int, list[str]], tuple[int, list[str]]]: + """Separate local clusters from isolated style markers. + + A repeated tell is evidence only within one paragraph. Two isolated uses in + distant paragraphs remain two markers instead of becoming one violation. + A supplied voice sample governs the allowed rate in each target paragraph. + """ + sample = detector(voice_body) if voice_body is not None else None + sample_words = sum(word_count(sentence) for sentence in sentences(voice_body or "")) or 1 + target_words = sum(word_count(sentence) for sentence in sentences(body)) or 1 + violation_hits: list[str] = [] + isolated_hits: list[str] = [] + sample_allowance_used = 0 + for block in logical_lines(body): + current = detector(block) + block_words = sum(word_count(sentence) for sentence in sentences(block)) or 1 + violation = clustered_tell( + current, + sample, + block_words, + sample_words, + singleton_allowance, + ) + violation_hits.extend(violation[1]) + if violation[0]: + sample_allowance_used += current[0] - violation[0] + else: + isolated_hits.extend(current[1]) + marker_allowance = 0 + if sample is not None and sample[0] > 0: + total_allowance = math.ceil(sample[0] * target_words / sample_words) + marker_allowance = max(0, total_allowance - sample_allowance_used) + marker_hits = isolated_hits[marker_allowance:] + return (len(violation_hits), violation_hits), (len(marker_hits), marker_hits) + + +def staccato_runs(blocks: list[str]) -> tuple[int, list[str]]: + hits: list[str] = [] + for block in blocks: + text = " ".join(prose_lines(block)) + run: list[str] = [] + for sentence in STACCATO_SPLIT_RE.split(text) + [""]: + sentence = sentence.strip() + short = 2 <= word_count(sentence) <= STACCATO_MAX_WORDS + if sentence and short and STACCATO_SHAPE_RE.match(sentence): + run.append(sentence) + continue + if len(run) >= STACCATO_RUN: + hits.append(" ".join(run)) + run = [] + return len(hits), hits + + +def boldface_clusters( + blocks: list[str], +) -> tuple[tuple[int, list[str]], tuple[int, list[str]]]: + violation_hits: list[str] = [] + marker_hits: list[str] = [] + for block in blocks: + if HEADING_PREFIX_RE.match(block): + continue + spans = BOLD_RE.findall(LIST_PREFIX_RE.sub("", block)) + if len(spans) > BOLD_PER_PARAGRAPH: + violation_hits.extend(spans[BOLD_PER_PARAGRAPH:]) + elif spans: + marker_hits.extend(spans) + return (len(violation_hits), violation_hits), (len(marker_hits), marker_hits) + + +def long_paragraphs(blocks: list[str]) -> tuple[int, list[str]]: + hits: list[str] = [] + for block in blocks: + found = sentences("\n".join(prose_lines(block))) + if len(found) > LONG_PARAGRAPH_SENTENCES: + hits.append(found[0]) + return len(hits), hits + + +def noun_trains(text: str) -> tuple[int, list[str]]: + hits: list[str] = [] + for sentence in sentences(text): + run: list[str] = [] + for word in TRAIN_WORD_RE.findall(sentence)[1:] + [""]: + if word and word.lower() not in FUNC_WORDS and not word[0].isupper(): + run.append(word) + continue + if len(run) >= NOUN_TRAIN_MIN: + hits.append(" ".join(run)) + run = [] + return len(hits), hits + + +def rule_of_three(text: str) -> tuple[int, list[str]]: + hits = [" ".join(match.group(0).split()) for match in RULE_OF_THREE_RE.finditer(text)] + return len(hits), hits + + +def repeated_openings(found: list[str]) -> tuple[int, list[str]]: + hits: list[str] = [] + run: list[str] = [] + opening = "" + for sentence in found + [""]: + match = FIRST_WORD_RE.match(sentence.strip()) + current = match.group(0).lower() if match else "" + if current and current == opening: + run.append(sentence) + continue + if len(run) >= 3: + hits.append(" ".join(run)) + run = [sentence] if current else [] + opening = current + return len(hits), hits + + +def heading_restatements(text: str) -> tuple[int, list[str]]: + """Mark close lexical repeats between a heading and its first sentence.""" + hits: list[str] = [] + lines = text.splitlines() + for index, line in enumerate(lines): + heading = HEADING_RE.match(line) + if not heading: + continue + heading_words = {word.lower() for word in WORD_RE.findall(heading.group(1))} + for candidate_index in range(index + 1, len(lines)): + candidate = lines[candidate_index].strip() + if not candidate: + continue + if _starts_a_unit(candidate): + break + sentence_words = {word.lower() for word in WORD_RE.findall(candidate)} + if heading_words and heading_words <= sentence_words and len(sentence_words) <= 8: + hits.append(candidate) + break + return len(hits), hits + + +def marker_scores( + body: str, + phrase_body: str, + found: list[str], + register: str, + singleton_markers: dict[str, tuple[int, list[str]]], +) -> dict[str, tuple[int, list[str]]]: + phrase_markers = { + "notability_padding": _hits(NOTABILITY_PADDING_RE, phrase_body), + "knowledge_gap": _hits(KNOWLEDGE_GAP_RE, phrase_body), + "unsupported_objection": _hits(UNSUPPORTED_OBJECTION_RE, phrase_body), + "fake_alternative": _hits(FAKE_ALTERNATIVE_RE, phrase_body), + "watched_vocabulary": _hits(WATCHED_VOCABULARY_RE, phrase_body), + "casual_signposting": _hits(CASUAL_SIGNPOSTING_RE, phrase_body), + } + missing = [sentence for sentence in found if MISSING_SUBJECT_RE.match(sentence)] + misplaced_conditions = [] + for sentence in found: + words = WORD_RE.findall(sentence) + if ( + words + and words[0].lower() in INSTRUCTION_VERBS - {"check"} + and re.search(r"\b(?:if|when|unless|until)\b", sentence, re.I) + ): + misplaced_conditions.append(sentence) + + return { + **singleton_markers, + "american_spelling": ( + (0, []) if register == "voiced" else _hits(NON_AMERICAN_SPELLING_RE, phrase_body) + ), + "condition_before_command": ( + (0, []) if register == "voiced" else (len(misplaced_conditions), misplaced_conditions) + ), + "copula_candidate": _hits(COPULA_CANDIDATE_RE, phrase_body), + "qualifier_phrase": _hits(QUALIFIER_PHRASE_RE, phrase_body), + "noun_train": noun_trains(body), + "rule_of_three": rule_of_three(body), + **phrase_markers, + "formulaic_section": _hits(FORMULAIC_SECTION_RE, body), + "previous_version_frame": _hits(PREVIOUS_VERSION_RE, body), + "repeated_opening": repeated_openings(found), + "predicate_hyphenation": _hits(PREDICATE_HYPHEN_RE, body), + "heading_restatement": heading_restatements(body), + "missing_subject": (len(missing), missing), + } + + +def _phrase(phrases: dict[str, list[str]], name: str) -> tuple[int, list[str]]: + hits = phrases[name] + return len(hits), hits + + +def _mechanical_scores( + body: str, + found: list[str], + phrases: dict[str, list[str]], + voice_body: str | None, +) -> tuple[ + dict[str, tuple[int, list[str]]], + dict[str, tuple[int, list[str]]], +]: + em_dash = local_clustered_tell(body, em_dashes, voice_body) + transition = local_clustered_tell( + body, + lambda text: _hits(TRANSITION_RE, text), + voice_body, + ) + curly_quote = local_clustered_tell(body, curly_quotes, voice_body, 2) + bold = boldface_clusters(logical_lines(body)) + scores = { + "em_dash": em_dash[0], + "transition_stack": transition[0], + "chatbot_residue": _phrase(phrases, "chatbot_residue"), + "copula_avoidance": _phrase(phrases, "copula_avoidance"), + "negative_parallelism": negative_parallelisms(body), + "emoji": _hits(EMOJI_RE, body), + "curly_quote": curly_quote[0], + "title_case_heading": title_case_headings(body), + "inline_header_list": inline_header_lists(body), + "boldface_overuse": bold[0], + "ai_vocabulary": _phrase(phrases, "ai_vocabulary"), + "promotional": _phrase(phrases, "promotional"), + "authority_trope": _phrase(phrases, "authority_trope"), + "aphorism": _phrase(phrases, "aphorism"), + "signposting": _phrase(phrases, "signposting"), + "rhetorical_opener": rhetorical_openers(found), + "ing_analysis": _phrase(phrases, "ing_analysis"), + "false_range": false_ranges(body), + "vague_attribution": _phrase(phrases, "vague_attribution"), + "modal_hedge": _phrase(phrases, "modal_hedge"), + "filler_phrase": _phrase(phrases, "filler_phrase"), + "generic_conclusion": _phrase(phrases, "generic_conclusion"), + "significance_inflation": _phrase(phrases, "significance_inflation"), + } + singleton_markers = { + "singleton_em_dash": em_dash[1], + "singleton_curly_quote": curly_quote[1], + "singleton_transition": transition[1], + "singleton_bold": bold[1], + } + return scores, singleton_markers + + +def _compression_scores( + body: str, found: list[str], blocks: list[str], phrases: dict[str, list[str]] +) -> dict[str, tuple[int, list[str]]]: + longs = [ + sentence + for sentence in found + if word_count(sentence) + > (INSTRUCTION_MAX_WORDS if is_instruction(sentence) else DESCRIPTIVE_MAX_WORDS) + ] + strict_count, strict_hits = _phrase(phrases, "strict_banned_word") + press_count, press_hits = _hits(PRESS_CONTROL_RE, body) + strict_count += press_count + strict_hits.extend(press_hits) + may_count, may_hits = _hits(MAY_RE, body) + nominal_count, nominal_hits = _hits(NOMINALIZATION_VERB_RE, body) + noun_count, noun_hits = _hits(NOMINALIZATION_NOUN_RE, body) + return { + "long_sentence": (len(longs), longs), + "semicolon": (body.count(";"), [";"] * body.count(";")), + "contraction": _hits(CONTRACTION_RE, body), + "passive_voice": passive_voices(body), + "complex_tense": _hits(COMPLEX_TENSE_RE, body), + "ing_main_verb": ing_main_verbs(body), + "nominalization": (nominal_count + noun_count, nominal_hits + noun_hits), + "phrasal_verb": _phrase(phrases, "phrasal_verb"), + "verbose_word": _phrase(phrases, "verbose_word"), + "long_paragraph": long_paragraphs(blocks), + "strict_banned_word": (strict_count + may_count, strict_hits + may_hits), + } + + +def _voice_scores(blocks: list[str]) -> dict[str, tuple[int, list[str]]]: + return { + "staccato_drama": staccato_runs(blocks), + } + + +def _sample(hits: list[str]) -> list[str]: + cleaned = [" ".join(hit.split()) for hit in hits if hit.strip()] + return list(dict.fromkeys(cleaned))[:SAMPLE_LIMIT] + + +def lint( + text: str, + register: str = DEFAULT_REGISTER, + quote_safe: bool = False, + voice_sample: str | None = None, +) -> dict[str, Any]: + if register not in REGISTERS: + raise ValueError(f"unknown register: {register!r}") + body = strip_quoted(text, quote_safe=quote_safe) + phrase_body = body + found = sentences(body) + blocks = paragraphs(body) + words = sum(word_count(sentence) for sentence in found) or 1 + + phrases = phrase_hits(phrase_body) + scored: dict[str, tuple[int, list[str]]] = {} + voice_body = ( + strip_quoted(voice_sample, quote_safe=quote_safe) + if voice_sample is not None and register == "voiced" + else None + ) + mechanical_scores, singleton_markers = _mechanical_scores(body, found, phrases, voice_body) + scored.update(mechanical_scores) + scored.update(_compression_scores(body, found, blocks, phrases)) + scored.update(_voice_scores(blocks)) + + violations: dict[str, int] = {} + by_layer = {"mechanical": 0, "compression": 0, "voice": 0} + samples: dict[str, list[str]] = {} + for layer in ("mechanical", "compression", "voice"): + if layer not in REGISTER_LAYERS[register]: + continue + for name in LAYERS[layer]: + # audit reports the strict word set too. It cannot know whether the + # document is a procedure, and an audit that hides the one word set + # procedures exist to enforce is worse than a review that overreports: + # nothing here is applied, and the reader filters by document type. + if name in STRICT_ONLY and register not in ("strict", "audit"): + continue + count, hits = scored[name] + violations[name] = count + by_layer[layer] += count + if hits: + samples[name] = _sample(hits) + + marker_results = marker_scores(body, phrase_body, found, register, singleton_markers) + markers: dict[str, int] = {} + for name in MARKERS: + count, hits = marker_results[name] + markers[name] = count + if hits: + samples[name] = _sample(hits) + + total = sum(violations.values()) + per100 = round(total * 100.0 / words, 2) + return { + "score_version": SCORE_VERSION, + "register": register, + "quote_safe": quote_safe, + "voice_sample": voice_sample is not None and register == "voiced", + "voice_sample_identity": ( + hashlib.sha256(voice_sample.encode("utf-8")).hexdigest() + if voice_sample is not None and register == "voiced" + else None + ), + "words": words, + "sentences": len(found), + "violations": violations, + "by_layer": by_layer, + "total": total, + "total_per100w": per100, + "longest_sentence_words": max((word_count(s) for s in found), default=0), + "markers": markers, + "manual_checks": list(MANUAL_CHECKS), + "samples": samples, + "delta": None, + } + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _finite_non_negative(value: str) -> float: + parsed = float(value) + if not math.isfinite(parsed) or parsed < 0: + raise argparse.ArgumentTypeError("must be a finite non-negative number") + return parsed + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="lint.py", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=( + "Score prose for AI tells and Simplified Technical English discipline.\n" + "Registers: strict for procedures and error messages, flavored for " + "docs and PR bodies, voiced for bylined prose, and audit to report " + "without rewriting. Thresholds exist only when --fail-over is set." + ), + epilog=( + "usage example: python3 lint.py --json --register strict RUNBOOK.md\n" + "exit codes: 0 clean, 1 over --fail-over, 2 a path or baseline could " + "not be read (every other path is still scored and reported)." + ), + ) + parser.add_argument("paths", nargs="*", help="Files to lint. Reads stdin when omitted.") + parser.add_argument( + "--register", + choices=REGISTERS, + default=None, + help=f"Which layers to score. Default {DEFAULT_REGISTER}.", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Deprecated alias for --register strict.", + ) + parser.add_argument( + "--quote-safe", + action="store_true", + help="Suppress word-list categories so a glossary does not score the words it names.", + ) + parser.add_argument( + "--voice-sample", + metavar="FILE", + help="Match typography and transition rates from this sample in the voiced register.", + ) + parser.add_argument("--json", action="store_true", help="Emit the full JSON report.") + parser.add_argument( + "--fail-over", + type=_finite_non_negative, + metavar="N", + help="Exit 1 when the worst total_per100w is greater than N.", + ) + parser.add_argument( + "--baseline", + metavar="FILE", + help="A JSON report from an earlier run. Adds a delta object to the output.", + ) + return parser + + +def _resolve_register(args: argparse.Namespace) -> str: + if args.register: + return args.register + if args.strict: + return "strict" + return DEFAULT_REGISTER + + +class BaselineError(ValueError): + """A readable baseline that cannot be compared to this run.""" + + def __init__(self, kind: str, detail: str): + super().__init__(detail) + self.kind = kind + + +def _file_identity(name: str) -> str: + return os.path.normcase(str(Path(name).resolve(strict=False))) + + +def _baseline_scores( + path: str, register: str, quote_safe: bool, voice_sample_identity: str | None +) -> tuple[dict[str, float], float | None]: + """Load compatible baseline scores keyed by normalized path identity.""" + data = json.loads(Path(path).read_text(encoding="utf-8")) + reports = data if isinstance(data, list) else [data] + if not reports or any(not isinstance(report, dict) for report in reports): + raise BaselineError("incompatible", "expected one report or a list of reports") + by_file: dict[str, float] = {} + anonymous_scores: list[float] = [] + for report in reports: + required = { + "score_version", + "register", + "quote_safe", + "voice_sample_identity", + "total_per100w", + } + missing = sorted(required - report.keys()) + if missing: + raise BaselineError("incompatible", f"missing {', '.join(missing)}") + if report["score_version"] != SCORE_VERSION: + raise BaselineError( + "incompatible", + f"score_version {report['score_version']!r} != {SCORE_VERSION}", + ) + if report["register"] != register: + raise BaselineError("incompatible", f"register {report['register']!r} != {register!r}") + if report["quote_safe"] is not quote_safe: + raise BaselineError( + "incompatible", + f"quote_safe {report['quote_safe']!r} != {quote_safe!r}", + ) + if report["voice_sample_identity"] != voice_sample_identity: + raise BaselineError("incompatible", "voice sample content or policy differs") + try: + score = float(report["total_per100w"]) + except (TypeError, ValueError) as error: + raise BaselineError("incompatible", "total_per100w is not numeric") from error + if not math.isfinite(score) or score < 0: + raise BaselineError( + "incompatible", + "total_per100w must be finite and non-negative", + ) + name = report.get("file") + if name: + identity = report.get("file_identity") + if not isinstance(identity, str) or not Path(identity).is_absolute(): + raise BaselineError("incompatible", "missing canonical file_identity") + identity = os.path.normcase(os.path.normpath(identity)) + if identity in by_file: + raise BaselineError("ambiguous", f"duplicate file identity {identity!r}") + by_file[identity] = score + else: + anonymous_scores.append(score) + if len(anonymous_scores) > 1 or (anonymous_scores and by_file): + raise BaselineError("ambiguous", "anonymous report cannot be mixed or repeated") + return by_file, anonymous_scores[0] if anonymous_scores else None + + +def _attach_deltas( + reports: list[dict[str, Any]], + by_file: dict[str, float], + anonymous: float | None, +) -> None: + named = [report for report in reports if report.get("file") is not None] + if named: + identities = [report.get("file_identity") for report in named] + if any(not isinstance(identity, str) for identity in identities): + raise BaselineError("incompatible", "current report lacks file_identity") + identities = [os.path.normcase(os.path.normpath(identity)) for identity in identities] + if len(identities) != len(set(identities)): + raise BaselineError("ambiguous", "current run repeats a file identity") + if anonymous is not None or set(identities) != set(by_file): + raise BaselineError("mismatch", "current and baseline file identities differ") + for report, identity in zip(named, identities): + before = by_file[identity] + after = report["total_per100w"] + report["delta"] = {"before": before, "after": after, "improved": after < before} + return + if len(reports) != 1 or anonymous is None or by_file: + raise BaselineError("mismatch", "stdin requires one anonymous baseline report") + after = reports[0]["total_per100w"] + reports[0]["delta"] = { + "before": anonymous, + "after": after, + "improved": after < anonymous, + } + + +def _read_stdin() -> str: + if hasattr(sys.stdin, "reconfigure"): + # Without this the platform default decodes the pipe, so an em dash + # piped in scores zero while the same bytes in a file score two. + sys.stdin.reconfigure(encoding="utf-8", errors="replace") + return sys.stdin.read() + + +def _read_file(path: Path) -> str: + """Read one file the way stdin is read: UTF-8, replacing what will not decode. + + A file that is not UTF-8 is still prose worth scoring, so a stray byte + becomes a replacement character instead of a traceback. A file carrying NUL + is not prose at all, and scoring a PNG as English helps nobody. + """ + data = path.read_bytes() + if b"\x00" in data: + raise ValueError("binary file") + return data.decode("utf-8", errors="replace") + + +def _failure(path: str, error: Exception) -> dict[str, Any]: + return {"file": path, "error": f"{type(error).__name__}: {error}"} + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + register = _resolve_register(args) + if hasattr(sys.stdout, "reconfigure"): + # Without this a sample carrying an em dash cannot be printed on a + # console whose default encoding is not UTF-8, and the whole run dies + # on the write rather than on anything about the prose. + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + reports: list[dict[str, Any]] = [] + failed = False + voice_sample: str | None = None + if args.voice_sample: + try: + voice_sample = _read_file(Path(args.voice_sample)) + except (OSError, ValueError, UnicodeDecodeError) as error: + print(f"voice sample unreadable: {type(error).__name__}: {error}", file=sys.stderr) + failed = True + if args.paths: + for raw_path in args.paths: + path = Path(raw_path) + try: + text = _read_file(path) + except (OSError, ValueError, UnicodeDecodeError) as error: + # One unreadable path must not discard the reports already + # computed for the paths that were fine. + reports.append(_failure(str(path), error)) + failed = True + continue + report = lint( + text, + register=register, + quote_safe=args.quote_safe, + voice_sample=voice_sample, + ) + if args.voice_sample and register == "voiced": + report["voice_sample"] = str(args.voice_sample) + report["file"] = str(path) + report["file_identity"] = _file_identity(str(path)) + reports.append(report) + else: + report = lint( + _read_stdin(), + register=register, + quote_safe=args.quote_safe, + voice_sample=voice_sample, + ) + if args.voice_sample and register == "voiced": + report["voice_sample"] = str(args.voice_sample) + reports.append(report) + + scored = [report for report in reports if "total_per100w" in report] + if args.fail_over is not None: + for report in scored: + report["fail_over"] = args.fail_over + report["over_fail_over"] = report["total_per100w"] > args.fail_over + if args.baseline: + try: + by_file, anonymous = _baseline_scores( + args.baseline, + register, + args.quote_safe, + scored[0]["voice_sample_identity"] if scored else None, + ) + # JSONDecodeError is a ValueError, which also covers a baseline report + # whose total_per100w is not a number. + except BaselineError as error: + print(f"baseline {error.kind}: {error}", file=sys.stderr) + failed = True + except (OSError, UnicodeDecodeError, ValueError) as error: + print(f"baseline unreadable: {type(error).__name__}: {error}", file=sys.stderr) + failed = True + else: + try: + _attach_deltas(scored, by_file, anonymous) + except BaselineError as error: + print(f"baseline {error.kind}: {error}", file=sys.stderr) + failed = True + + if args.json: + payload: Any = reports[0] if len(reports) == 1 else reports + json.dump(payload, sys.stdout, indent=2, allow_nan=False) + print() + else: + for report in reports: + label = Path(report["file"]).name if "file" in report else "-" + if "error" in report: + print(f"{label:32} error={report['error']}") + continue + state = "" + if "over_fail_over" in report: + state = " over" if report["over_fail_over"] else " ok" + print( + f"{label:32} register={report['register']:8} " + f"words={report['words']:5d} total={report['total']:4d} " + f"per100w={report['total_per100w']:7.2f}{state}" + ) + + if failed: + return 2 + if any(report.get("over_fail_over", False) for report in scored): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/reference/rhdh-forge/SKILL.md b/skills/reference/rhdh-forge/SKILL.md index 9f593b5..8e15821 100644 --- a/skills/reference/rhdh-forge/SKILL.md +++ b/skills/reference/rhdh-forge/SKILL.md @@ -1,14 +1,14 @@ --- name: rhdh-forge description: >- - Reads GitHub and GitLab on behalf of the other RHDH skills: parse an issue, - pull request, or merge request reference, fetch issue detail as JSON, resolve - the plugin workspace an issue belongs to, read check or pipeline status and - failed run logs, and read repository files through the API. Use for a GitHub - or GitLab issue URL, a bare #number, a !number merge request, "which workspace - is this issue in", a stale statusCheckRollup, "why did that check fail", gh, - glab, or jq syntax for a forge read, and the exact payload behind a comment, - label, assignee, approval, or /publish write. + Reads GitHub and GitLab and constructs unexecuted forge payloads for the other + RHDH skills: parse an issue, pull request, or merge request reference, fetch + issue detail, resolve a plugin workspace, inspect checks or pipelines, read + repository files, or build the exact command for a GitHub pull request, + GitLab merge request, comment, label, assignee, approval, or /publish write. + Use for a forge URL, a bare #number, a !number merge request, "which workspace + is this issue in", a stale statusCheckRollup, "why did that check fail", or + safe gh, glab, and jq command construction. compatibility: >- GitHub CLI authenticated through gh auth login, plus Python 3. glab is optional and needed only for GitLab work, authenticated through @@ -17,10 +17,11 @@ compatibility: >- # RHDH Forge -One home for reading a forge. Issue parsing, issue fetch, workspace resolution, -check and pipeline reads, repository content reads, and the `gh` and `glab` -behaviours that mislead a caller who has not met them before all live here, -because otherwise every skill that touches a forge keeps its own drifting copy. +One home for reading a forge and constructing its write payloads. Issue parsing, +issue fetch, workspace resolution, check and pipeline reads, repository content +reads, and the `gh` and `glab` behaviours that mislead a caller who has not met +them before all live here. Otherwise, every skill that touches a forge keeps its +own drifting copy. This skill reads. It never executes a write. @@ -39,6 +40,8 @@ which is only needed when a GitLab host is in play. | Explain a failing, stale, or missing check | `references/gh-cli.md` | | Read a file from a repository or a PR branch | `references/gh-cli.md` | | Read a GitLab issue, MR state, pipeline status, or file | `references/glab-cli.md` | +| Prepare a GitHub pull-request creation payload | `references/gh-cli.md`, then the caller's mutation gate | +| Prepare a GitLab merge-request creation payload | `references/glab-cli.md`, then the caller's mutation gate | | Prepare a comment, label, assignee, or `/publish` payload | `references/issue-context.md`, then the caller's mutation gate | | Prepare a GitLab comment, label, or approval payload | `references/glab-cli.md`, then the caller's mutation gate | @@ -69,10 +72,18 @@ The command patterns in the references are payloads, not authorization. This skill builds the command and hands it back unexecuted, which is what leaves the decision with the user rather than with the module that knows the syntax. -Before any comment, label, assignee, review, approval, or `/publish` write, the -calling skill invokes `/mutation-gate` and follows it. The forge detail that -gate needs from here is the exact command, the repository, the issue, PR, or MR -number, the head SHA, and the body or label that will land. +Before any pull request, merge request, comment, label, assignee, review, +approval, or `/publish` write, the calling skill invokes `/mutation-gate` and +follows it. This skill returns the exact argument vector and a shell-safe +rendering, the canonical host and repository, the target object or base and head +branches, the title when applicable, and the absolute body-file path. It also +returns the body-file contents for preview and a read-only verification command. +It never runs the write command. + +For PR and MR creation, reject a missing or multiline title, a relative or +unreadable body file, an unresolved repository, or an empty base or head branch. +Treat each value as one argument. Do not interpolate a title or body into shell +syntax, use `eval`, or replace the body file with a heredoc. A request to fetch, triage, or analyze is intent to read. It approves no write. @@ -116,12 +127,13 @@ inventing a name. read with `glab` instead. - `references/issue-context.md` covers reference parsing for both forges, field extraction, workspace resolution, and the gated interaction payloads. -- `references/gh-cli.md` covers `gh` and `jq` read patterns, check and - workflow-run reads, repository content reads, the failure table, and the - overlay repository's `/publish` rules. +- `references/gh-cli.md` covers `gh` and `jq` read patterns, pull-request + creation payloads, check and workflow-run reads, repository content reads, + the failure table, and the overlay repository's `/publish` rules. - `references/glab-cli.md` covers `glab` reads for merge requests, pipelines, - and repository content, the GitLab field names that differ from GitHub's, and - the comment, label, and approval commands it constructs but never runs. + and repository content, GitLab merge-request creation payloads, the GitLab + field names that differ from GitHub's, and the other commands it constructs + but never runs. ## Completion @@ -132,6 +144,9 @@ it or reads `unresolved` with `name: null`. A check verdict is complete only once `gh run list --branch` confirmed it on GitHub, or the pipeline for the MR head SHA confirmed it on GitLab; a `gh pr checks` or `statusCheckRollup` value alone is a cached view, not a verdict. A write payload is complete when it -states the exact command, repository, issue, PR, or MR number, head SHA, and -body or label, and is handed back unexecuted for the caller's mutation gate. A -Jira key found in the issue is reported to the caller, never resolved here. +is handed back unexecuted for the caller's mutation gate. An interaction payload +states the exact command, repository, issue, PR, or MR number, head SHA, and body +or label. A PR or MR creation payload carries the canonical repository, exact +base and head branches, one-line title, absolute body-file path, body preview, +argument vector, shell-safe command, and read-only verification command. A Jira +key found in the issue is reported to the caller, never resolved here. diff --git a/skills/reference/rhdh-forge/references/gh-cli.md b/skills/reference/rhdh-forge/references/gh-cli.md index 66eda9d..ac0214e 100644 --- a/skills/reference/rhdh-forge/references/gh-cli.md +++ b/skills/reference/rhdh-forge/references/gh-cli.md @@ -1,4 +1,4 @@ -# gh reads +# gh reads and payloads Read patterns for GitHub pull requests, checks, workflow runs, and repository content, plus the behaviours that catch a caller out. Everything here is @@ -68,6 +68,56 @@ gh pr view <number> --repo <owner/repo> --json files \ For a full review context rather than an ad-hoc read, invoke `/rhdh-pr-review`, which owns the deterministic fetch. +## Construct a pull-request creation payload + +Accept `REPOSITORY`, `BASE_BRANCH`, `HEAD_BRANCH`, `TITLE`, and `BODY_FILE`. +Confirm `gh auth status` succeeds. Resolve `REPOSITORY` with +`gh repo view "$REPOSITORY" --json nameWithOwner --jq .nameWithOwner`; use that +result rather than an alias or remote URL. + +Require a canonical `owner/repo`, nonempty exact branch names, a nonempty +one-line title, and an absolute readable body file in a unique temporary +directory. Read the file for the payload preview, but do not put its contents on +the command line. + +Construct this argument vector and return it without execution: + +```text +["gh", "pr", "create", "--repo", REPOSITORY, + "--base", BASE_BRANCH, "--head", HEAD_BRANCH, + "--title", TITLE, "--body-file", BODY_FILE] +``` + +Its shell rendering quotes every value as one argument: + +```bash +gh pr create \ + --repo "$REPOSITORY" \ + --base "$BASE_BRANCH" \ + --head "$HEAD_BRANCH" \ + --title "$TITLE" \ + --body-file "$BODY_FILE" +``` + +The returned command contains shell-quoted resolved literals, not variable +references. The variables above only show the argument boundaries. + +Do not use `--body`, a heredoc, command substitution, `eval`, `--fill`, or an +editor. Those paths either put prose into shell syntax or replace the caller's +approved title and body. The caller previews the body file, sends this exact +command to `/mutation-gate`, and executes it only after approval. + +Return this read-only verification command with the payload: + +```bash +gh pr list --repo "$REPOSITORY" --base "$BASE_BRANCH" \ + --head "$HEAD_BRANCH" --state open --limit 1 \ + --json url,title,baseRefName,headRefName +``` + +If canonical repository resolution, authentication, or branch resolution +fails, return the missing capability or unresolved input instead of a command. + ## Checks go stale `gh pr checks` and `statusCheckRollup` serve a cached rollup. A rerun, a force diff --git a/skills/reference/rhdh-forge/references/glab-cli.md b/skills/reference/rhdh-forge/references/glab-cli.md index 573487d..44d89ae 100644 --- a/skills/reference/rhdh-forge/references/glab-cli.md +++ b/skills/reference/rhdh-forge/references/glab-cli.md @@ -1,4 +1,4 @@ -# glab reads +# glab reads and payloads Read patterns for GitLab merge requests, pipelines, and repository content, plus the places where a caller who learned GitHub first gets a wrong answer. @@ -106,6 +106,62 @@ The file path is URL-encoded too, so `docs/README.md` becomes `docs%2FREADME.md` and slashes inside it must be escaped. Unlike the GitHub contents API, `/raw` returns the file as it is, with no base64 step. +## Construct a merge-request creation payload + +Accept `HOST`, `PROJECT`, `BASE_BRANCH`, `HEAD_BRANCH`, `TITLE`, and `BODY_FILE`. +Confirm `glab auth status --hostname "$HOST"` succeeds. Resolve the project with +`glab api --hostname "$HOST" "projects/<encoded-project>"` and use its +`path_with_namespace` as the canonical `PROJECT`. Encode every slash in the +canonical project path as `%2F` for the API endpoint. + +Require a nonempty host and canonical project, nonempty exact branch names, a +nonempty one-line title, and an absolute readable body file in a unique +temporary directory. The `glab mr create` command has no body-file option. Use +`glab api --field description=@<file>` so `glab` reads the body as data rather +than putting it into shell syntax. + +Construct this argument vector and return it without execution: + +```text +["glab", "api", "--hostname", HOST, "--method", "POST", + "projects/<encoded-project>/merge_requests", + "--raw-field", "source_branch=<HEAD_BRANCH>", + "--raw-field", "target_branch=<BASE_BRANCH>", + "--raw-field", "title=<TITLE>", + "--field", "description=@<BODY_FILE>"] +``` + +Its shell rendering quotes every field as one argument: + +```bash +glab api --hostname "$HOST" --method POST \ + "projects/$ENCODED_PROJECT/merge_requests" \ + --raw-field "source_branch=$HEAD_BRANCH" \ + --raw-field "target_branch=$BASE_BRANCH" \ + --raw-field "title=$TITLE" \ + --field "description=@$BODY_FILE" +``` + +The returned command contains shell-quoted resolved literals, not variable +references. The variables above only show the argument boundaries. Percent- +encode the canonical project path as one endpoint component; at minimum, every +namespace slash becomes `%2F`. + +Add `--field "remove_source_branch=true"` only when the caller explicitly asks +for that behavior. Do not use command substitution, a heredoc, `eval`, or +`glab mr create --fill`; the title and body must remain the caller's reviewed +bytes. The caller previews the body file, sends the exact returned command to +`/mutation-gate`, and executes it only after approval. + +Return this read-only verification command with the payload: + +```bash +glab mr view "$HEAD_BRANCH" --repo "https://$HOST/$PROJECT" --output json +``` + +If authentication, host, project, or branch resolution fails, return the +missing capability or unresolved input instead of a command. + ## Payloads, not executions This skill constructs these and hands them back. The calling skill states the diff --git a/skills/reference/rhdh-jira-authoring/SKILL.md b/skills/reference/rhdh-jira-authoring/SKILL.md index cfd28a8..4d199e8 100644 --- a/skills/reference/rhdh-jira-authoring/SKILL.md +++ b/skills/reference/rhdh-jira-authoring/SKILL.md @@ -68,6 +68,14 @@ that conversion. ## Completion +When a person invokes this skill directly and the result is a complete draft +for them to use, invoke `/prose-editing` once on the filled summary and +description in the **flavored** register before returning it. Preserve Jira +wiki markup, template headings, field values, issue keys, links, and acceptance +criteria. When `/rhdh-jira-create` or `/rhdh-jira-refine` calls this skill, hand +back the authoring result without editing; the final caller owns the single +prose pass. + Complete when the draft names its template and its issue type, every section the template asks for is either filled or explicitly marked out of scope, every applicable challenge in the matrix has been applied or skipped with a stated diff --git a/skills/release/rhdh-release-announce/SKILL.md b/skills/release/rhdh-release-announce/SKILL.md index 578bf30..6f59bb8 100644 --- a/skills/release/rhdh-release-announce/SKILL.md +++ b/skills/release/rhdh-release-announce/SKILL.md @@ -7,7 +7,7 @@ description: >- Day figures from Jira RHIDP and RHDHPLAN. Use for "announce feature freeze", "draft the code freeze message for 1.10", "send the freeze update for 1.11", or "write the freeze post for the release channel". -compatibility: "Python 3.9+ and uv; acli with a Jira session; gog for team data; an RHIDP Operational Rich Filter export; the external /humanizer skill." +compatibility: "Python 3.9+ and uv; acli with a Jira session; gog for team data; an RHIDP Operational Rich Filter export." --- # RHDH freeze announcements @@ -15,15 +15,7 @@ compatibility: "Python 3.9+ and uv; acli with a Jira session; gog for team data; Produce a Slack message a human posts under their own name. The counts come from the CLI; the voice must not sound machine-written. -## Hard prerequisite: /humanizer - -`/humanizer` is required before any draft is shown to the user, on every route. - -If `/humanizer` is not installed, stop. Say the draft cannot be presented, name -`/setup-rhdh-skills install` as the way to add it, and do not show the raw -message in the meantime — not as a preview, not in a code block, not "just so you -can see the numbers". Reporting the counts as plain data is fine; presenting -anything shaped like the post is not. +## Delivery boundary Never post to Slack. This skill hands the user a message to copy; the user decides whether, when, and where it goes out. @@ -47,7 +39,7 @@ thing people get wrong. ## Completion -Complete when the message is the humanized draft, wrapped in a triple-backtick +Complete when the message is the edited draft, wrapped in a triple-backtick block ready to paste, with every placeholder filled and every count carrying a URL-encoded Jira search link. State which of the four announcements was drafted and whether it is the milestone or the update, so the sender knows what day it diff --git a/skills/release/rhdh-release-announce/workflows/freeze-announcement.md b/skills/release/rhdh-release-announce/workflows/freeze-announcement.md index e04b259..b04bbd7 100644 --- a/skills/release/rhdh-release-announce/workflows/freeze-announcement.md +++ b/skills/release/rhdh-release-announce/workflows/freeze-announcement.md @@ -4,9 +4,8 @@ Four announcements, one command, one token apart. <prerequisites> -`/humanizer` must be available — see the hard prerequisite in `../SKILL.md`. Jira -reads use `acli`, team data uses `gog`, and every freeze and release-note scope -comes from the Rich Filter export. Run +Jira reads use `acli`, team data uses `gog`, and every freeze and release-note +scope comes from the Rich Filter export. Run `uv run scripts/release.py --json check` when anything fails and follow its `next_steps`. @@ -40,10 +39,11 @@ demos, Test Day features, open issues, EPICs, CVEs, release notes. The two updates carry per-team lines with each team's count, Jira link, and lead Slack handle, plus the freeze date itself, so the teams still holding work can see it. -## Step 2: Humanize, then present +## Step 2: Edit, then present -Invoke `/humanizer` on the full draft. Present the humanized text in a -triple-backtick block. Never show the pre-humanizer message as the draft. +Invoke `/prose-editing` on the full draft in the **voiced** register. Present +what it returns in a triple-backtick block. Never show the unedited message as +the draft. Alongside the draft, list any figure the CLI could not produce. Leave it named and missing; do not fill it. @@ -82,7 +82,7 @@ Filter's Cloud ID clauses; fix the configuration and retry instead. <success_criteria> - [ ] The right token for the milestone-or-update the user meant -- [ ] `/humanizer` ran on the draft before it was shown +- [ ] `/prose-editing` ran on the draft before it was shown - [ ] Draft in a triple-backtick block, no placeholders left unfilled - [ ] Every count carries a Jira link; missing figures are named, not invented - [ ] Nothing was posted diff --git a/skills/release/rhdh-test-plan-review/workflows/review-test-plan.md b/skills/release/rhdh-test-plan-review/workflows/review-test-plan.md index 80ee9a0..03bb945 100644 --- a/skills/release/rhdh-test-plan-review/workflows/review-test-plan.md +++ b/skills/release/rhdh-test-plan-review/workflows/review-test-plan.md @@ -211,10 +211,17 @@ These suggestions are based on support lifecycle pages checked on [today's date] No changes have been applied to this ticket. ``` +Invoke `/prose-editing` once on the completed comment body in the **flavored** +register. Preserve product versions, platform names, dates, and the claim about +whether changes were applied. Show and gate that edited body; `/rhdh-jira-update` +only transports it. + Follow `/mutation-gate`. State one operation: the target ticket and the exact comment body shown above. The user may approve, edit, or cancel it. Post only -after approval, through `/rhdh-jira-update`, then report the outcome. Stop here; -do not create child tasks for a comment-only outcome. +after approval. Hand the exact approved body to `/rhdh-jira-update` as +**caller-finalized**, so it transports the body without another prose pass or +confirmation. Report the outcome, then stop; do not create child tasks for a +comment-only outcome. --- diff --git a/tests/conftest.py b/tests/conftest.py index 53eeb4c..00c2ef4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import json import os +import re import subprocess import sys from io import StringIO @@ -13,6 +14,42 @@ # Path to the project root PROJECT_ROOT = Path(__file__).parent.parent +# Git advertises the environment variables whose values are local to a repository. +# A hook exports them, so a `git init` that inherits them can retarget the hook's +# repository instead of the temporary directory a test asked for. Ask the installed +# Git for the complete list rather than maintaining an inevitably incomplete copy. +GIT_LOCAL_ENV_VARS = tuple( + dict.fromkeys( + subprocess.run( + ["git", "rev-parse", "--local-env-vars"], + check=True, + capture_output=True, + text=True, + env={key: value for key, value in os.environ.items() if not key.startswith("GIT_")}, + ).stdout.splitlines() + + ["GIT_NAMESPACE"] + ) +) + + +def git_env(**overrides: str) -> dict[str, str]: + """Return os.environ with Git's repository-local variables removed. + + Every subprocess git call in the test suite goes through this so a test that + creates its own repository cannot reach the checkout pytest is running in. + ``GIT_CONFIG_COUNT`` is in Git's advertised list; remove its dynamically named + ``GIT_CONFIG_KEY_n`` and ``GIT_CONFIG_VALUE_n`` companions as well. + """ + env = { + key: value + for key, value in os.environ.items() + if key.upper() not in GIT_LOCAL_ENV_VARS + and not re.fullmatch(r"GIT_CONFIG_(?:KEY|VALUE)_\d+", key.upper()) + } + env.update(overrides) + return env + + # Path to the context skill directory (where the preserved rhdh package lives) RHDH_SKILL_DIR = PROJECT_ROOT / "skills" / "reference" / "rhdh-context" @@ -101,19 +138,18 @@ def isolated_env(tmp_path, monkeypatch): (sample_workspace / "plugins-list.yaml").write_text("- plugins/test/frontend:\n") # Initialize as git repo - subprocess.run(["git", "init"], cwd=overlay_dir, capture_output=True) - subprocess.run(["git", "add", "."], cwd=overlay_dir, capture_output=True) + subprocess.run(["git", "init"], cwd=overlay_dir, capture_output=True, env=git_env()) + subprocess.run(["git", "add", "."], cwd=overlay_dir, capture_output=True, env=git_env()) subprocess.run( ["git", "commit", "-m", "init"], cwd=overlay_dir, capture_output=True, - env={ - **os.environ, - "GIT_AUTHOR_NAME": "test", - "GIT_AUTHOR_EMAIL": "test@test.com", - "GIT_COMMITTER_NAME": "test", - "GIT_COMMITTER_EMAIL": "test@test.com", - }, + env=git_env( + GIT_AUTHOR_NAME="test", + GIT_AUTHOR_EMAIL="test@test.com", + GIT_COMMITTER_NAME="test", + GIT_COMMITTER_EMAIL="test@test.com", + ), ) # Create mock rhdh-local @@ -235,32 +271,3 @@ def _run_cli(*args, env=None): return run_cli_python(*args, env=full_env, isolated_env=isolated_env) return _run_cli - - -# Legacy fixture for subprocess-based testing (kept for backward compatibility) -def run_cli_subprocess(*args, cwd=None, env=None): - """Run the rhdh CLI via subprocess and return result. - - Args: - *args: CLI arguments - cwd: Working directory - env: Environment variables (merged with current env) - - Returns: - subprocess.CompletedProcess with stdout, stderr, returncode - """ - script_path = SCRIPTS_DIR / "rhdh" - - run_env = os.environ.copy() - if env: - run_env.update(env) - - result = subprocess.run( - [str(script_path), *args], - capture_output=True, - text=True, - cwd=cwd, - env=run_env, - ) - - return CLIResult(result.returncode, result.stdout, result.stderr) diff --git a/tests/unit/test_git_env_isolation.py b/tests/unit/test_git_env_isolation.py new file mode 100644 index 0000000..1e03f00 --- /dev/null +++ b/tests/unit/test_git_env_isolation.py @@ -0,0 +1,100 @@ +"""A test that creates a git repository must not be able to reach the checkout +pytest is running in. + +A git hook exports GIT_DIR and friends. A `git init` that inherits them retargets +the hook's repository: it rewrites that repository's config (marking a normal +checkout core.bare=true) and points `git add` at the real index. +""" + +from __future__ import annotations + +import subprocess + +from conftest import GIT_LOCAL_ENV_VARS, git_env + + +def test_location_vars_are_removed(monkeypatch): + assert {"GIT_CONFIG", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"} <= set(GIT_LOCAL_ENV_VARS) + for name in GIT_LOCAL_ENV_VARS: + monkeypatch.setenv(name, "/somewhere/else") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "redirect.setting") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "inherited") + + env = git_env() + + assert not [name for name in GIT_LOCAL_ENV_VARS if name in env] + assert "GIT_CONFIG_KEY_0" not in env + assert "GIT_CONFIG_VALUE_0" not in env + + +def test_identity_vars_survive_because_they_do_not_redirect(monkeypatch): + monkeypatch.setenv("GIT_DIR", "/somewhere/else") + + env = git_env(GIT_AUTHOR_NAME="test", GIT_AUTHOR_EMAIL="test@test.com") + + assert env["GIT_AUTHOR_NAME"] == "test" + assert env["GIT_AUTHOR_EMAIL"] == "test@test.com" + assert "GIT_DIR" not in env + + +def test_overrides_are_applied(monkeypatch): + monkeypatch.setenv("HOME", "/original") + + assert git_env(HOME="/replacement")["HOME"] == "/replacement" + + +def test_git_init_under_a_hook_environment_leaves_the_outer_repo_alone(tmp_path, monkeypatch): + """The regression. Without git_env, this git init rewrites `outer`'s config.""" + outer = tmp_path / "outer" + outer.mkdir() + subprocess.run(["git", "init"], cwd=outer, check=True, capture_output=True, env=git_env()) + assert ( + subprocess.run( + ["git", "config", "--get", "core.bare"], + cwd=outer, + capture_output=True, + text=True, + env=git_env(), + ).stdout.strip() + == "false" + ) + + # Stand where a git hook stands: GIT_DIR already points at another repository. + monkeypatch.setenv("GIT_DIR", str(outer / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", str(outer)) + + inner = tmp_path / "inner" + inner.mkdir() + subprocess.run(["git", "init"], cwd=inner, check=True, capture_output=True, env=git_env()) + + assert (inner / ".git").is_dir(), "git init did not create a repository where it was asked to" + still_bare = subprocess.run( + ["git", "config", "--get", "core.bare"], + cwd=outer, + capture_output=True, + text=True, + env=git_env(), + ).stdout.strip() + assert still_bare == "false", "git init reached out of its cwd and rewrote the outer repository" + + +def test_dynamic_config_environment_does_not_reach_a_temporary_repo(tmp_path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True, env=git_env()) + + monkeypatch.setenv("GIT_CONFIG_COUNT", "1") + monkeypatch.setenv("GIT_CONFIG_KEY_0", "redirect.setting") + monkeypatch.setenv("GIT_CONFIG_VALUE_0", "inherited") + monkeypatch.setenv("GIT_CONFIG_PARAMETERS", "'other.setting'='also-inherited'") + + result = subprocess.run( + ["git", "config", "--get-regexp", r"^(redirect|other)\.setting$"], + cwd=repo, + capture_output=True, + text=True, + env=git_env(), + ) + + assert result.returncode == 1 + assert result.stdout == "" diff --git a/tests/unit/test_prose_editing_callers.py b/tests/unit/test_prose_editing_callers.py new file mode 100644 index 0000000..d92e9a8 --- /dev/null +++ b/tests/unit/test_prose_editing_callers.py @@ -0,0 +1,627 @@ +"""Stable composition and CI-skill contracts for prose that leaves a workflow.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest +from conftest import git_env + +ROOT = Path(__file__).resolve().parents[2] +LINTER = ROOT / "skills/reference/prose-editing/scripts/lint.py" +RPA_UPDATER = ROOT / "skills/ci/rhdh-konflux-rpa/scripts/update_rpa_tags.py" +RPA_RELATIVE_DIR = Path("config/stone-prod-p02.hjvn.p1/product/ReleasePlanAdmission/rhdh") +RPA_FILENAMES = ( + "rhdh-1-9-prod.yaml", + "rhdh-1-9-stage.yaml", + "rhdh-plugin-catalog-1-9-prod.yaml", + "rhdh-plugin-catalog-1-9-stage.yaml", +) + +STATIC_TEMPLATE_MANUAL_CHECKS = { + "claim_preservation": "The fixed template is the source; substitutions are identifiers.", + "voice_fidelity": "Automation templates have no supplied personal voice.", + "terminology_consistency": "RHDH and RPM/RPA terms are fixed by their workflows.", + "word_meaning_consistency": "The templates contain no ambiguous synonym swaps.", + "active_subject_context": "Each action names the automation or changed artifact.", + "one_instruction_per_sentence": "The templates report work; they give no instructions.", + "article_use": "A maintainer reviewed article use in the fixed text.", + "abbreviation_definition": "Titles use repository-standard RPM/RPA terminology.", + "paragraph_focus": "Each short paragraph has one reporting purpose.", + "safety_labels": "The templates contain no warning or safety procedure.", + "heading_restatement": "The What/Why headings separate payload fields.", + "hollow_paragraph": "Every paragraph names a change or release purpose.", + "quotation_ownership": "The templates quote no person or external source.", + "objection_context": "The templates make no objection or rebuttal.", + "alternative_relevance": "The templates compare no alternatives.", + "american_spelling": "The fixed templates use repository-standard US spelling.", + "condition_before_command": "The templates contain no conditional commands.", +} + + +def _path(relative: str) -> Path: + return ROOT / relative + + +def _text(relative: str) -> str: + return _path(relative).read_text(encoding="utf-8") + + +def _named_skill_contexts(relative: str, name: str = "/prose-editing") -> list[str]: + text = _text(relative) + return [ + text[match.start() : match.start() + 240] for match in re.finditer(re.escape(name), text) + ] + + +def _frontmatter(relative: str) -> str: + parts = _text(relative).split("---", 2) + assert len(parts) == 3 + return parts[1] + + +def _linter_module(name: str): + spec = importlib.util.spec_from_file_location(name, LINTER) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _rpa_updater_module(name: str): + spec = importlib.util.spec_from_file_location(name, RPA_UPDATER) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _make_rpa_repo( + tmp_path: Path, + content: str = 'tags: ["1.9", "1.9.6"]\n', + remote: str = "https://gitlab.cee.redhat.com/releng/konflux-release-data.git", +) -> tuple[Path, Path]: + repo = tmp_path / "konflux-release-data" + rpa_dir = repo / RPA_RELATIVE_DIR + rpa_dir.mkdir(parents=True) + for name in RPA_FILENAMES: + (rpa_dir / name).write_text(content, encoding="utf-8") + + env = git_env() + subprocess.run(["git", "init", "-q", str(repo)], check=True, env=env) + subprocess.run(["git", "-C", str(repo), "remote", "add", "origin", remote], check=True, env=env) + subprocess.run(["git", "-C", str(repo), "add", "."], check=True, env=env) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qm", + "fixture", + ], + check=True, + env=env, + ) + return repo, rpa_dir + + +def _commit_fixture(repo: Path, message: str) -> None: + env = git_env() + subprocess.run(["git", "-C", str(repo), "add", "."], check=True, env=env) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-qm", + message, + ], + check=True, + env=env, + ) + + +def _symlink_or_skip(link: Path, target: Path, *, directory: bool = False) -> None: + try: + link.symlink_to(target, target_is_directory=directory) + except OSError as error: + pytest.skip(f"symlink creation is unavailable: {error}") + + +def _run_rpa(script: Path, repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(script.resolve()), "1.9.7", "--repo-dir", str(repo.resolve()), *args], + check=False, + capture_output=True, + text=True, + env=git_env(), + ) + + +def _assert_static_template(report: dict[str, object]) -> None: + assert report["total"] == 0 + markers = report["markers"] + assert isinstance(markers, dict) + assert set(markers.values()) == {0} + assert set(report["manual_checks"]) == set(STATIC_TEMPLATE_MANUAL_CHECKS) + assert all(STATIC_TEMPLATE_MANUAL_CHECKS.values()) + + +@pytest.mark.parametrize( + ("relative", "register", "expected_calls"), + [ + ("skills/plugins/rhdh-pr-review/workflows/review-code.md", "flavored", 1), + ("skills/plugins/rhdh-pr-review/workflows/review-operator-pr.md", "flavored", 1), + ("skills/jira/rhdh-jira-create/workflows/create-issue.md", "flavored", 2), + ("skills/jira/rhdh-jira-update/workflows/update-issue.md", "flavored", 1), + ("skills/jira/rhdh-jira-refine/workflows/refine-issues.md", "flavored", 1), + ("skills/release/rhdh-release-announce/workflows/freeze-announcement.md", "voiced", 1), + ("skills/release/rhdh-test-plan-review/workflows/review-test-plan.md", "flavored", 1), + ("skills/plugins/rhdh-pr-create/workflows/create-pull-request.md", "flavored", 1), + ("skills/jira/rhdh-jira-link/SKILL.md", "flavored", 1), + ("skills/plugins/rhdh-overlay/workflows/draft-notification.md", "voiced", 1), + ("skills/plugins/rhdh-overlay/workflows/onboard-plugin.md", "flavored", 2), + ("skills/plugins/rhdh-overlay/workflows/update-plugin.md", "flavored", 2), + ("skills/plugins/rhdh-plugin-bug-fix/workflows/fix-bug.md", "flavored", 1), + ("skills/plugins/rhdh-plugin-midstream-propagate/SKILL.md", "flavored", 1), + ("skills/ci/rhdh-prow-release-branch/workflows/commission-release.md", "flavored", 1), + ("skills/ci/rhdh-prow-release-branch/workflows/decommission-release.md", "flavored", 1), + ], +) +def test_final_composers_name_one_register_per_artifact( + relative: str, register: str, expected_calls: int +) -> None: + calls = [ + call + for call in _named_skill_contexts(relative) + if re.search(rf"\b{register}\b", call, re.IGNORECASE) + ] + assert len(calls) == expected_calls + + +def test_shared_caller_policy_lives_at_final_composers() -> None: + assert "/prose-editing" not in _text("skills/plugins/rhdh-pr-review/SKILL.md") + assert "/prose-editing" not in _text("skills/release/rhdh-release-announce/SKILL.md") + + +def test_jira_authoring_edits_direct_handback_but_not_caller_handoff() -> None: + text = _text("skills/reference/rhdh-jira-authoring/SKILL.md") + calls = _named_skill_contexts("skills/reference/rhdh-jira-authoring/SKILL.md") + assert len(calls) == 1 + assert re.search(r"\bflavored\b", calls[0], re.IGNORECASE) + assert "/rhdh-jira-create" in text + assert "/rhdh-jira-refine" in text + + +def test_jira_update_preserves_a_caller_finalized_comment() -> None: + producer = _text("skills/release/rhdh-test-plan-review/workflows/review-test-plan.md") + transport = _text("skills/jira/rhdh-jira-update/workflows/update-issue.md") + + assert "caller-finalized" in producer + assert "caller-finalized" in transport + assert transport.count("/prose-editing") == 1 + + +def test_jira_refine_finalizes_comment_bodies_before_one_write_gate() -> None: + workflow = _text("skills/jira/rhdh-jira-refine/workflows/refine-issues.md") + + assert workflow.count("/prose-editing") == 1 + assert workflow.count("/mutation-gate") == 1 + assert workflow.index("/prose-editing") < workflow.index("/mutation-gate") + + +def test_transport_layers_do_not_reedit_prose() -> None: + for relative in ( + "skills/plugins/rhdh-pr-review/workflows/post-to-github.md", + "skills/jira/rhdh-jira-link/scripts/create-pr-mr.js", + "skills/jira/rhdh-jira-link/scripts/link-pr-mr.js", + "skills/ci/rhdh-base-images/scripts/base-images-and-rpms.sh", + "skills/ci/rhdh-konflux-rpa/scripts/update_rpa_tags.py", + ): + assert "/prose-editing" not in _text(relative) + + +def test_prow_uses_forge_payload_and_mutation_gate() -> None: + skill = _text("skills/ci/rhdh-prow-release-branch/SKILL.md") + assert "gh" in skill.partition("compatibility:")[2].partition("---")[0] + assert not re.search(r"\bgh\s+auth\s+status\b", skill) + assert "git remote get-url" not in skill + gate_contexts = _named_skill_contexts( + "skills/ci/rhdh-prow-release-branch/SKILL.md", "/mutation-gate" + ) + assert len(gate_contexts) == 1 + assert "make update" not in gate_contexts[0] + assert not re.search( + r"(?:copy|edit|delet|make update)[^.]{0,160}/mutation-gate", + skill, + re.IGNORECASE, + ) + assert all( + operation in gate_contexts[0].casefold() for operation in ("commit", "push", "pull request") + ) + for workflow in ("commission-release.md", "decommission-release.md"): + text = _text(f"skills/ci/rhdh-prow-release-branch/workflows/{workflow}") + assert text.count("/rhdh-forge") == 1 + assert text.count("/mutation-gate") == 1 + clean = text.index("git status --porcelain --untracked-files=all") + branch = text.index("git switch -c") + validate = text.index("git diff --check") + forge = text.index("/rhdh-forge") + gate = text.index("/mutation-gate") + update_positions = [match.start() for match in re.finditer(r"`make update`", text)] + assert clean < branch < validate < forge < gate + assert any(branch < update < validate for update in update_positions) + branch_command = re.search(r'git switch -c\s+"[^"]+"\s+"<base-branch>"', text) + assert branch_command + commit = text.index("git commit", gate) + push = text.index("git push", gate) + remote_head = text.index("git ls-remote", gate) + forge_command = text.index("<forge-pr-command>", gate) + assert commit < push < remote_head < forge_command + + +def test_overlay_triage_delegates_slack_drafting_without_a_fallback() -> None: + triage = _text("skills/plugins/rhdh-overlay/workflows/triage-prs.md") + owner = _text("skills/plugins/rhdh-overlay/workflows/draft-notification.md") + + assert triage.count("workflows/draft-notification.md") == 1 + assert "/prose-editing" not in triage + assert not re.search(r"\bcompose\s+manually\b", triage, re.IGNORECASE) + assert not re.search(r"(?m)^Hey\s+@", triage) + assert owner.count("/prose-editing") == 1 + + +def test_rpa_is_an_independently_installable_skill() -> None: + task_skill = _text("skills/ci/rhdh-konflux-tasks/SKILL.md") + assert "konflux-rpa" not in task_skill.lower() + assert "ReleasePlanAdmission" not in task_skill + assert not _path("skills/ci/rhdh-konflux-tasks/workflows/konflux-rpa-update.md").exists() + assert not _path("skills/ci/rhdh-konflux-tasks/scripts/update-rpa-tags.sh").exists() + + rpa = _text("skills/ci/rhdh-konflux-rpa/SKILL.md") + rpa_frontmatter = _frontmatter("skills/ci/rhdh-konflux-rpa/SKILL.md") + interface = _text("skills/ci/rhdh-konflux-rpa/agents/openai.yaml") + workflow = _text("skills/ci/rhdh-konflux-rpa/workflows/update-rpa.md") + assert re.search(r"^name: rhdh-konflux-rpa$", rpa, re.MULTILINE) + assert "ReleasePlanAdmission" in rpa_frontmatter + assert "konflux-release-data" in rpa_frontmatter + assert "rhdh-konflux-tasks" not in rpa_frontmatter + compatibility = rpa_frontmatter.casefold() + assert all(tool in compatibility for tool in ("glab", "python", "git")) + assert "bash" not in compatibility + scripts = [ + path for path in _path("skills/ci/rhdh-konflux-rpa/scripts").iterdir() if path.is_file() + ] + assert scripts == [RPA_UPDATER] + assert "interface:" in interface + forge_positions = [match.start() for match in re.finditer("/rhdh-forge", workflow)] + gate_positions = [match.start() for match in re.finditer("/mutation-gate", workflow)] + assert forge_positions and gate_positions + assert any(forge < gate for forge in forge_positions for gate in gate_positions) + assert "--dry-run" in workflow + assert "--local-only" in workflow + assert not _named_skill_contexts("skills/ci/rhdh-konflux-rpa/workflows/update-rpa.md") + + catalog = json.loads(_text("skills/meta/setup-rhdh-skills/assets/catalog.json")) + entry = next(item for item in catalog["skills"] if item["name"] == "rhdh-konflux-rpa") + assert set(entry["requiresSkills"]) == {"mutation-gate", "rhdh-forge"} + + +def test_rpa_local_only_mode_changes_files_without_publish_transport(tmp_path: Path) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + script = RPA_UPDATER + dry_run = _run_rpa(script, repo, "--dry-run") + assert dry_run.returncode == 0, dry_run.stderr + assert ( + subprocess.run( + ["git", "-C", str(repo), "diff", "--quiet"], check=False, env=git_env() + ).returncode + == 0 + ) + + result = _run_rpa(script, repo, "--local-only") + assert result.returncode == 0, result.stderr + assert "Local-only update complete" in result.stderr + assert "1.9.7" in (rpa_dir / "rhdh-1-9-prod.yaml").read_text(encoding="utf-8") + assert ( + subprocess.run( + ["git", "-C", str(repo), "diff", "--quiet"], check=False, env=git_env() + ).returncode + == 1 + ) + + +def test_rpa_cli_ignores_ambient_git_repository_overrides( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + target_repo, target_rpa = _make_rpa_repo(tmp_path / "target") + foreign_repo, _ = _make_rpa_repo(tmp_path / "foreign") + monkeypatch.setenv("GIT_DIR", str(foreign_repo / ".git")) + monkeypatch.setenv("GIT_WORK_TREE", str(foreign_repo)) + updater = _rpa_updater_module("rpa_updater_git_environment") + + result = updater.main(["1.9.7", "--repo-dir", str(target_repo), "--dry-run"]) + report = json.loads(capsys.readouterr().out) + + assert result == 0 + assert all(str(target_rpa) in path for path in report["files"]) + + +def test_rpa_replacement_changes_only_literal_tag_values(tmp_path: Path) -> None: + source = """metadata: + annotations: + release-note: "keep 1.9.6 and 1x9x6" + tags: ["1.9.6"] +spec: + description: "keep 1.9.6" + notes: | # embedded examples are opaque + tags: + - "1.9.6" + tags: + - "1.9" + - "1.9.6" + - '1.9.6''note' + - '1.9.6' + - "1.9.6--1.20.2" + - "1.9.*" + - "1x9x6" + nested: + tags: ["1.9", "1.9.6", '1.9.6''note, keep', '1.9.6', "1.9.6--2.3.4", "1.9.*", "1x9x6"] # keep comment + scalar: + tags: '1.9.6--3.4.5' # keep scalar comment + indentationless: + tags: + - "1.9.6" + tag-mapping: + tags: + annotations: + - "1.9.6" +""" + expected = """metadata: + annotations: + release-note: "keep 1.9.6 and 1x9x6" + tags: ["1.9.6"] +spec: + description: "keep 1.9.6" + notes: | # embedded examples are opaque + tags: + - "1.9.6" + tags: + - "1.9" + - "1.9.7" + - '1.9.6''note' + - '1.9.7' + - "1.9.7--1.20.2" + - "1.9.*" + - "1x9x6" + nested: + tags: ["1.9", "1.9.7", '1.9.6''note, keep', '1.9.7', "1.9.7--2.3.4", "1.9.*", "1x9x6"] # keep comment + scalar: + tags: '1.9.7--3.4.5' # keep scalar comment + indentationless: + tags: + - "1.9.7" + tag-mapping: + tags: + annotations: + - "1.9.6" +""" + repo, rpa_dir = _make_rpa_repo(tmp_path, content=source) + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--local-only") + + assert result.returncode == 0, result.stderr + for name in RPA_FILENAMES: + assert (rpa_dir / name).read_text(encoding="utf-8") == expected + + +@pytest.mark.parametrize( + "unsupported", + ( + 'tags: ["1.9",\n "1.9.6"]\n', + 'tags:\n ["1.9",\n "1.9.6"]\n', + ), +) +def test_rpa_rejects_multiline_flow_tags_without_writing_any_file( + tmp_path: Path, unsupported: str +) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + (rpa_dir / RPA_FILENAMES[-1]).write_text(unsupported, encoding="utf-8") + _commit_fixture(repo, "unsupported fixture") + originals = {path: path.read_bytes() for path in rpa_dir.iterdir()} + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--local-only") + + assert result.returncode != 0 + assert {path: path.read_bytes() for path in rpa_dir.iterdir()} == originals + + +@pytest.mark.parametrize( + "unsupported", + ( + "tags: '1.9.6\n continued'\n", + 'tags: "1.9.6\n continued"\n', + "tags:\n - '1.9.6\n continued'\n", + 'tags:\n - "1.9.6\n continued"\n', + ), +) +def test_rpa_rejects_multiline_quoted_tag_values_without_writing_any_file( + tmp_path: Path, unsupported: str +) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + (rpa_dir / RPA_FILENAMES[-1]).write_text(unsupported, encoding="utf-8") + _commit_fixture(repo, "unsupported quoted fixture") + originals = {path: path.read_bytes() for path in rpa_dir.iterdir()} + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--local-only") + + assert result.returncode != 0 + assert {path: path.read_bytes() for path in rpa_dir.iterdir()} == originals + + +def test_rpa_rejects_a_symlinked_canonical_directory(tmp_path: Path) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + physical = repo / "physical-rpa" + rpa_dir.rename(physical) + _symlink_or_skip(rpa_dir, physical, directory=True) + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--dry-run") + + assert result.returncode != 0 + + +def test_rpa_rejects_a_symlinked_target_file(tmp_path: Path) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + target = rpa_dir / RPA_FILENAMES[0] + physical = repo / "outside-rpa.yaml" + target.rename(physical) + _symlink_or_skip(target, physical) + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--dry-run") + + assert result.returncode != 0 + + +def test_rpa_write_restores_all_files_when_a_replace_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + paths = tuple(rpa_dir / name for name in RPA_FILENAMES) + originals = {path: path.read_bytes() for path in paths} + original_modes = {path: path.stat().st_mode for path in paths} + updater = _rpa_updater_module("rpa_updater_atomic_failure") + real_replace = updater.os.replace + attempts = 0 + + def fail_third_replace(source: str | Path, destination: str | Path) -> None: + nonlocal attempts + if Path(destination).name in RPA_FILENAMES: + attempts += 1 + if attempts == 3: + raise OSError("injected replacement failure") + real_replace(source, destination) + + monkeypatch.setattr(updater.os, "replace", fail_third_replace) + + result = updater.main(["1.9.7", "--repo-dir", str(repo), "--local-only"]) + + assert result == 2 + assert {path: path.read_bytes() for path in paths} == originals + assert {path: path.stat().st_mode for path in paths} == original_modes + assert {path.name for path in rpa_dir.iterdir()} == set(RPA_FILENAMES) + + +def test_rpa_write_restores_all_files_when_replacement_is_interrupted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + paths = tuple(rpa_dir / name for name in RPA_FILENAMES) + originals = {path: path.read_bytes() for path in paths} + original_modes = {path: path.stat().st_mode for path in paths} + updater = _rpa_updater_module("rpa_updater_atomic_interrupt") + real_replace = updater.os.replace + attempts = 0 + + def interrupt_third_replace(source: str | Path, destination: str | Path) -> None: + nonlocal attempts + if Path(destination).name in RPA_FILENAMES: + attempts += 1 + if attempts == 3: + raise KeyboardInterrupt + real_replace(source, destination) + + monkeypatch.setattr(updater.os, "replace", interrupt_third_replace) + + with pytest.raises(KeyboardInterrupt): + updater.main(["1.9.7", "--repo-dir", str(repo), "--local-only"]) + + assert {path: path.read_bytes() for path in paths} == originals + assert {path: path.stat().st_mode for path in paths} == original_modes + assert {path.name for path in rpa_dir.iterdir()} == set(RPA_FILENAMES) + + +def test_rpa_local_edit_rejects_untracked_files(tmp_path: Path) -> None: + repo, rpa_dir = _make_rpa_repo(tmp_path) + (repo / "untracked.txt").write_text("keep", encoding="utf-8") + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--local-only") + + assert result.returncode != 0 + assert "1.9.6" in (rpa_dir / RPA_FILENAMES[0]).read_text(encoding="utf-8") + + +def test_rpa_local_edit_requires_the_upstream_repository(tmp_path: Path) -> None: + repo, rpa_dir = _make_rpa_repo( + tmp_path, remote="https://gitlab.cee.redhat.com/example/konflux-release-data.git" + ) + script = RPA_UPDATER + + result = _run_rpa(script, repo, "--local-only") + + assert result.returncode != 0 + assert "1.9.6" in (rpa_dir / RPA_FILENAMES[0]).read_text(encoding="utf-8") + + +def test_rpa_local_edit_requires_the_canonical_rpa_directory(tmp_path: Path) -> None: + repo, _ = _make_rpa_repo(tmp_path) + decoy = repo / "decoy" + decoy.mkdir() + for name in RPA_FILENAMES: + (decoy / name).write_text('tags: ["1.9", "1.9.6"]\n', encoding="utf-8") + script = RPA_UPDATER + + result = _run_rpa(script, decoy, "--dry-run") + + assert result.returncode != 0 + assert "1.9.6" in (decoy / RPA_FILENAMES[0]).read_text(encoding="utf-8") + + +def test_rpa_script_contains_no_publish_transport() -> None: + script = RPA_UPDATER.read_text(encoding="utf-8") + assert "glab" not in script + for command in ("push", "commit", "fetch"): + assert not re.search(rf"[\"']{command}[\"']", script) + + +def test_base_image_automation_pr_payload_passes_static_prose_lint() -> None: + script = _text("skills/ci/rhdh-base-images/scripts/base-images-and-rpms.sh") + payload = {field: re.search(rf'--{field}\s+"([^"]+)"', script) for field in ("title", "body")} + assert all(payload.values()), "expected the fixed automation PR title and body" + + linter = _linter_module("prose_editing_lint_callers") + for match in payload.values(): + assert match + rendered = re.sub(r"\$\{[^}]+\}", "release-1.10", match.group(1)) + _assert_static_template(linter.lint(rendered, register="flavored")) + + +def test_rpa_automation_mr_body_passes_static_prose_lint() -> None: + template = _text("skills/ci/rhdh-konflux-rpa/references/mr-body.md") + rendered = re.sub(r"\{[^}]+\}", "VALUE", template) + report = _linter_module("prose_editing_lint_rpa").lint(rendered, register="flavored") + _assert_static_template(report) diff --git a/tests/unit/test_prose_editing_lint.py b/tests/unit/test_prose_editing_lint.py new file mode 100644 index 0000000..24f4c2a --- /dev/null +++ b/tests/unit/test_prose_editing_lint.py @@ -0,0 +1,1958 @@ +"""The prose-editing linter, exercised through lint() and through the CLI. + +Every expected score below is a worked example: the counts are written out by +hand from the fixture text, never recomputed from the implementation. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILL = PROJECT_ROOT / "skills" / "reference" / "prose-editing" +SCRIPT = SKILL / "scripts" / "lint.py" +REFERENCES = ( + SKILL / "references" / "mechanical.md", + SKILL / "references" / "compression.md", + SKILL / "references" / "voice.md", +) + +# seamless + leverage (ai_vocabulary), cutting-edge (promotional), one em dash marker, +# "it is important to note" (modal_hedge, and nowhere else), "serves as" +# (copula_avoidance), "I hope this helps" (chatbot_residue), the not-just +# parallelism, and two contractions. Nine scored violations over 31 words. +SLOPPY = ( + "This seamless platform will leverage cutting-edge tooling — it is " + "important to note that the parser serves as a gateway. I hope this helps! " + "It's not just a parser, it's a platform." +) +SLOPPY_WORDS = 31 +SLOPPY_TOTAL = 9 +SLOPPY_PER100W = 29.03 + +PLAIN = "The parser reads the file. Then it writes the result." +PLAIN_WORDS = 10 + +# A glossary puts third-party/example text in explicit Markdown quote zones. +# Under --quote-safe those zones go quiet and only first-party checks stay on. +GLOSSARY = ( + "# Words this skill removes\n\n" + "Every word below is banned in a rewrite.\n\n" + "> leverage, utilize, delve, seamless, robust\n\n" + "> Do not write spin up. The phrase in order to becomes to.\n" +) +GLOSSARY_WORDS = 29 +GLOSSARY_TOTAL = 8 +GLOSSARY_SAFE_TOTAL = 1 + +VOICED = ( + "Then the release landed. It had no roadmap. No plan. No owner.\n\n" + "The **team** shipped the **fix** and the **docs** in one day.\n\n" + "## Conclusion\n\n" + "The future looks bright. The release plays a vital role for us.\n" +) +VOICED_WORDS = 36 +VOICED_TOTAL = 6 +VOICED_PER100W = 16.67 +# staccato_drama is the voice layer. Boldface overuse is mechanical because it +# applies to every register. The +# `## Conclusion` heading, "the future looks bright" and "plays a vital role" +# are mechanical, because a README closes that way too. +VOICED_VOICE_LAYER = 1 + +# Every term the modern marketing register runs on. Each one has to score +# somewhere; which list owns it is an implementation detail. +MARKETING_TERMS = ( + "streamline", + "elevate", + "harness the", + "foster", + "bolster", + "navigate the complexities", + "ever-evolving", + "at a crossroads", + "find themselves", + "in summary", + "in conclusion", + "unlock the power", + "take it to the next level", + "game changer", + "deep dive", + "best-in-class", + "robust and scalable", +) + +CONTRACT_KEYS = { + "score_version", + "register", + "quote_safe", + "voice_sample", + "voice_sample_identity", + "words", + "sentences", + "violations", + "by_layer", + "total", + "total_per100w", + "longest_sentence_words", + "markers", + "manual_checks", + "samples", + "delta", +} + + +def load_lint(): + spec = importlib.util.spec_from_file_location("prose_editing_lint", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def run_lint( + *args: str, + stdin: str | None = None, + env: dict[str, str] | None = None, + cwd: Path | None = None, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + input=stdin, + capture_output=True, + encoding="utf-8", + check=False, + env={**os.environ, **env} if env else None, + cwd=cwd, + ) + + +def documented_examples() -> list[tuple[str, str, Path]]: + """Every `### \\`category\\`` section in the reference files, with its first example. + + The reference files put the tell in the first blockquote of the section and + the rewrite, when there is one, in the blockquotes after it. + """ + found: list[tuple[str, str, Path]] = [] + heading = re.compile(r"^#{2,4}\s+`([a-z_]+)`\s*$") + for path in REFERENCES: + category = None + quote: list[str] = [] + for line in path.read_text(encoding="utf-8").split("\n"): + if line.startswith(">"): + quote.append(line.lstrip(">").strip()) + continue + if quote and category: + found.append((category, " ".join(quote), path)) + category = None + quote = [] + match = heading.match(line.strip()) + if match: + category = match.group(1) + elif line.startswith("#"): + category = None + if quote and category: + found.append((category, " ".join(quote), path)) + return found + + +def run_lint_bytes(*args: str, stdin: bytes) -> subprocess.CompletedProcess[bytes]: + """Feed raw bytes so the child has to decode the pipe itself.""" + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + input=stdin, + capture_output=True, + check=False, + ) + + +# -------------------------------------------------------------------------- +# Worked examples +# -------------------------------------------------------------------------- + + +def test_a_puffed_paragraph_scores_nine_over_thirty_one_words(): + report = load_lint().lint(SLOPPY) + violations = report["violations"] + + assert report["register"] == "flavored" + assert report["words"] == SLOPPY_WORDS + assert violations["em_dash"] == 0 + assert violations["ai_vocabulary"] == 2 + assert violations["promotional"] == 1 + assert violations["modal_hedge"] == 1 + assert violations["copula_avoidance"] == 1 + assert violations["chatbot_residue"] == 1 + assert violations["negative_parallelism"] == 1 + assert violations["contraction"] == 2 + assert report["total"] == SLOPPY_TOTAL + assert report["total_per100w"] == SLOPPY_PER100W + assert report["by_layer"]["mechanical"] == 7 + assert report["by_layer"]["compression"] == 2 + assert report["by_layer"]["voice"] == 0 + + +def test_plain_technical_prose_scores_nothing(): + report = load_lint().lint(PLAIN) + + assert report["words"] == PLAIN_WORDS + assert report["total"] == 0 + assert report["total_per100w"] == 0.0 + assert report["samples"] == {} + + +def test_the_report_carries_every_contract_key(): + report = load_lint().lint(PLAIN) + + assert set(report) == CONTRACT_KEYS + assert report["score_version"] == 11 + assert report["quote_safe"] is False + assert report["voice_sample"] is False + assert report["delta"] is None + assert set(report["markers"]) == set(load_lint().MARKERS) + assert not any(report["markers"].values()) + assert report["manual_checks"] == list(load_lint().MANUAL_CHECKS) + assert report["longest_sentence_words"] == 5 + + +# -------------------------------------------------------------------------- +# Regressions +# -------------------------------------------------------------------------- + + +def test_it_is_important_to_note_scores_once_not_twice(): + """It sat in the banned list and the hedge list, so one hit scored two.""" + report = load_lint().lint("It is important to note that the parser reads the file.") + + assert report["violations"]["modal_hedge"] == 1 + assert report["violations"]["verbose_word"] == 0 + assert report["total"] == 1 + + +def test_no_two_phrase_lists_hold_the_same_phrase(): + """Containment is resolved by the longest match. An exact tie has no owner.""" + module = load_lint() + lists = list(module.PHRASE_LISTS.items()) + for name, phrases in lists: + assert len(set(phrases)) == len(phrases), f"{name} repeats a phrase" + for index, (left_name, left) in enumerate(lists): + for right_name, right in lists[index + 1 :]: + shared = set(left) & set(right) + assert not shared, f"{left_name} and {right_name} both claim {sorted(shared)}" + + +def test_every_phrase_scores_its_own_category_exactly_once(): + """The companion to the list check: two categories may not claim one span. + + `marks a pivotal moment` holds `pivotal`, `underscores our ongoing + commitment` holds `commitment to`, and `it should be noted` holds the STE + ban on `should`. Each of those is one tell, and the longest match owns it. + """ + module = load_lint() + owned = set(module.PHRASE_LISTS) + wrong = [] + for name, phrases in module.PHRASE_LISTS.items(): + for phrase in phrases: + report = module.lint(f"The tool {phrase} the file.", register="audit") + scored = { + category: count + for category, count in report["violations"].items() + if count and category in owned + } + if scored != {name: 1}: + wrong.append((name, phrase, scored)) + + assert wrong == [] + + +def test_an_em_dash_scores_and_a_range_and_a_posix_separator_do_not(): + module = load_lint() + + report = module.lint( + "Pass the --json flag. The build failed -- the cache was stale. Retry -- once." + ) + assert report["violations"]["em_dash"] == 1 + assert report["total"] == 1 + + assert module.lint("The build failed — the cache was stale.")["violations"]["em_dash"] == 0 + # An en dash is the correct character for a range, and the spaced double + # hyphen after a command is POSIX end-of-options, not punctuation. + assert module.lint("The window is 10–20 seconds.")["violations"]["em_dash"] == 0 + assert ( + module.lint("Supported on OCP 5.1–5.6 and in layers L1–L4b.")["violations"]["em_dash"] == 0 + ) + assert ( + module.lint("Run npm test -- --watch and git log -- src/ now.")["violations"]["em_dash"] + == 0 + ) + # A free-standing en dash is still a dash doing a period's work. + assert module.lint("The build failed – the cache was stale.")["violations"]["em_dash"] == 0 + + +def test_the_not_just_parallelism_stops_at_a_paragraph_break(): + module = load_lint() + + joined = module.lint("It's not just a parser, it's a platform.") + split = module.lint("It's not just a parser.\n\nThe operator restarts the pod. It's ready.") + + assert joined["violations"]["negative_parallelism"] == 1 + assert split["violations"]["negative_parallelism"] == 0 + + +def test_stdin_decodes_utf8_no_matter_the_platform_default(tmp_path): + """A piped em dash and the same em dash in a file must score the same.""" + text = "The build failed — the cache was stale. Restart the pod — twice." + draft = tmp_path / "draft.md" + draft.write_text(text, encoding="utf-8") + + from_file = json.loads(run_lint("--json", str(draft)).stdout) + piped = run_lint_bytes("--json", stdin=text.encode("utf-8")) + from_stdin = json.loads(piped.stdout.decode("utf-8")) + + assert from_file["violations"]["em_dash"] == 1 + assert from_stdin["violations"]["em_dash"] == 1 + assert from_stdin["words"] == from_file["words"] + assert "file" not in from_stdin + + +# -------------------------------------------------------------------------- +# Quote safety +# -------------------------------------------------------------------------- + + +def test_yaml_frontmatter_is_not_prose(): + module = load_lint() + body = "The linter reads the file.\n" + with_frontmatter = ( + "---\n" + "name: prose-editing\n" + "description: A seamless and robust linter that will leverage\n" + " cutting-edge tooling to supercharge your docs.\n" + "---\n\n" + body + ) + + framed = module.lint(with_frontmatter) + bare = module.lint(body) + + assert framed["words"] == bare["words"] == 5 + assert framed["total"] == 0 + assert framed["violations"]["ai_vocabulary"] == 0 + + +def test_table_cells_are_first_party_prose_by_default(): + report = load_lint().lint( + "The glossary lists replacements.\n\n" + "| Avoid | Use |\n" + "|---|---|\n" + "| spin up / spun up | start |\n" + "| leverage | use |\n" + ) + + assert report["words"] == 13 + assert report["total"] == 3 + assert report["violations"]["phrasal_verb"] == 2 + assert report["violations"]["ai_vocabulary"] == 1 + + +def test_link_targets_are_dropped_and_link_text_is_kept(): + report = load_lint().lint( + "Read the flavored rules first.\n\n" + "See [references/flavored.md](references/flavored.md) and " + "[the strict list](references/strict.md).\n" + ) + + assert report["words"] == 12 + assert report["total"] == 0 + assert report["markers"]["noun_train"] == 0 + + +def test_blockquotes_are_prose_by_default_and_code_is_not_scored(): + module = load_lint() + + quoted = module.lint( + "The rule is simple.\n\n" + "> This seamless platform will leverage cutting-edge tooling.\n" + "> I hope this helps!\n" + ) + fenced = module.lint( + "The parser reads the file.\n\n" + "```\nThis seamless platform will leverage cutting-edge tooling — supercharge.\n```\n\n" + "Call `supercharge` to continue.\n" + ) + + assert quoted["words"] == 15 + assert quoted["total"] == 4 + assert ( + module.lint( + "> This seamless platform will leverage cutting-edge tooling.\n", + quote_safe=True, + )["total"] + == 0 + ) + assert fenced["words"] == 8 + assert fenced["total"] == 0 + + +def test_quote_safe_lets_a_glossary_name_the_words_it_forbids(): + module = load_lint() + + scored = module.lint(GLOSSARY) + safe = module.lint(GLOSSARY, quote_safe=True) + + assert scored["words"] == GLOSSARY_WORDS + assert safe["words"] == 12 + assert scored["violations"]["ai_vocabulary"] == 5 + assert scored["violations"]["phrasal_verb"] == 1 + assert scored["violations"]["verbose_word"] == 1 + assert scored["total"] == GLOSSARY_TOTAL + + assert safe["quote_safe"] is True + assert safe["violations"]["ai_vocabulary"] == 0 + assert safe["violations"]["phrasal_verb"] == 0 + assert safe["violations"]["verbose_word"] == 0 + assert "ai_vocabulary" not in safe["samples"] + # Structural checks stay on: the sentence about the words is still prose. + assert safe["violations"]["passive_voice"] == 1 + assert safe["total"] == GLOSSARY_SAFE_TOTAL + + first_party = module.lint( + "We leverage parsing, caching, and routing for the release.", quote_safe=True + ) + assert first_party["violations"]["ai_vocabulary"] == 1 + + no_punctuation = module.lint("We leverage parsing, caching, and routing", quote_safe=True) + table = module.lint("| mode | We leverage parsing, caching, and routing |", quote_safe=True) + cue = module.lint("Do not use leverage in first-party prose.", quote_safe=True) + assert no_punctuation["violations"]["ai_vocabulary"] == 1 + assert table["violations"]["ai_vocabulary"] == 1 + assert cue["violations"]["ai_vocabulary"] == 1 + + +# -------------------------------------------------------------------------- +# Registers and layers +# -------------------------------------------------------------------------- + + +def test_strict_adds_the_ste_word_set_without_an_implicit_threshold(): + module = load_lint() + text = "You should follow the runbook using the listed steps." + + flavored = module.lint(text) + strict = module.lint(text, register="strict") + + assert "strict_banned_word" not in flavored["violations"] + assert flavored["total"] == 0 + assert strict["violations"]["strict_banned_word"] == 3 + assert "fail_over" not in strict + assert "over_fail_over" not in strict + assert strict["total"] == 3 + + +def test_strict_matches_may_case_sensitively_so_the_month_stays_clean(): + report = load_lint().lint("May is a month. The pod may restart.", register="strict") + + assert report["violations"]["strict_banned_word"] == 1 + assert report["samples"]["strict_banned_word"] == ["may"] + + +def test_each_register_scores_its_own_layers(): + module = load_lint() + + flavored = module.lint(PLAIN) + voiced = module.lint(PLAIN, register="voiced") + audit = module.lint(PLAIN, register="audit") + + assert set(module.MECHANICAL) <= set(flavored["violations"]) + assert set(module.COMPRESSION) - {"strict_banned_word"} <= set(flavored["violations"]) + assert set(module.VOICE).isdisjoint(flavored["violations"]) + + assert set(module.VOICE) <= set(voiced["violations"]) + assert set(module.COMPRESSION).isdisjoint(voiced["violations"]) + assert set(audit["violations"]) == ( + set(module.MECHANICAL) | set(module.COMPRESSION) | set(module.VOICE) + ) + assert "fail_over" not in audit + assert "over_fail_over" not in audit + + +def test_audit_reports_the_strict_word_set_because_it_cannot_know_the_document_type(): + """A read-only pass over a runbook must still surface the procedure word set.""" + module = load_lint() + runbook = "You should follow the runbook using the listed steps." + + audit = module.lint(runbook, register="audit") + flavored = module.lint(runbook, register="flavored") + + assert audit["violations"]["strict_banned_word"] >= 3 + assert "strict_banned_word" not in flavored["violations"] + assert "fail_over" not in audit, "the library reports; only the CLI can gate" + + +def test_the_voice_layer_scores_drama_boldface_conclusions_and_inflation(): + report = load_lint().lint(VOICED, register="voiced") + violations = report["violations"] + + assert report["words"] == VOICED_WORDS + assert violations["staccato_drama"] == 1 + assert violations["boldface_overuse"] == 2 + assert violations["generic_conclusion"] == 2 + assert violations["significance_inflation"] == 1 + assert report["total"] == VOICED_TOTAL + assert report["total_per100w"] == VOICED_PER100W + assert report["by_layer"]["voice"] == VOICED_VOICE_LAYER + assert report["by_layer"]["mechanical"] == VOICED_TOTAL - VOICED_VOICE_LAYER + assert "over_fail_over" not in report + + +def test_a_send_off_and_an_inflated_claim_score_in_every_register(): + """Both moved out of the voice layer: a README ends that way too.""" + module = load_lint() + text = "In conclusion, the release marks a turning point for the platform.\n" + + for register in ("strict", "flavored", "voiced", "audit"): + report = module.lint(text, register=register) + assert report["violations"]["generic_conclusion"] == 1, register + assert report["violations"]["significance_inflation"] == 1, register + assert report["by_layer"]["mechanical"] >= 2, register + + # Short steps remain voice-specific. Repeated decorative boldface applies + # everywhere, while one defined term stays below its cluster threshold. + assert set(module.VOICE) == {"staccato_drama"} + assert { + "boldface_overuse", + "generic_conclusion", + "significance_inflation", + } <= set(module.MECHANICAL) + assert module.lint("The **parser** reads the file.")["violations"]["boldface_overuse"] == 0 + + +def test_markers_are_reported_and_never_added_to_the_total(): + report = load_lint().lint( + "The team shipped the parser, the linter, and the docs.\n\n" + "The release adds caching, ensuring the pod restarts cleanly.\n" + ) + + assert report["markers"]["rule_of_three"] == 1 + assert report["markers"]["noun_train"] == 1 + assert "noun_train" not in report["violations"] + assert "rule_of_three" not in report["violations"] + assert report["violations"]["ing_analysis"] == 1 + assert report["total"] == 1 + + +# -------------------------------------------------------------------------- +# Precision +# -------------------------------------------------------------------------- + + +def test_title_case_headings_need_a_capitalized_function_word(): + report = load_lint().lint( + "# Install the Red Hat Developer Hub operator\n\n" + "## Strategic Negotiations And Global Partnerships\n\n" + "### Red Hat Developer Hub\n\n" + "#### Quote safety and link targets\n" + ) + + assert report["violations"]["title_case_heading"] == 1 + assert report["samples"]["title_case_heading"] == [ + "Strategic Negotiations And Global Partnerships" + ] + + +def test_a_product_name_in_a_heading_is_not_title_case(): + """No list of proper nouns can hold a product namespace, so none is kept. + + Title Case capitalizes the function words a name never does, and that is + the only evidence this check now accepts. + """ + module = load_lint() + products = module.lint( + "## Amazon Elastic Kubernetes Service\n\n" + "## Red Hat Advanced Cluster Security\n\n" + "## Configure Keycloak Identity Brokering\n\n" + "## Azure Front Door Standard\n" + ) + titled = module.lint("## Installing Dynamic Plugins On OpenShift\n") + + assert products["violations"]["title_case_heading"] == 0 + assert titled["violations"]["title_case_heading"] == 1 + assert not hasattr(module, "PROPER_NOUNS") + + +def test_false_ranges_ignore_real_numbers_and_real_conversions(): + report = load_lint().lint( + "The book takes us from the singularity of the Big Bang to the grand " + "cosmic web.\n\n" + "Upgrade from 1.9 to 1.10 first.\n\n" + "Convert the file from the old schema to the new schema.\n\n" + "The job copies artifacts from the build directory to the release bucket.\n" + ) + + assert report["violations"]["false_range"] == 1 + assert report["samples"]["false_range"] == [ + "from the singularity of the Big Bang to the grand cosmic web" + ] + + +def test_emoji_covers_pictographs_and_leaves_punctuation_alone(): + module = load_lint() + + decorated = module.lint("🚀 The build shipped. ✅ Tests pass. “done” and “ship”.\n") + punctuation = module.lint("The arrow → and the ellipsis … are not emoji.") + + assert decorated["violations"]["emoji"] == 2 + assert decorated["violations"]["curly_quote"] == 2 + assert punctuation["violations"]["emoji"] == 0 + assert punctuation["violations"]["curly_quote"] == 0 + + +def test_inline_header_list_needs_the_value_to_restate_the_label(): + """The tell is padding, so the value decides. A label carrying a fact is good structure.""" + module = load_lint() + + padded = module.lint( + "- **User Experience:** The user experience has been significantly improved.\n" + "- **Performance:** Performance has been enhanced through optimization.\n" + ) + definition_list = module.lint("- **Milestone:** 2026-03-01\n- **Owner:** the release team\n") + label_with_news = module.lint("- **RBAC:** Operators can now scope plugins per team.\n") + + assert padded["violations"]["inline_header_list"] == 2 + assert definition_list["violations"]["inline_header_list"] == 0 + assert label_with_news["violations"]["inline_header_list"] == 0 + + +def test_rhetorical_openers_need_the_theatrical_pause(): + module = load_lint() + + opener = module.lint("Is it worth it? Honestly? It depends on the cache.") + plain_look = module.lint("Look at the logs before you restart the pod.") + + assert opener["violations"]["rhetorical_opener"] == 1 + assert plain_look["violations"]["rhetorical_opener"] == 0 + + +# -------------------------------------------------------------------------- +# CLI +# -------------------------------------------------------------------------- + + +def test_json_cli_reports_the_file_and_fail_over_exits_one(tmp_path): + sloppy = tmp_path / "draft.md" + sloppy.write_text(SLOPPY, encoding="utf-8") + plain = tmp_path / "plain.md" + plain.write_text(PLAIN, encoding="utf-8") + + scored = run_lint("--json", str(sloppy)) + report = json.loads(scored.stdout) + assert scored.returncode == 0 + assert report["file"] == str(sloppy) + assert report["total_per100w"] == SLOPPY_PER100W + assert "fail_over" not in report + assert "over_fail_over" not in report + + failed = run_lint("--json", "--fail-over", "2.5", str(sloppy)) + passed = run_lint("--json", "--fail-over", "2.5", str(plain)) + assert failed.returncode == 1 + assert json.loads(failed.stdout)["fail_over"] == 2.5 + assert json.loads(failed.stdout)["over_fail_over"] is True + assert passed.returncode == 0 + assert json.loads(passed.stdout)["fail_over"] == 2.5 + assert json.loads(passed.stdout)["over_fail_over"] is False + + both = json.loads(run_lint("--json", str(sloppy), str(plain)).stdout) + assert [Path(entry["file"]).name for entry in both] == ["draft.md", "plain.md"] + + +@pytest.mark.parametrize("threshold", ["nan", "inf", "-1"]) +def test_fail_over_rejects_nonfinite_and_negative_thresholds(threshold): + result = run_lint("--json", "--fail-over", threshold, stdin=SLOPPY) + + assert result.returncode == 2 + assert result.stdout == "" + assert "finite non-negative number" in result.stderr + + +def test_the_plain_text_line_reports_the_score_and_still_fails_over(tmp_path): + sloppy = tmp_path / "draft.md" + sloppy.write_text(SLOPPY, encoding="utf-8") + plain = tmp_path / "plain.md" + plain.write_text(PLAIN, encoding="utf-8") + + failed = run_lint("--fail-over", "2.5", str(sloppy)) + assert failed.returncode == 1 + assert failed.stdout.startswith("draft.md") + assert "register=flavored" in failed.stdout + assert f"per100w={SLOPPY_PER100W:7.2f}" in failed.stdout + assert failed.stdout.rstrip().endswith("over") + assert not failed.stdout.lstrip().startswith("{") + + passed = run_lint("--fail-over", "2.5", str(plain)) + assert passed.returncode == 0 + assert passed.stdout.rstrip().endswith("ok") + + ungated = run_lint(str(sloppy)) + assert ungated.returncode == 0 + assert ungated.stdout.rstrip().endswith(f"per100w={SLOPPY_PER100W:7.2f}") + + +def test_the_deprecated_strict_flag_still_selects_the_strict_register(tmp_path): + runbook = tmp_path / "runbook.md" + runbook.write_text("You should follow the runbook using the listed steps.", encoding="utf-8") + + legacy = json.loads(run_lint("--json", "--strict", str(runbook)).stdout) + named = json.loads(run_lint("--json", "--register", "strict", str(runbook)).stdout) + + assert legacy["register"] == "strict" + assert legacy["violations"] == named["violations"] + + +def test_a_baseline_adds_a_delta(tmp_path): + draft = tmp_path / "draft.md" + draft.write_text(SLOPPY, encoding="utf-8") + baseline = tmp_path / "before.json" + before = json.loads(run_lint("--json", str(draft)).stdout) + baseline.write_text(json.dumps({**before, "total_per100w": 40.0}), encoding="utf-8") + + improved = json.loads(run_lint("--json", "--baseline", str(baseline), str(draft)).stdout) + assert improved["delta"] == { + "before": 40.0, + "after": SLOPPY_PER100W, + "improved": True, + } + + baseline.write_text(json.dumps({**before, "total_per100w": 1.0}), encoding="utf-8") + worse = json.loads(run_lint("--json", "--baseline", str(baseline), str(draft)).stdout) + assert worse["delta"] == {"before": 1.0, "after": SLOPPY_PER100W, "improved": False} + + baseline.write_text(json.dumps({**before, "total_per100w": SLOPPY_PER100W}), encoding="utf-8") + equal = json.loads(run_lint("--json", "--baseline", str(baseline), str(draft)).stdout) + assert equal["delta"] == { + "before": SLOPPY_PER100W, + "after": SLOPPY_PER100W, + "improved": False, + } + + +def test_named_baselines_use_only_the_python_39_zip_interface(tmp_path): + draft = tmp_path / "draft.md" + baseline = tmp_path / "before.json" + shim = tmp_path / "python39" + shim.mkdir() + draft.write_text(SLOPPY, encoding="utf-8") + baseline.write_text(run_lint("--json", str(draft)).stdout, encoding="utf-8") + draft.write_text(PLAIN, encoding="utf-8") + shim.joinpath("sitecustomize.py").write_text( + "import builtins\n" + "_zip = builtins.zip\n" + "builtins.zip = lambda *iterables: _zip(*iterables)\n", + encoding="utf-8", + ) + + result = run_lint( + "--json", + "--baseline", + str(baseline), + str(draft), + env={"PYTHONPATH": str(shim)}, + ) + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["delta"]["improved"] is True + + +def test_a_baseline_for_another_file_is_a_hard_mismatch(tmp_path): + """The delta paired on position, so an unrelated baseline invented one. + + Linting an unedited file against somebody else's baseline reported + `before 42.86, after 0.0, improved true` for an edit that never happened. + """ + draft = tmp_path / "draft.md" + draft.write_text(PLAIN, encoding="utf-8") + baseline = tmp_path / "before.json" + before = json.loads(run_lint("--json", str(draft)).stdout) + baseline.write_text( + json.dumps({**before, "file_identity": str((tmp_path / "other.md").resolve())}), + encoding="utf-8", + ) + + result = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert result.returncode == 2 + assert "baseline mismatch" in result.stderr + + # A named current file never pairs with an unnamed baseline report either. + anonymous = { + key: value for key, value in before.items() if key not in {"file", "file_identity"} + } + baseline.write_text(json.dumps(anonymous), encoding="utf-8") + unnamed = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert unnamed.returncode == 2 + assert "baseline mismatch" in unnamed.stderr + + +def test_a_single_unnamed_baseline_pairs_with_a_single_unnamed_run(tmp_path): + baseline = tmp_path / "before.json" + before = json.loads(run_lint("--json", stdin=SLOPPY).stdout) + baseline.write_text(json.dumps({**before, "total_per100w": 40.0}), encoding="utf-8") + + piped = run_lint("--json", "--baseline", str(baseline), stdin=SLOPPY) + assert json.loads(piped.stdout)["delta"] == { + "before": 40.0, + "after": SLOPPY_PER100W, + "improved": True, + } + + baseline.write_text( + json.dumps( + [ + {**before, "total_per100w": 40.0}, + {**before, "total_per100w": 3.0}, + ] + ), + encoding="utf-8", + ) + ambiguous = run_lint("--json", "--baseline", str(baseline), stdin=SLOPPY) + assert json.loads(ambiguous.stdout)["delta"] is None + assert ambiguous.returncode == 2 + assert "baseline ambiguous" in ambiguous.stderr + + +def test_an_unreadable_path_is_reported_and_the_run_continues(tmp_path): + plain = tmp_path / "plain.md" + plain.write_text(PLAIN, encoding="utf-8") + binary = tmp_path / "logo.png" + binary.write_bytes(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00") + missing = tmp_path / "gone.md" + directory = tmp_path / "docs" + directory.mkdir() + + result = run_lint("--json", str(missing), str(binary), str(directory), str(plain)) + reports = json.loads(result.stdout) + + assert result.returncode == 2 + assert [Path(entry["file"]).name for entry in reports] == [ + "gone.md", + "logo.png", + "docs", + "plain.md", + ] + assert all("error" in entry for entry in reports[:3]) + # The good file still carries its whole report. + assert reports[3]["total_per100w"] == 0.0 + assert "error" not in reports[3] + + lines = run_lint(str(missing), str(plain)) + assert lines.returncode == 2 + assert "error=" in lines.stdout + assert lines.stdout.rstrip().endswith("per100w= 0.00") + + +def test_a_file_that_is_not_utf8_is_scored_with_replacement(tmp_path): + draft = tmp_path / "latin1.md" + draft.write_bytes("The café build failed.".encode("latin-1")) + + result = run_lint("--json", str(draft)) + report = json.loads(result.stdout) + + assert result.returncode == 0 + assert report["words"] == 4 + assert report["total"] == 0 + + +def test_a_broken_baseline_is_reported_without_losing_the_reports(tmp_path): + draft = tmp_path / "draft.md" + draft.write_text(SLOPPY, encoding="utf-8") + broken = tmp_path / "before.json" + broken.write_text("{not json", encoding="utf-8") + + malformed = run_lint("--json", "--baseline", str(broken), str(draft)) + report = json.loads(malformed.stdout) + assert malformed.returncode == 2 + assert "baseline unreadable" in malformed.stderr + assert report["total_per100w"] == SLOPPY_PER100W + assert report["delta"] is None + + missing = run_lint("--json", "--baseline", str(tmp_path / "gone.json"), str(draft)) + assert missing.returncode == 2 + assert "baseline unreadable" in missing.stderr + assert json.loads(missing.stdout)["total_per100w"] == SLOPPY_PER100W + + +def test_output_survives_a_console_that_is_not_utf8(tmp_path): + """A report line carries whatever the file name carries, so stdout is UTF-8. + + `json.dump` escapes its own non-ASCII, but the plain report line prints the + file name as it is, and an accented name on an ASCII console kills the run + on the write rather than on anything about the prose. + """ + draft = tmp_path / "café.md" + draft.write_text("The build failed — the cache was stale. Retry — once.\n", encoding="utf-8") + ascii_console = {**os.environ, "PYTHONIOENCODING": "ascii"} + + lines = subprocess.run( + [sys.executable, str(SCRIPT), str(draft)], + capture_output=True, + check=False, + env=ascii_console, + ) + scored = subprocess.run( + [sys.executable, str(SCRIPT), "--json", str(draft)], + capture_output=True, + check=False, + env=ascii_console, + ) + + assert lines.returncode == 0, lines.stderr.decode("utf-8", "replace") + assert lines.stdout.decode("utf-8").startswith("café.md") + assert json.loads(scored.stdout.decode("utf-8"))["samples"]["em_dash"] == ["—"] + + +def test_quote_safe_reaches_the_cli(tmp_path): + glossary = tmp_path / "glossary.md" + glossary.write_text(GLOSSARY, encoding="utf-8") + + scored = json.loads(run_lint("--json", str(glossary)).stdout) + safe = json.loads(run_lint("--json", "--quote-safe", str(glossary)).stdout) + + assert scored["total"] == GLOSSARY_TOTAL + assert safe["total"] == GLOSSARY_SAFE_TOTAL + assert safe["quote_safe"] is True + + +def test_help_lists_every_flag(): + result = run_lint("--help") + help_text = result.stdout + + assert result.returncode == 0 + for flag in ("--register", "--strict", "--quote-safe", "--json", "--fail-over", "--baseline"): + assert flag in help_text + assert "python3" in help_text + + +# -------------------------------------------------------------------------- +# Hard wrapping +# -------------------------------------------------------------------------- + +# One sentence of 27 words and one of 17, wrapped at eighty columns the way +# every markdown file in this repository is. +WRAPPED = ( + "The operator reconciles the Deployment whenever the ConfigMap changes,\n" + "and it restarts the backend container so that the dynamic plugins load in\n" + "the order the catalog lists them, which the marketplace plugin then shows.\n" + "The marketplace plugin reads that catalog and shows which version of each\n" + "plugin the cluster currently runs today.\n" +) + + +def test_a_hard_wrapped_paragraph_reads_as_whole_sentences(): + """Splitting on newlines hid the long sentences and invented short ones.""" + module = load_lint() + + wrapped = module.lint(WRAPPED, register="audit") + joined = module.lint(" ".join(WRAPPED.split()), register="audit") + + assert wrapped["sentences"] == joined["sentences"] == 2 + assert wrapped["longest_sentence_words"] == joined["longest_sentence_words"] == 34 + assert wrapped["violations"]["long_sentence"] == joined["violations"]["long_sentence"] == 1 + + +def test_a_wrapped_paragraph_is_not_a_long_paragraph(): + """Seven wrapped lines are two sentences, not seven, so the cap of six holds.""" + module = load_lint() + seven_lines = ( + "The overlay reads the plugin version from the package manifest and\n" + "writes it into the export overlay so that the release pipeline can\n" + "build the dynamic plugin without any manual edit to the repository at\n" + "all. The catalog then records the version it built together with the\n" + "digest of the image that carries it, which is what the marketplace\n" + "plugin shows to an administrator who is deciding whether an upgrade is\n" + "worth taking today.\n" + ) + + report = module.lint(seven_lines, register="audit") + + assert report["sentences"] == 2 + assert report["violations"]["long_paragraph"] == 0 + + +def test_a_list_item_is_its_own_unit(): + module = load_lint() + items = ( + "- The operator reconciles the Deployment whenever the ConfigMap\n" + " changes and restarts the backend.\n" + "- The marketplace plugin reads the catalog.\n" + ) + + assert module.logical_lines(items) == [ + "- The operator reconciles the Deployment whenever the ConfigMap changes" + " and restarts the backend.", + "- The marketplace plugin reads the catalog.", + ] + assert module.lint(items)["sentences"] == 2 + + +def test_a_seven_sentence_paragraph_still_scores_long_paragraph(): + module = load_lint() + block = " ".join(f"The pod restarts {index} times." for index in range(7)) + + assert module.lint(block)["violations"]["long_paragraph"] == 1 + assert module.lint(" ".join(block.split(". ")[:6]))["violations"]["long_paragraph"] == 0 + + +# -------------------------------------------------------------------------- +# Precision +# -------------------------------------------------------------------------- + + +def test_a_possessive_is_not_a_contraction(): + """`s` sat in the general branch, so every possessive scored a contraction.""" + module = load_lint() + + possessive = module.lint( + "The skill's reference, the plugin's version, and the user's token stay." + ) + genuine = module.lint("It's ready. Don't stop. We've shipped. He's late. I'm done.") + + assert possessive["violations"]["contraction"] == 0 + assert genuine["violations"]["contraction"] == 5 + + +def test_negative_parallelism_needs_the_antithesis_shape(): + module = load_lint() + tell = module.lint("The overlay reads the version from the plugin manifest, no manual bumps.") + conditional = module.lint("If validation fails, no installation operation runs.") + one_word = module.lint("The script uses the standard library, no lockfile.") + enumeration = module.lint("Skills compose by stable name, no imports, no layout probing.") + list_item = module.lint("- Draft in a triple-backtick block, no placeholders left unfilled\n") + + assert tell["violations"]["negative_parallelism"] == 1 + assert conditional["violations"]["negative_parallelism"] == 0 + assert one_word["violations"]["negative_parallelism"] == 0 + assert enumeration["violations"]["negative_parallelism"] == 0 + assert list_item["violations"]["negative_parallelism"] == 0 + + +def test_nominalization_needs_a_noun_that_names_an_action(): + module = load_lint() + action = module.lint("The installation of the plugin precedes the migration of the data.") + things = module.lint( + "An instance of the class, the distance of the run, and the sentence of the paragraph.", + register="audit", + ) + + assert action["violations"]["nominalization"] == 2 + assert things["violations"]["nominalization"] == 0 + + +def test_passive_voice_leaves_predicate_adjectives_alone(): + module = load_lint() + report = module.lint( + "The field is indeed correct. The value is missing. The token is required.", + register="audit", + ) + real = module.lint("The chart was installed by the operator. The pod is restarted.") + + assert report["violations"]["passive_voice"] == 0 + assert report["violations"]["ing_main_verb"] == 0 + assert real["violations"]["passive_voice"] == 2 + + +def test_one_verb_phrase_and_one_apostrophe_score_once(): + module = load_lint() + perfect = module.lint("The chart has been deployed to the cluster.", register="audit") + curly = module.lint("It doesn’t restart. The operator’s log is short.", register="audit") + utilization = module.lint("The utilization of the cache is high.", register="audit") + + assert perfect["violations"]["complex_tense"] == 1 + assert perfect["violations"]["passive_voice"] == 0 + + # The contraction owns its apostrophe. One possessive apostrophe is only a + # singleton typography marker, so it does not contribute to the score. + assert curly["violations"]["contraction"] == 1 + assert curly["violations"]["curly_quote"] == 0 + + assert utilization["violations"]["ai_vocabulary"] == 1 + assert utilization["violations"]["nominalization"] == 0 + + +def test_matrix_marks_and_arrows_are_not_emoji(): + module = load_lint() + matrix = module.lint("Supported ✓ and unsupported ✗ and the pointer ⬅ stay.") + pictographs = module.lint("🚀 The build shipped. ✅ Tests pass.") + + assert matrix["violations"]["emoji"] == 0 + assert pictographs["violations"]["emoji"] == 2 + + +def test_a_hyphenated_word_does_not_trip_a_banned_word(): + module = load_lint() + + followup = module.lint("Follow-up items stay open.", register="strict") + verb = module.lint("Follow the runbook.", register="strict") + + assert followup["violations"]["strict_banned_word"] == 0 + assert verb["violations"]["strict_banned_word"] == 1 + + +def test_provided_as_a_conjunction_is_not_a_verbose_verb(): + module = load_lint() + + conjunction = module.lint("Provided the token is valid, the request succeeds.") + also_conjunction = module.lint("The call succeeds provided that the token is valid.") + verb = module.lint("The operator provided the token.") + + assert conjunction["violations"]["verbose_word"] == 0 + assert also_conjunction["violations"]["verbose_word"] == 0 + assert verb["violations"]["verbose_word"] == 1 + + +def test_false_ranges_stay_linear_on_a_large_document(): + """The check re-split the whole prefix per candidate, so 870KB took 17s.""" + module = load_lint() + document = "The catalog covers everything from user onboarding to cost reporting. " * 16000 + + started = time.monotonic() + count, _ = module.false_ranges(document) + elapsed = time.monotonic() - started + + assert count == 16000 + assert elapsed < 3.0, f"false_ranges took {elapsed:.1f}s on {len(document)} characters" + + +def test_passive_span_ownership_stays_linear_on_a_large_document(): + module = load_lint() + document = "The chart has been deployed. The pod was restarted by the operator. " * 8000 + + started = time.monotonic() + count, _ = module.passive_voices(document) + elapsed = time.monotonic() - started + + assert count == 8000 + assert elapsed < 3.0, f"passive_voices took {elapsed:.1f}s on {len(document)} characters" + + +def test_heading_restatement_scan_stays_linear_on_a_heading_heavy_document(): + module = load_lint() + document = "\n".join(f"## Heading {index}" for index in range(40_000)) + + started = time.monotonic() + count, _ = module.heading_restatements(document) + elapsed = time.monotonic() - started + + assert count == 0 + assert elapsed < 0.8, f"heading_restatements took {elapsed:.1f}s on 40,000 headings" + + +def test_style_unit_scanning_stays_linear_on_a_large_list(): + module = load_lint() + document = "\n".join(f"- Ordinary list item {index}." for index in range(10_000)) + + started = time.monotonic() + report = module.lint(document) + elapsed = time.monotonic() - started + + assert report["markers"]["singleton_bold"] == 0 + assert report["markers"]["singleton_em_dash"] == 0 + assert elapsed < 3.0, f"lint took {elapsed:.1f}s on 10,000 list items" + + +def test_gfm_table_unit_scanning_stays_linear_without_leading_pipes(): + module = load_lint() + middle = "\n".join(f"{index} | Ordinary table row." for index in range(10_000)) + document = ( + "ID | Description\n" + "--- | ---\n" + "first | This row uses — punctuation.\n" + f"{middle}\n" + "last | This row uses — punctuation.\n" + ) + + started = time.monotonic() + report = module.lint(document) + elapsed = time.monotonic() - started + + assert report["violations"]["em_dash"] == 0 + assert report["markers"]["singleton_em_dash"] == 2 + assert elapsed < 3.0, f"lint took {elapsed:.1f}s on 10,000 table rows" + + +# -------------------------------------------------------------------------- +# Structure that must not swallow the document +# -------------------------------------------------------------------------- + + +def test_a_leading_thematic_break_is_not_frontmatter(): + """A document may open on a horizontal rule and close a section with another. + + Reading the first one as frontmatter blanked everything down to the second + and turned a high-scoring draft into a clean one. + """ + module = load_lint() + text = ( + "---\n\n" + "This seamless platform will leverage cutting-edge tooling to supercharge docs.\n\n" + "---\n\n" + "The parser reads the file.\n" + ) + + report = module.lint(text) + + assert report["words"] == 15 + assert report["violations"]["ai_vocabulary"] == 2 + assert report["violations"]["promotional"] == 2 + assert "over_fail_over" not in report + + frontmatter = module.lint("---\nname: prose-editing\n---\n\nThe parser reads the file.\n") + assert frontmatter["words"] == 5 + + +def test_an_unclosed_code_fence_does_not_delete_the_rest_of_the_document(): + module = load_lint() + unclosed = "```\nThis seamless platform will leverage cutting-edge tooling.\n" + closed = "```\nThis seamless platform will leverage cutting-edge tooling.\n```\n" + + assert module.lint(unclosed)["words"] == 7 + assert module.lint(unclosed)["total"] == 3 + assert module.lint(closed)["total"] == 0 + + +def test_a_bare_url_is_not_prose(): + module = load_lint() + + report = module.lint("See https://example.com/leverage for the robust story.") + + assert report["words"] == 5 + assert report["violations"]["ai_vocabulary"] == 1 + + +def test_a_link_reference_definition_is_not_prose(): + module = load_lint() + + report = module.lint("The parser reads the file.\n\n[robust]: https://example.com/x\n") + + assert report["words"] == 5 + assert report["total"] == 0 + + +def test_samples_stop_at_six_hits(): + module = load_lint() + text = ( + "It will leverage a seamless and robust landscape of intricate, nuanced," + " multifaceted realms and myriad tapestry." + ) + + report = module.lint(text) + + # "realms" is not "realm", so nine of the ten words score. + assert report["violations"]["ai_vocabulary"] == 9 + assert len(report["samples"]["ai_vocabulary"]) == 6 + assert module.SAMPLE_LIMIT == 6 + + +# -------------------------------------------------------------------------- +# The tells the reference files teach +# -------------------------------------------------------------------------- + + +def test_staccato_counts_the_run_and_not_the_sentence_length(): + module = load_lint() + documented = ( + "Then the marketplace shipped. No more editing ConfigMaps by hand. No" + " more waiting on a rebuild. No more guessing which version you had." + " The old workflow was gone." + ) + seven = "Alpha beta gamma delta epsilon zeta eta. " * 3 + eight = "Alpha beta gamma delta epsilon zeta eta theta. " * 3 + two_in_a_row = "The pod restarts. The build fails." + + assert module.lint(documented, register="voiced")["violations"]["staccato_drama"] == 1 + assert module.lint(seven, register="voiced")["violations"]["staccato_drama"] == 1 + assert module.lint(eight, register="voiced")["violations"]["staccato_drama"] == 0 + assert module.lint(two_in_a_row, register="voiced")["violations"]["staccato_drama"] == 0 + assert module.STACCATO_MAX_WORDS == 7 + assert module.STACCATO_RUN == 3 + + +def test_generic_conclusion_matches_the_shape_not_one_wording(): + module = load_lint() + + for text in ( + "The future of the platform is bright.", + "The future looks bright.", + "In conclusion, the work continues.", + "In summary, the work continues.", + "The possibilities are endless.", + "Exciting times ahead.", + "The road ahead is long.", + "## Conclusion\n\nThe release shipped.\n", + ): + assert module.lint(text)["violations"]["generic_conclusion"] >= 1, text + + +def test_significance_inflation_covers_the_inflected_forms(): + module = load_lint() + + for text in ( + "The release sets the stage for 1.11.", + "Setting the stage for 1.11, the team shipped.", + "This set the stage for the migration.", + "The release marks a pivotal moment for the platform.", + "It underscores our ongoing commitment to the community.", + "It underscores our commitment to the community.", + "The release marks a turning point.", + ): + assert module.lint(text)["violations"]["significance_inflation"] >= 1, text + + +def test_every_documented_example_scores_the_category_it_teaches(): + """The acceptance test: the reference files and the linter must agree. + + Each `### \\`category\\`` section opens with the example of the tell. When + the linter scores it zero, the skill is teaching a rule its own tool cannot + find. + """ + module = load_lint() + examples = documented_examples() + assert len(examples) >= 18 + + missed = [] + for category, example, path in examples: + report = module.lint(example, register="audit") + count = report["violations"].get(category, report["markers"].get(category)) + assert count is not None, f"{path.name} documents an unknown category {category}" + if not count: + missed.append((path.name, category, example[:60])) + + assert missed == [] + + +def test_the_documented_rewrites_score_nothing_in_their_category(): + """The other half: the reference files' own fixes have to come out clean.""" + module = load_lint() + rewrites = { + "ai_vocabulary": "The marketplace plugin reads the catalog and lists the plugins.", + "copula_avoidance": "The dynamic-plugins ConfigMap lists the enabled plugins.", + "negative_parallelism": "The operator creates the Deployment and the Route.", + "significance_inflation": "Red Hat Developer Hub 1.10 is generally available today.", + "staccato_drama": ( + "The marketplace plugin removed the manual ConfigMap edits that" + " installing a plugin used to require, and it shows the version." + ), + "boldface_overuse": "RHDH 1.10 ships the marketplace plugin and adds RBAC support.", + } + + for category, rewrite in rewrites.items(): + report = module.lint(rewrite, register="audit") + assert report["violations"][category] == 0, (category, rewrite) + + +# -------------------------------------------------------------------------- +# The modern marketing register +# -------------------------------------------------------------------------- + + +def test_every_marketing_term_scores_somewhere(): + module = load_lint() + + unscored = [ + term for term in MARKETING_TERMS if module.lint(f"The team {term} the file.")["total"] < 1 + ] + + assert unscored == [] + + +def test_an_ai_marketing_page_has_high_density_in_each_editing_register(): + """A long page must not dilute repeated tells into a deceptively low density.""" + module = load_lint() + page = ( + "In today's fast-paced world, engineering organizations find themselves" + " at a crossroads. As they navigate the complexities of an ever-evolving" + " cloud landscape, the need for a robust and scalable developer portal" + " has never been more crucial.\n\n" + "Our next-generation platform is a game changer that empowers teams to" + " streamline their workflows, elevate the developer experience, and" + " unlock the power of a seamless internal platform. By leveraging" + " cutting-edge tooling and best-in-class automation, it delivers a" + " comprehensive, holistic solution that fosters collaboration and" + " bolsters productivity.\n\n" + "Let's dive in. Teams can harness the power of dynamic plugins without a" + " single line of glue code, which underscores our ongoing commitment to" + " developer productivity and marks a pivotal moment in the evolution of" + " the platform.\n\n" + "In conclusion, the possibilities are endless, and the future of the" + " platform is bright. Exciting times ahead.\n" + ) + + flavored = module.lint(page) + voiced = module.lint(page, register="voiced") + + assert flavored["words"] > 130 + assert flavored["total_per100w"] > 10.0 + assert "over_fail_over" not in flavored + assert voiced["total_per100w"] > 10.0 + assert "over_fail_over" not in voiced + + +# -------------------------------------------------------------------------- +# Repaired public contract +# -------------------------------------------------------------------------- + + +def test_audit_is_the_only_no_edit_register(): + module = load_lint() + + report = module.lint("The file was written by the script.", register="audit") + + assert report["register"] == "audit" + assert "fail_over" not in report + assert report["violations"]["passive_voice"] == 1 + with pytest.raises(ValueError, match="unknown register"): + module.lint("The file was written by the script.", register="review") + + +def test_instruction_and_descriptive_sentences_have_different_length_caps(): + module = load_lint() + instruction = "Remove the " + " ".join(f"item{index}" for index in range(20)) + "." + description = "The parser reads " + " ".join(f"item{index}" for index in range(19)) + "." + + assert module.word_count(instruction) == module.word_count(description) == 22 + assert module.lint(instruction)["violations"]["long_sentence"] == 1 + assert module.lint(description)["violations"]["long_sentence"] == 0 + + too_long = "The parser reads " + " ".join(f"item{index}" for index in range(23)) + "." + assert module.word_count(too_long) == 26 + assert module.lint(too_long)["violations"]["long_sentence"] == 1 + + conditional = ( + "If the test fails, remove the " + " ".join(f"item{index}" for index in range(16)) + "." + ) + required = "You must remove the " + " ".join(f"item{index}" for index in range(18)) + "." + assert module.word_count(conditional) == module.word_count(required) == 22 + assert module.lint(conditional)["violations"]["long_sentence"] == 1 + assert module.lint(required)["violations"]["long_sentence"] == 1 + + adverbial = ( + "You must carefully remove the " + " ".join(f"item{index}" for index in range(16)) + "." + ) + passive = ( + "The stale resources must be carefully removed before the " + + " ".join(f"item{index}" for index in range(12)) + + "." + ) + unpunctuated_condition = ( + "If validation fails remove the " + " ".join(f"item{index}" for index in range(16)) + "." + ) + assert module.word_count(adverbial) == 21 + assert module.word_count(passive) == 21 + assert module.word_count(unpunctuated_condition) == 21 + assert module.lint(adverbial)["violations"]["long_sentence"] == 1 + assert module.lint(passive)["violations"]["long_sentence"] == 1 + assert module.lint(unpunctuated_condition)["violations"]["long_sentence"] == 1 + + +def test_functional_conditions_recognize_copulas_auxiliaries_and_adverb_runs(): + module = load_lint() + copular = ( + "When the file is missing carefully immediately delete the " + + " ".join(f"item{index}" for index in range(12)) + + "." + ) + auxiliary = ( + "If validation does not pass quickly safely remove the " + + " ".join(f"item{index}" for index in range(12)) + + "." + ) + punctuated = ( + "If validation fails, carefully immediately remove the " + + " ".join(f"item{index}" for index in range(14)) + + "." + ) + descriptive = ( + "If users remove the file the parser logs " + + " ".join(f"item{index}" for index in range(13)) + + "." + ) + + assert all(module.word_count(text) == 21 for text in (copular, auxiliary, punctuated)) + assert module.word_count(descriptive) == 21 + assert module.lint(copular)["violations"]["long_sentence"] == 1 + assert module.lint(auxiliary)["violations"]["long_sentence"] == 1 + assert module.lint(punctuated)["violations"]["long_sentence"] == 1 + assert module.lint(descriptive)["violations"]["long_sentence"] == 0 + + +@pytest.mark.parametrize("participle", ["completed", "failed", "finished", "passed", "succeeded"]) +def test_perfect_auxiliary_conditions_use_the_instruction_sentence_cap(participle): + module = load_lint() + instruction = ( + f"If validation has {participle} carefully immediately remove the " + + " ".join(f"item{index}" for index in range(13)) + + "." + ) + descriptive = ( + f"If validation has {participle} the removal the parser reports " + + " ".join(f"item{index}" for index in range(12)) + + "." + ) + + assert module.word_count(instruction) == module.word_count(descriptive) == 21 + assert module.lint(instruction)["violations"]["long_sentence"] == 1 + assert module.lint(descriptive)["violations"]["long_sentence"] == 0 + + +def test_singleton_typography_and_transition_tells_need_a_cluster_or_voice_mismatch(tmp_path): + module = load_lint() + + singleton = module.lint("However, the writer said “ship it” — once.") + assert singleton["violations"]["em_dash"] == 0 + assert singleton["violations"]["curly_quote"] == 0 + assert singleton["violations"]["transition_stack"] == 0 + assert singleton["markers"]["singleton_em_dash"] == 1 + assert singleton["markers"]["singleton_curly_quote"] == 2 + assert singleton["markers"]["singleton_transition"] == 1 + assert singleton["samples"]["singleton_em_dash"] == ["—"] + + clustered = module.lint( + "However, the writer said “ship it” — once. Moreover, she said “today” — twice." + ) + assert clustered["violations"]["em_dash"] == 1 + assert clustered["violations"]["curly_quote"] == 2 + assert clustered["violations"]["transition_stack"] == 1 + assert clustered["markers"]["singleton_em_dash"] == 0 + assert clustered["markers"]["singleton_curly_quote"] == 0 + assert clustered["markers"]["singleton_transition"] == 0 + + separated = module.lint( + "However, the writer said “ship it” — once.\n\nMoreover, she said “today” — later." + ) + assert separated["violations"]["em_dash"] == 0 + assert separated["violations"]["curly_quote"] == 0 + assert separated["violations"]["transition_stack"] == 0 + assert separated["markers"]["singleton_em_dash"] == 2 + assert separated["markers"]["singleton_curly_quote"] == 4 + assert separated["markers"]["singleton_transition"] == 2 + + sample = "However, I use “quotes” — at this rate." + matched = module.lint( + "However, the writer said “ship it” — once. Moreover, she said “today” — twice.", + register="voiced", + voice_sample=sample, + ) + assert matched["violations"]["em_dash"] == 0 + assert matched["violations"]["curly_quote"] == 0 + assert matched["violations"]["transition_stack"] == 0 + assert matched["markers"]["singleton_em_dash"] == 0 + assert matched["markers"]["singleton_curly_quote"] == 0 + assert matched["markers"]["singleton_transition"] == 0 + + unmatched = module.lint( + "However, the writer said “ship it” — once.", + register="voiced", + voice_sample="The writer uses plain punctuation.", + ) + assert unmatched["markers"]["singleton_em_dash"] == 1 + assert unmatched["markers"]["singleton_curly_quote"] == 2 + assert unmatched["markers"]["singleton_transition"] == 1 + + draft = tmp_path / "draft.md" + voice = tmp_path / "voice.md" + draft.write_text( + clustered["samples"]["em_dash"][0] and "Two — dashes — here.", encoding="utf-8" + ) + voice.write_text("One — dash.", encoding="utf-8") + cli = run_lint("--json", "--register", "voiced", "--voice-sample", str(voice), str(draft)) + assert cli.returncode == 0 + assert json.loads(cli.stdout)["voice_sample"] == str(voice) + + +def test_a_sparse_voice_sample_does_not_hide_unlimited_isolated_style_hits(): + module = load_lint() + voice = "One dash — appears here. " + " ".join(f"word{index}" for index in range(96)) + "." + target = "\n\n".join(f"Paragraph {index} uses — punctuation." for index in range(20)) + + report = module.lint(target, register="voiced", voice_sample=voice) + + assert module.word_count(voice) == 100 + assert report["words"] == 80 + assert report["violations"]["em_dash"] == 0 + assert report["markers"]["singleton_em_dash"] == 19 + + +def test_a_single_bold_span_is_a_marker_but_clustered_bold_is_only_a_violation(): + module = load_lint() + + singleton = module.lint("The **parser** reads the file.") + clustered = module.lint("The **parser** reads the **file**.") + mixed = module.lint("The **parser** reads the **file**.\n\nThe **writer** records the result.") + + assert singleton["violations"]["boldface_overuse"] == 0 + assert singleton["markers"]["singleton_bold"] == 1 + assert singleton["samples"]["singleton_bold"] == ["**parser**"] + assert clustered["violations"]["boldface_overuse"] == 1 + assert clustered["markers"]["singleton_bold"] == 0 + assert mixed["violations"]["boldface_overuse"] == 1 + assert mixed["markers"]["singleton_bold"] == 1 + assert mixed["samples"]["singleton_bold"] == ["**writer**"] + + +def test_list_items_and_table_rows_are_local_style_units(): + module = load_lint() + list_bold = module.lint( + "- The **parser** reads the **file**.\n- The **writer** records the result.\n" + ) + assert list_bold["violations"]["boldface_overuse"] == 1 + assert list_bold["markers"]["singleton_bold"] == 1 + assert list_bold["samples"]["singleton_bold"] == ["**writer**"] + + middle_items = "\n".join(f"- Ordinary item {index}." for index in range(40)) + separated_list = module.lint( + "- However, the first item uses — punctuation.\n" + f"{middle_items}\n" + "- Moreover, the last item uses — punctuation.\n" + ) + assert separated_list["violations"]["em_dash"] == 0 + assert separated_list["violations"]["transition_stack"] == 0 + assert separated_list["markers"]["singleton_em_dash"] == 2 + assert separated_list["markers"]["singleton_transition"] == 2 + + middle_rows = "\n".join(f"| {index} | Ordinary row. |" for index in range(40)) + separated_table = module.lint( + "| ID | Description |\n" + "| --- | --- |\n" + "| first | However, this row uses — punctuation. |\n" + f"{middle_rows}\n" + "| last | Moreover, this row uses — punctuation. |\n" + ) + assert separated_table["violations"]["em_dash"] == 0 + assert separated_table["violations"]["transition_stack"] == 0 + assert separated_table["markers"]["singleton_em_dash"] == 2 + assert separated_table["markers"]["singleton_transition"] == 2 + + +def test_gfm_table_rows_without_leading_pipes_are_local_prose_units(): + module = load_lint() + middle_rows = "\n".join(f"{index} | Ordinary row." for index in range(40)) + report = module.lint( + "ID | Description\n" + "--- | ---\n" + "first | However, the **parser** will **leverage** the file — once.\n" + f"{middle_rows}\n" + "last | Moreover, the **writer** records it — later.\n" + ) + + assert report["violations"]["ai_vocabulary"] == 1 + assert report["violations"]["em_dash"] == 0 + assert report["violations"]["transition_stack"] == 0 + assert report["violations"]["boldface_overuse"] == 1 + assert report["markers"]["singleton_em_dash"] == 2 + assert report["markers"]["singleton_transition"] == 2 + assert report["markers"]["singleton_bold"] == 1 + assert report["samples"]["singleton_bold"] == ["**writer**"] + + +def test_first_party_markdown_prose_is_linted_and_quote_safe_protects_quoted_material(): + module = load_lint() + text = ( + "> [!NOTE]\n" + "> The robust platform is seamless.\n\n" + "| Field | Description |\n" + "| --- | --- |\n" + "| mode | The powerful mode will leverage automation. |\n" + ) + + report = module.lint(text) + safe = module.lint(text, quote_safe=True) + + assert report["violations"]["promotional"] == 1 + assert report["violations"]["ai_vocabulary"] == 3 + # Quote-safe removes the quoted callout, not unrelated first-party table prose. + assert safe["violations"]["promotional"] == 1 + assert safe["violations"]["ai_vocabulary"] == 1 + assert safe["words"] < report["words"] + + +def test_markdown_delimiters_and_identifiers_are_protected_only_when_they_match(): + module = load_lint() + protected = ( + "````text\nThe robust platform will leverage tooling.\n````\n" + "The ``robust leverage`` identifier stays.\n" + "The robust_setting and leverageMode identifiers stay.\n" + "[robust label](https://example.com/leverage) stays visible.\n" + ) + mismatched = "````text\nThe robust platform will leverage tooling.\n```\n" + + clean = module.lint(protected) + exposed = module.lint(mismatched) + escaped = module.lint(r"The team will \`leverage\` this change.") + + assert clean["violations"]["ai_vocabulary"] == 1 # link label is prose + assert clean["violations"]["promotional"] == 0 + assert exposed["violations"]["ai_vocabulary"] == 2 + assert exposed["violations"]["promotional"] == 0 + assert escaped["violations"]["ai_vocabulary"] == 1 + + +def test_only_an_odd_backslash_run_escapes_an_inline_code_opener(): + module = load_lint() + + odd = module.lint(r"The team uses \`leverage` in prose.") + even = module.lint(r"The team uses \\`leverage` in prose.") + + assert odd["violations"]["ai_vocabulary"] == 1 + assert even["violations"]["ai_vocabulary"] == 0 + + +def test_a_backtick_in_fence_info_does_not_create_a_commonmark_code_block(): + module = load_lint() + invalid_backtick = module.lint("```bad`info\nThe robust platform will leverage tooling.\n```\n") + valid_tilde = module.lint("~~~bad~info\nThe robust platform will leverage tooling.\n~~~\n") + + assert invalid_backtick["violations"]["ai_vocabulary"] == 2 + assert valid_tilde["violations"]["ai_vocabulary"] == 0 + + +@pytest.mark.parametrize("indent", ["\t", " "]) +def test_tab_and_four_space_backticks_do_not_fence_off_first_party_prose(indent): + module = load_lint() + invalid = module.lint(f"{indent}```\nThe robust platform will leverage tooling.\n{indent}```\n") + valid = module.lint(" ```\nThe robust platform will leverage tooling.\n ```\n") + + assert invalid["violations"]["ai_vocabulary"] == 2 + assert valid["violations"]["ai_vocabulary"] == 0 + + +def test_low_confidence_humanizer_patterns_are_markers_not_guessed_violations(): + module = load_lint() + text = ( + "Her work received independent coverage from national media outlets. " + "Details are not publicly available, so she likely keeps a low profile. " + "To be clear, this is not really about documentation. " + "A tempting approach would be to restart the service, but that drops sessions.\n\n" + "## Future outlook\n\n" + "This feature was added to replace the previous implementation.\n\n" + "She opened the file. She read the file. She closed the file.\n\n" + "The report is high-quality.\n" + ) + + report = module.lint(text, register="audit") + + for category in ( + "notability_padding", + "knowledge_gap", + "unsupported_objection", + "fake_alternative", + "formulaic_section", + "previous_version_frame", + "repeated_opening", + "predicate_hyphenation", + ): + assert report["markers"][category] >= 1, category + assert category not in report["violations"] + + +def test_context_sensitive_new_vocabulary_is_marked_for_manual_review(): + module = load_lint() + report = module.lint( + "The actually valuable change quietly gated a key path. " + "Heads up, the next paragraph explains it.", + register="audit", + ) + + assert report["markers"]["watched_vocabulary"] == 5 + assert report["markers"]["casual_signposting"] == 1 + + +def test_strict_press_flags_only_a_physical_control(): + module = load_lint() + + control = module.lint("Press the red button.", register="strict") + news = module.lint("The press covered the release.", register="strict") + flavored = module.lint("Press the red button.", register="flavored") + + assert control["violations"]["strict_banned_word"] == 1 + assert news["violations"]["strict_banned_word"] == 0 + assert "strict_banned_word" not in flavored["violations"] + + +def test_american_spelling_and_condition_order_are_contextual_markers(): + module = load_lint() + report = module.lint( + "Set the colour and behaviour. Read the task log if the PipelineRun fails.", + register="strict", + ) + correct = module.lint( + "Set the color and behavior. If the PipelineRun fails, read the task log.", + register="strict", + ) + + assert report["markers"]["american_spelling"] == 2 + assert report["markers"]["condition_before_command"] == 1 + assert correct["markers"]["american_spelling"] == 0 + assert correct["markers"]["condition_before_command"] == 0 + assert {"american_spelling", "condition_before_command"} <= set(report["manual_checks"]) + voiced = module.lint("Set the colour. Read the task log if the build fails.", register="voiced") + assert voiced["markers"]["american_spelling"] == 0 + assert voiced["markers"]["condition_before_command"] == 0 + + +def test_agreeable_chatbot_tone_is_scored_and_ambiguous_copulas_and_qualifiers_are_marked(): + module = load_lint() + report = module.lint( + "Excellent point! You're absolutely right. " + "Gallery 825 represents a space for contemporary art. " + "The release marks a boundary between support tiers. " + "Freedom represents progress. The release marks maturity. " + "The release marks a shift in support. " + "The release marks a pivotal moment. " + "To be fair, it's also possible this might arguably work. " + "In some cases it may fail; this is an inference.", + register="audit", + ) + + assert report["violations"]["chatbot_residue"] == 2 + assert report["markers"]["copula_candidate"] == 4 + assert report["violations"]["significance_inflation"] == 2 + assert report["markers"]["qualifier_phrase"] == 5 + assert "represents a" in report["samples"]["copula_candidate"] + assert "represents progress" in report["samples"]["copula_candidate"] + assert "marks a shift" not in report["samples"]["copula_candidate"] + assert "to be fair" in [sample.lower() for sample in report["samples"]["qualifier_phrase"]] + + +def test_baselines_require_compatible_metadata_and_an_exact_unique_identity(tmp_path): + draft = tmp_path / "draft.md" + other = tmp_path / "other.md" + baseline = tmp_path / "baseline.json" + draft.write_text(SLOPPY, encoding="utf-8") + other.write_text(SLOPPY, encoding="utf-8") + + before = json.loads(run_lint("--json", str(draft)).stdout) + baseline.write_text(json.dumps(before), encoding="utf-8") + draft.write_text(PLAIN, encoding="utf-8") + + matched = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert matched.returncode == 0 + assert json.loads(matched.stdout)["delta"]["improved"] is True + + for field, incompatible in ( + ("score_version", before["score_version"] - 1), + ("register", "strict"), + ("quote_safe", True), + ): + changed = {**before, field: incompatible} + baseline.write_text(json.dumps(changed), encoding="utf-8") + result = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert result.returncode == 2, field + assert "baseline incompatible" in result.stderr + + mismatch = {**before, "file_identity": str(other.resolve())} + baseline.write_text(json.dumps(mismatch), encoding="utf-8") + result = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert result.returncode == 2 + assert "baseline mismatch" in result.stderr + + baseline.write_text(json.dumps([before, before]), encoding="utf-8") + ambiguous = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert ambiguous.returncode == 2 + assert "baseline ambiguous" in ambiguous.stderr + + same_identity = { + **before, + "file_identity": str(tmp_path / "unused" / ".." / "draft.md"), + } + baseline.write_text(json.dumps([before, same_identity]), encoding="utf-8") + normalized_duplicate = run_lint("--json", "--baseline", str(baseline), str(draft)) + assert normalized_duplicate.returncode == 2 + assert "baseline ambiguous" in normalized_duplicate.stderr + + +@pytest.mark.parametrize("invalid_score", [float("nan"), float("inf"), -1.0]) +def test_baseline_scores_must_be_finite_and_non_negative(tmp_path, invalid_score): + baseline = tmp_path / "baseline.json" + before = json.loads(run_lint("--json", stdin=SLOPPY).stdout) + baseline.write_text( + json.dumps({**before, "total_per100w": invalid_score}), + encoding="utf-8", + ) + + result = run_lint("--json", "--baseline", str(baseline), stdin=SLOPPY) + + assert result.returncode == 2 + assert "baseline incompatible" in result.stderr + json.loads( + result.stdout, + parse_constant=lambda value: pytest.fail(f"non-standard JSON constant {value}"), + ) + + +def test_baseline_identity_is_not_reinterpreted_from_the_current_working_directory(tmp_path): + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + (first / "draft.md").write_text(SLOPPY, encoding="utf-8") + (second / "draft.md").write_text(PLAIN, encoding="utf-8") + baseline = tmp_path / "baseline.json" + + before = run_lint("--json", "draft.md", cwd=first) + assert before.returncode == 0 + baseline.write_text(before.stdout, encoding="utf-8") + + result = run_lint("--json", "--baseline", str(baseline), "draft.md", cwd=second) + + assert result.returncode == 2 + assert "baseline mismatch" in result.stderr + + +def test_baseline_requires_the_same_voice_sample_content(tmp_path): + draft = tmp_path / "draft.md" + first_voice = tmp_path / "first-voice.md" + second_voice = tmp_path / "second-voice.md" + baseline = tmp_path / "baseline.json" + draft.write_text("However, the writer used an em dash — twice — here.", encoding="utf-8") + first_voice.write_text("I use straight punctuation.", encoding="utf-8") + second_voice.write_text("I use — dashes — often.", encoding="utf-8") + + before = run_lint( + "--json", "--register", "voiced", "--voice-sample", str(first_voice), str(draft) + ) + assert before.returncode == 0 + baseline.write_text(before.stdout, encoding="utf-8") + + result = run_lint( + "--json", + "--register", + "voiced", + "--voice-sample", + str(second_voice), + "--baseline", + str(baseline), + str(draft), + ) + + assert result.returncode == 2 + assert "baseline incompatible" in result.stderr + + +def test_report_names_the_rules_that_still_require_manual_judgment(): + report = load_lint().lint("The parser reads the file.") + + assert report["manual_checks"] == list(load_lint().MANUAL_CHECKS) + assert { + "claim_preservation", + "voice_fidelity", + "terminology_consistency", + "article_use", + "paragraph_focus", + "safety_labels", + "quotation_ownership", + } <= set(report["manual_checks"]) diff --git a/tests/unit/test_release_cli.py b/tests/unit/test_release_cli.py index 3186069..e6f82d8 100644 --- a/tests/unit/test_release_cli.py +++ b/tests/unit/test_release_cli.py @@ -1,12 +1,13 @@ """Unit tests for the rhdh-release scripts.""" import json -import os import subprocess import sys from pathlib import Path from urllib.parse import quote +from conftest import git_env + PROJECT_ROOT = Path(__file__).resolve().parents[2] _RELEASE_SCRIPTS = PROJECT_ROOT / "skills" / "release" / "rhdh-release-status" / "scripts" _NO_RICH_FILTER = PROJECT_ROOT / ".test-no-rich-filter.json" @@ -1156,15 +1157,15 @@ def test_direct_script_discovers_project_config(self, tmp_path): (config_dir / "config.json").write_text( json.dumps({"repos": {"private-data": str(private_data)}}) ) - subprocess.run(["git", "init"], cwd=project, check=True, capture_output=True) + env = git_env(HOME=str(tmp_path / "home")) + env.pop("PYTHONPATH", None) + subprocess.run(["git", "init"], cwd=project, check=True, capture_output=True, env=env) code = ( "import sys; " f"sys.path.insert(0, {str(_RELEASE_SCRIPTS)!r}); " "import rich_filter; print(rich_filter.discover())" ) - env = {**os.environ, "HOME": str(tmp_path / "home")} - env.pop("PYTHONPATH", None) result = subprocess.run( [sys.executable, "-c", code], cwd=project, diff --git a/tests/unit/test_setup_rhdh_skills.py b/tests/unit/test_setup_rhdh_skills.py index ee944b8..951eca3 100644 --- a/tests/unit/test_setup_rhdh_skills.py +++ b/tests/unit/test_setup_rhdh_skills.py @@ -57,7 +57,7 @@ def test_doctor_discovers_dependencies_across_supported_host_layouts(tmp_path): home = tmp_path / "home" project = tmp_path / "project" install_fake_skill(home / ".agents" / "skills", "grilling") - install_fake_skill(home / ".claude" / "skills", "humanizer") + install_fake_skill(home / ".claude" / "skills", "mutation-gate") install_fake_skill(project / ".cursor" / "skills", "ask-rhdh") result = run_setup( @@ -75,12 +75,11 @@ def test_doctor_discovers_dependencies_across_supported_host_layouts(tmp_path): assert result.returncode == 1 report = json.loads(result.stdout) assert "contract" not in report - assert set(report["installedSkills"]) == {"ask-rhdh", "grilling", "humanizer"} + assert set(report["installedSkills"]) == {"ask-rhdh", "grilling", "mutation-gate"} # handoff is required but not present in this fixture, so doctor must report it # rather than silently passing: three skills route the user to it. assert report["requiredExternalSkills"] == { "grilling": "installed", - "humanizer": "installed", "handoff": "missing", } assert report["capabilities"]["tools"]["oc"] == "not-probed" @@ -127,7 +126,7 @@ def test_install_plan_uses_one_pack_command_for_the_whole_collection(): assert operation["onFailure"].strip() -def test_install_plan_fallback_includes_the_repo_and_both_external_sources(): +def test_install_plan_fallback_includes_the_repo_and_the_external_source(): result = run_setup( "install-plan", "--catalog", @@ -143,12 +142,10 @@ def test_install_plan_fallback_includes_the_repo_and_both_external_sources(): operations = json.loads(result.stdout)["operations"] assert [operation["command"][3] for operation in operations] == [ "redhat-developer/rhdh-skills", - "blader/humanizer", "mattpocock/skills", ] assert operations[0]["command"][4:6] == ["--skill", "*"] - assert ["--skill", "humanizer"] == operations[1]["command"][4:6] - assert ["--skill", "grilling"] == operations[2]["command"][4:6] + assert ["--skill", "grilling", "--skill", "handoff"] == operations[1]["command"][4:8] def test_apply_runs_nothing_until_the_stated_plan_is_confirmed(monkeypatch): @@ -289,7 +286,7 @@ def fake_run(command, **kwargs): def test_apply_reports_an_outcome_for_every_operation_including_skipped_ones(monkeypatch): setup = load_setup_module() plan = setup.install_plan(read_catalog(), agent="codex", scope="project", pack_url=None) - assert len(plan["operations"]) == 3 + assert len(plan["operations"]) == 2 monkeypatch.setattr( setup, @@ -306,11 +303,10 @@ def fake_run(command, **kwargs): assert returncode == 1 assert report["valid"] is False - assert [outcome["order"] for outcome in report["outcomes"]] == [1, 2, 3] + assert [outcome["order"] for outcome in report["outcomes"]] == [1, 2] assert [outcome["status"] for outcome in report["outcomes"]] == [ "failed", "skipped", - "skipped", ] assert report["outcomes"][0]["stderr"] == "network unreachable" assert report["outcomes"][1]["returnCode"] is None diff --git a/tests/unit/test_skill_catalog.py b/tests/unit/test_skill_catalog.py index b503cef..6400b10 100644 --- a/tests/unit/test_skill_catalog.py +++ b/tests/unit/test_skill_catalog.py @@ -8,6 +8,8 @@ import sys from pathlib import Path +import pytest + PROJECT_ROOT = Path(__file__).resolve().parents[2] VALIDATOR = PROJECT_ROOT / "scripts" / "validate_skill_catalog.py" @@ -115,10 +117,10 @@ def test_repository_catalog_exposes_the_approved_composable_skill_set(): # assert they agree rather than restating the roster here, where it only rots. assert set(report["promotedSkills"]) == {entry["name"] for entry in catalog["skills"]} - # These two are contracts rather than inventory: exactly two entry points are + # These two are contracts rather than inventory: exactly three entry points are # human-invoked, and the pack depends on exactly two external skills. - assert set(report["humanInvokedSkills"]) == {"ask-rhdh", "setup-rhdh-skills"} - assert set(report["requiredExternalSkills"]) == {"grilling", "humanizer", "handoff"} + assert set(report["humanInvokedSkills"]) == {"ask-rhdh", "setup-rhdh-skills", "clean-prose"} + assert set(report["requiredExternalSkills"]) == {"grilling", "handoff"} assert every_promoted_skill_lives_in_a_domain_category(catalog) @@ -237,6 +239,75 @@ def test_a_required_skill_absent_from_the_owning_body_is_reported(tmp_path): assert "beta: requiresSkills declares alpha" in messages(report, "DEPENDENCY_NOT_DOCUMENTED")[0] +@pytest.mark.parametrize("instruction_dir", ["workflows", "references"]) +def test_a_required_skill_may_be_documented_in_owned_instruction_markdown( + tmp_path, instruction_dir +): + validator = load_validator() + root = build_fixture(tmp_path, skill_bodies={"alpha": "\nEmits `Widget/v1`.\n", "beta": ""}) + instruction = root / "skills" / "meta" / "beta" / instruction_dir / "compose.md" + instruction.parent.mkdir(parents=True) + instruction.write_text( + "Use `/alpha` to produce `Widget/v1`, then consume its shape and size.\n", + encoding="utf-8", + ) + + report = validator.validate_repository(root) + + assert report["valid"] is True, report["errors"] + + +def test_a_dependency_named_only_in_a_workflow_code_example_is_not_documented(tmp_path): + validator = load_validator() + root = build_fixture(tmp_path, skill_bodies={"alpha": "\nEmits `Widget/v1`.\n", "beta": ""}) + workflow = root / "skills" / "meta" / "beta" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text("```text\n/alpha\n```\n", encoding="utf-8") + + report = validator.validate_repository(root) + + assert "DEPENDENCY_NOT_DOCUMENTED" in codes(report) + + +@pytest.mark.parametrize("fence", ["````", "~~~~"]) +def test_a_dependency_inside_a_matching_long_fence_is_not_documented(tmp_path, fence): + validator = load_validator() + root = build_fixture(tmp_path, skill_bodies={"alpha": "\nEmits `Widget/v1`.\n", "beta": ""}) + workflow = root / "skills" / "meta" / "beta" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text( + f"{fence}markdown\n```text\n/alpha\n```\n{fence}\n", + encoding="utf-8", + ) + + report = validator.validate_repository(root) + + assert "DEPENDENCY_NOT_DOCUMENTED" in codes(report) + + +@pytest.mark.parametrize("opening,closing", [("````", "`````"), ("~~~~", "~~~~~")]) +def test_removing_a_long_fence_preserves_only_surrounding_instructions(opening, closing): + validator = load_validator() + text = f"Before.\n\n{opening}markdown\n```text\n/alpha\n```\n{closing}\n\nAfter.\n" + + cleaned = validator._without_noninstructions(text) + + assert cleaned.split() == ["Before.", "After."] + + +def test_a_typoed_dependency_in_a_workflow_is_still_rejected(tmp_path): + validator = load_validator() + root = build_fixture(tmp_path, skill_bodies={"alpha": "\nEmits `Widget/v1`.\n", "beta": ""}) + workflow = root / "skills" / "meta" / "beta" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text("Use `/alph` before consuming `Widget/v1`.\n", encoding="utf-8") + + report = validator.validate_repository(root) + + assert "DEPENDENCY_NOT_DOCUMENTED" in codes(report) + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + + def test_a_dependency_named_only_inside_a_longer_token_does_not_count(tmp_path): validator = load_validator() root = build_fixture( @@ -391,3 +462,349 @@ def test_invocation_parity_is_checked_in_both_directions(tmp_path): frontmatter_human = validator.validate_repository(root) assert "INVOCATION_MISMATCH" in codes(frontmatter_human) assert "drop disable-model-invocation" in messages(frontmatter_human, "INVOCATION_MISMATCH")[0] + + +def write_wrapper(root: Path, name: str, target: str) -> Path: + """Write a delegating wrapper: human-invoked, no sections, one skill named.""" + skill_dir = write_skill(root, name, invocation="human") + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Sample {name} entry point.\n" + f"disable-model-invocation: true\n---\n\nRun a `/{target}` pass.\n", + encoding="utf-8", + ) + return skill_dir + + +def test_a_delegating_wrapper_owes_no_completion_section(tmp_path): + """A wrapper's completion is its delegate's; restating it would duplicate the rule.""" + validator = load_validator() + root = write_repository( + tmp_path, + [entry("clean-prose", invocation="human"), entry("prose-editing", category="reference")], + ) + write_wrapper(root, "clean-prose", "prose-editing") + + report = validator.validate_repository(root) + + assert "MISSING_COMPLETION" not in codes(report) + assert report["delegatingWrappers"] == {"clean-prose": "prose-editing"} + + +def test_a_wrapper_pointing_at_nothing_is_reported(tmp_path): + validator = load_validator() + root = write_repository(tmp_path, [entry("clean-prose", invocation="human")]) + write_wrapper(root, "clean-prose", "prose-editing") + + report = validator.validate_repository(root) + + assert "WRAPPER_TARGET_MISSING" in codes(report) + assert "/prose-editing" in messages(report, "WRAPPER_TARGET_MISSING")[0] + + +def test_a_wrapper_may_not_delegate_to_another_entry_point(tmp_path): + """Chaining entry points leaves neither reachable by the router.""" + validator = load_validator() + root = write_repository( + tmp_path, + [entry("clean-prose", invocation="human"), entry("ask-rhdh", invocation="human")], + ) + write_wrapper(root, "clean-prose", "ask-rhdh") + + report = validator.validate_repository(root) + + assert "WRAPPER_TARGET_NOT_MODEL" in codes(report) + + +def test_a_human_skill_that_carries_work_still_owes_a_completion_section(tmp_path): + """The exemption is for wrappers with no substance, not for human invocation.""" + validator = load_validator() + root = write_repository(tmp_path, [entry("setup-rhdh-skills", invocation="human")]) + skill = root / "skills" / "meta" / "setup-rhdh-skills" / "SKILL.md" + skill.write_text( + "---\nname: setup-rhdh-skills\ndescription: Sample setup skill.\n" + "disable-model-invocation: true\n---\n\n" + "# setup\n\n## Steps\n\nRun a `/prose-editing` pass, then do the rest here.\n", + encoding="utf-8", + ) + + report = validator.validate_repository(root) + + assert "MISSING_COMPLETION" in codes(report) + + +def test_a_substantive_skill_cannot_pose_as_a_wrapper_and_escape_completion(tmp_path): + """The exemption must fail closed: a real skill slipping into it loses a required section.""" + module = load_validator() + poses = { + "an H1 instead of an H2": "# Setup\n\nDo the thing.\n\n- step one\n\nUse /rhdh-context.\n", + "no heading, several paragraphs": ( + "Do the thing carefully.\n\nIt matters for the release.\n\nCheck /rhdh-context first.\n" + ), + "a setext heading": "Completion\n----------\n\nRun /rhdh-context.\n", + "delegate hidden in an HTML comment": "Do something else.\n<!-- /prose-editing -->\n", + "delegate hidden in a code fence": "Do something else.\n\n```\n/prose-editing\n```\n", + "names no skill at all": "Just do the thing.\n", + } + for label, body in poses.items(): + assert module._delegation_target(body) is None, label + + assert module._delegation_target("Run a `/prose-editing` pass.\n") == "prose-editing" + + +@pytest.mark.parametrize("fence", ["````", "~~~~"]) +def test_a_long_fenced_invocation_cannot_turn_prose_into_a_wrapper(fence): + module = load_validator() + body = f"Do something else.\n\n{fence}markdown\n/prose-editing\n{fence}\n" + + assert module._delegation_target(body) is None + + +def test_a_stale_skill_citation_is_caught_even_without_the_rhdh_prefix(tmp_path): + """A rename must not leave callers pointing at nothing, whatever the skill is named.""" + module = load_validator() + root = write_repository( + tmp_path, + [entry("rhdh-pr-review", category="plugins"), entry("prose-editing", category="reference")], + skill_bodies={"rhdh-pr-review": "\nEvery draft goes through `/prose-edit` first.\n"}, + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + assert "/prose-edit" in messages(report, "UNKNOWN_SKILL_REFERENCE")[0] + + +def test_an_exact_retired_single_token_skill_citation_is_caught(tmp_path): + """Single-token skill names must not evade validation's citation grammar.""" + module = load_validator() + root = write_repository( + tmp_path, + [entry("rhdh-pr-review", category="plugins"), entry("prose-editing", category="reference")], + skill_bodies={"rhdh-pr-review": "\nRun `/humanizer` before returning the review.\n"}, + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + assert "/humanizer" in messages(report, "UNKNOWN_SKILL_REFERENCE")[0] + + +def test_a_retired_single_token_skill_citation_in_a_workflow_is_caught(tmp_path): + module = load_validator() + root = write_repository(tmp_path, [entry("alpha")]) + workflow = root / "skills" / "meta" / "alpha" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text("Run `/humanizer` before returning the draft.\n", encoding="utf-8") + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + assert "workflows/compose.md" in messages(report, "UNKNOWN_SKILL_REFERENCE")[0] + + +def test_backtick_in_fence_info_does_not_hide_a_quoted_skill_invocation(tmp_path): + """CommonMark rejects a backtick fence whose info string contains a backtick.""" + module = load_validator() + root = write_repository(tmp_path, [entry("alpha")]) + workflow = root / "skills" / "meta" / "alpha" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text( + '```bad`info\nRun "/humanizer" before returning the draft.\n```\n', + encoding="utf-8", + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + assert "/humanizer" in messages(report, "UNKNOWN_SKILL_REFERENCE")[0] + + +def test_tab_indented_fence_does_not_hide_a_retired_invocation(tmp_path): + """A tab is four columns, so CommonMark treats the opener as indented code.""" + module = load_validator() + root = write_repository(tmp_path, [entry("alpha")]) + workflow = root / "skills" / "meta" / "alpha" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text( + '\t```text\nRun "/humanizer" before returning the draft.\n```\n', + encoding="utf-8", + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + assert "/humanizer" in messages(report, "UNKNOWN_SKILL_REFERENCE")[0] + + +def test_a_retired_invocation_in_a_blockquoted_fence_is_an_example(tmp_path): + module = load_validator() + root = write_repository(tmp_path, [entry("alpha")]) + workflow = root / "skills" / "meta" / "alpha" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text( + '> ```text\n> Run "/humanizer" only as an example.\n> ```\n', + encoding="utf-8", + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" not in codes(report) + + +def test_live_blockquote_text_after_a_fence_is_still_validated(tmp_path): + module = load_validator() + root = write_repository(tmp_path, [entry("alpha")]) + workflow = root / "skills" / "meta" / "alpha" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text( + ( + '> ```text\n> Run "/humanizer" only as an example.\n> ```\n' + '> Run "/humanizer" as a live instruction.\n' + ), + encoding="utf-8", + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + assert "/humanizer" in messages(report, "UNKNOWN_SKILL_REFERENCE")[0] + + +@pytest.mark.parametrize( + ("opening", "closing"), + [("````markdown", "`````"), ("~~~~bad~info", "~~~~~")], +) +def test_nested_blockquoted_fence_removes_only_its_example(opening, closing): + module = load_validator() + text = ( + f' > > {opening}\n > > Run "/humanizer" only as an example.\n' + f" > > {closing}\n" + '> Run "/humanizer" as a live instruction.\n' + ) + + cleaned = module._without_noninstructions(text) + + assert cleaned == '> Run "/humanizer" as a live instruction.\n' + + +def test_ending_a_blockquote_ends_its_unclosed_fence(): + module = load_validator() + text = ( + '> ```text\n> Run "/humanizer" only as an example.\n' + 'Run "/humanizer" as a live instruction.\n' + ) + + cleaned = module._without_noninstructions(text) + + assert cleaned == 'Run "/humanizer" as a live instruction.\n' + + +@pytest.mark.parametrize("indent", ["\t", " "]) +def test_indented_blockquote_marker_cannot_open_a_fence(indent): + module = load_validator() + text = f'{indent}> ```text\n> Run "/humanizer" as a live instruction.\n> ```\n' + + cleaned = module._without_noninstructions(text) + + assert "/humanizer" in cleaned + + +@pytest.mark.parametrize("fence", ["```", "~~~"]) +def test_tab_indented_fence_opener_remains_instruction_text(fence): + module = load_validator() + text = f'\t{fence}text\nRun "/humanizer" before returning.\n{fence}\n' + + cleaned = module._without_noninstructions(text) + + assert "/humanizer" in cleaned + + +@pytest.mark.parametrize("fence", ["```", "~~~"]) +def test_tab_indented_fence_closer_does_not_end_a_fence(fence): + module = load_validator() + text = f"{fence}text\nexample\n\t{fence}\n/humanizer\n{fence}\nAfter.\n" + + cleaned = module._without_noninstructions(text) + + assert cleaned == "After.\n" + + +def test_tilde_fence_info_may_contain_a_tilde_and_still_hide_an_example(tmp_path): + module = load_validator() + root = write_repository(tmp_path, [entry("alpha")]) + workflow = root / "skills" / "meta" / "alpha" / "workflows" / "compose.md" + workflow.parent.mkdir(parents=True) + workflow.write_text( + '~~~bad~info\nRun "/humanizer" as an example.\n~~~\n', + encoding="utf-8", + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" not in codes(report) + + +@pytest.mark.parametrize("quote", ["'", '"']) +def test_a_quoted_retired_skill_citation_is_caught(tmp_path, quote): + module = load_validator() + root = write_repository( + tmp_path, + [entry("alpha")], + skill_bodies={"alpha": f"\nRun {quote}/humanizer{quote} before returning the draft.\n"}, + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" in codes(report) + + +@pytest.mark.parametrize( + "route", ['"/image-registry"', "'/my-plugin'", '"https://example.com/humanizer"'] +) +def test_quoted_routes_and_urls_are_not_skill_citations(tmp_path, route): + module = load_validator() + root = write_repository( + tmp_path, + [ + entry("rhdh-plugin-wiring", category="plugins"), + entry("prose-editing", category="reference"), + ], + skill_bodies={"rhdh-plugin-wiring": f"\nMount or fetch {route}.\n"}, + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" not in codes(report) + + +def test_a_declared_external_single_token_skill_citation_is_valid(tmp_path): + """Expanding the grammar must preserve declared external composition.""" + module = load_validator() + root = write_repository( + tmp_path, + [entry("alpha")], + skill_bodies={"alpha": "\nRun `/handoff` when context must survive the session.\n"}, + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" not in codes(report) + + +def test_a_url_route_that_looks_like_a_citation_is_not_reported(tmp_path): + """Plugin docs mount routes with the same syntax; only names resembling a skill count.""" + module = load_validator() + root = write_repository( + tmp_path, + [ + entry("rhdh-plugin-wiring", category="plugins"), + entry("prose-editing", category="reference"), + ], + skill_bodies={ + "rhdh-plugin-wiring": "\nMount the tab at `/image-registry` and route `/my-plugin`.\n" + }, + ) + + report = module.validate_repository(root) + + assert "UNKNOWN_SKILL_REFERENCE" not in codes(report)