feat(ci): add portability-lint gate for ecosystem/forge-agnostic skills (#531)#609
Conversation
|
Claude finished @kyle-sexton's task in 4m 27s —— View job Code Review —
|
…tic skills (#531) Skills declared agnostic ship bare hardcoded stack/forge/branch/tracker defaults because agnosticism was asserted in prose and never enforced — the dominant review-churn class. This adds a fail-closed CI lane that greps the skill files a PR changes for coupling tokens, honoring detection-first/ presence-gated uses, per-site `portability-ok:` exemptions, and whole-file `portability-scope:` declarations (no new frontmatter field). Token list is external, extensible data (scripts/skill-portability-tokens.txt), seeded with one active class (the #467 branch/default-branch hardcode) and staged classes commented with enable-triggers, per the ratified one-class-at-a- time rollout. Changed-file scoping keeps main green: existing violations wait for their member-issue fix or the file's next edit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
c5add0f to
19a9af4
Compare
|
Claude finished @kyle-sexton's task in 3m 31s —— View job PR Review
This is a solid, well-designed gate. The architecture is sound and the implementation is clean. Three things worth flagging before merge: one is a real concern about future-class breakage, one is a correctness edge case in the guard-marker logic, and one is a low-priority note on staged patterns.
|
Review classification (work-2 lane, post-green pass)
Findings 1–2: fix in progress on this branch (comment-only additions, no behavior change). Finding 3: tracked in #611, cited in |
…ility-lint classes Add an enable-time checklist to the STAGED section of the portability-lint token list, covering two claude-review findings on PR #609: - is_guarded() in check-skill-portability.sh seeds guard markers for the active branch class only; its generic presence signals (if using / when installed / when present) would spuriously guard a bare dotnet/gh ecosystem hardcode. Remind maintainers to audit and tighten is_guarded() against a class's real false-guard risk before enabling it. - `\b` is not a reliable word boundary in awk: POSIX ERE defines none, and on gawk (verified: GNU Awk 5.4.0, word boundary is `\y`) and mawk/nawk `\b` matches a literal backspace byte, so a `\b`-anchored class silently never matches. Remind maintainers to rewrite to a POSIX-safe boundary before activating the staged Clean-Architecture and gh classes. Comment-only; no active behavior, no ACTIVE-token or test changes. Self-test still passes 12/12. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC
|
Claude finished @kyle-sexton's task in 2m 3s —— View job PR Review —
|
|
Claude finished @kyle-sexton's task in 2m 37s —— View job PR Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 72ca25ffa6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…etection evidence Two false-negative holes in the portability-lint gate, both flagged as P2 by the external reviewer against the active branch/default-branch class: - scan_file's exit status was ignored: a malformed active ERE token makes awk fault with no stdout, the empty output read as "clean", and the file was silently skipped — the exact false negative fail-closed forbids. Propagate the scanner's non-zero exit and fail closed (exit 2). - is_guarded() carried generic optional-dependency presence markers (`if using`, `when installed`, `when present`, `if installed`) that are not branch-resolution evidence. A line like "If using a rebase workflow, diff origin/main first" was treated as guarded, letting a bare default branch through. Scope the active class to branch-detection evidence only; those presence guards return class-scoped when a class that needs them is enabled. Adds regression tests proving both holes are closed and tightens the annotation-non-leak test to also assert the annotated line stays clean. Suite is 14/14. Tokens-file staged-class preamble updated to match the tightened is_guarded() contract. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 50s —— View job PR Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c755ec642e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…esolution evidence External-reviewer P2 on the prior commit: the `[Ff]allback` / `[Ff]alling back` markers in is_guarded() match any prose using the word, so a line like "As a fallback for a network timeout, run git diff origin/main" was accepted with no branch-resolution evidence, still imposing main on consumers. The word does zero independent work today: every real detection ladder in the corpus (review/fanout, review/quality-gate) co-locates a resolution command (origin/HEAD / merge-base / -> origin/ / baseRefName) on the same line, so those stay guarded. Remove the standalone word markers; a bare default whose resolution evidence is not co-located now correctly flags and uses a per-site portability-ok escape. Adds a regression test with the reviewer's exact example, and syncs the header escape list and the violation help text to the resolution-command-is-the- evidence contract. Suite is 15/15. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 33s —— View job PR Review —
|
| Finding | Source | Status |
|---|---|---|
Silent scan_file failure → false negative |
First review (medium) | Fixed in c755ec6 ✓ |
is_guarded() generic presence markers over-match |
Codex P2 / second review | Fixed in c755ec6 ✓ |
| Annotation non-leak test missing negative assertion | First/third review (low) | Fixed in c755ec6 ✓ |
\b non-POSIX ERE in staged patterns |
Second review / Codex | Documented in 72ca25f ✓ |
is_comment() inline HTML pending_annot carry-over |
Second review (deferred) | Tracked in #611 ✓ |
Bare-word fallback guards unrelated prose |
Codex P2 (c755ec6 round) |
Fixed here in 8caf57a ✓ |
Ready to merge. The gate is fail-closed, self-testing before the PR-diff step, data-driven, changed-file-scoped, and now guards against all three reviewer-identified false-negative paths (scanner fault, generic-presence over-match, bare-word prose guard). The one deferred edge case (#611) is correctly out of scope for the initial gate's active token class.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8caf57a1b6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…dropped External-reviewer P2 on the portability-lint gate's changed-file collection: a changed file whose pathname triggers Git's C-style quoting (a non-ASCII byte under the default core.quotePath, or a literal quote/backslash) was silently excluded from the PR-diff gate — the silent-exclusion class the contract forbids. `git diff --name-only` (no -z) emits such a path as `"plugins/…/caf\303\251.md"`, wrapped in double quotes with octal escapes. The leading quote misses the `plugins/*/skills/*` case glob (and `[[ -f ]]` fails on the quoted string), so the file never enters the scanned set and its coupling ships ungated. Switch the collection to NUL-delimited plumbing: `git diff --name-only -z` emits pathnames verbatim, `sort -z -u` preserves the dedup, and `read -r -d ''` consumes the NUL-separated stream. Paths now arrive unquoted and are gated regardless of the characters in the name. Adds a diff-mode regression test — the first coverage of the bare-<base-ref> file-collection path — with a git-init'd fixture committing one ASCII-named and one non-ASCII-named coupling file: the ASCII hit proves -z left the common path intact, and two COUPLING lines prove the quoted path was read, not skipped. Confirmed red before the fix (quoted file dropped, one COUPLING line). Suite is 16/16. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 1s —— View job PR Review —
|
| Finding | Source | Status |
|---|---|---|
Silent scan_file failure → false negative |
First review (medium) | Fixed in c755ec6 ✓ |
is_guarded() generic presence markers over-match |
Codex P2 / second review | Fixed in c755ec6 ✓ |
| Annotation non-leak test missing negative assertion | First/third review (low) | Fixed in c755ec6 ✓ |
\b non-POSIX ERE in staged patterns |
Second review / Codex | Documented in 72ca25f ✓ |
Bare-word fallback guards unrelated prose |
Codex P2 | Fixed in 8caf57a ✓ |
| Git-quoted pathnames silently dropped from PR diff | Codex P2 | Fixed here in 15d8399 ✓ |
is_comment() inline HTML pending_annot carry-over |
Second review (deferred) | Tracked in #611 ✓ |
Ready to merge. The gate is fail-closed, self-testing before the PR-diff step, data-driven, changed-file-scoped, and now correctly gates skill files regardless of pathname encoding. The one deferred edge case (#611) is correctly out of scope for the initial gate's active token class.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 15d8399be1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…fragment The diff-mode regression test built its non-ASCII fixture name from `printf 'caf\303\251.md'`; the octal escape leaves the bare ASCII fragment `caf` in the source, which the hygiene typos check flags as a misspelling of "calf" (repo-wide, so it red-lined the hygiene lane). Rename the fixture to `quoted-\303\251.md` — same trailing U+00E9 byte, so it still triggers Git's C-style quoting, but the ASCII tokens (`quoted`, `md`) are clean. Fixing the fixture at the source keeps the shared _typos.toml free of a test-only word. Suite is 16/16, shellcheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 42s —— View job PR Review —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6a783584d9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
#617) (#622) Closes #617 ## Summary Docs-only PRs — operator precedent codifications and other `docs/topics/` churn, a large share of current throughput per #617 — ran the full plugin contract suite (`plugin-gate`: Node + Python installs, every `plugins/**/*.test.sh`, manifest + catalog validation) and the full `miro-plugin` Node build for a diff that cannot affect either. Both lanes now short-circuit their expensive steps when the diff is confined to a small, positive, provably-inert allowlist, reporting an honest evaluated-and-not-applicable success. ## Fix **Mechanism — in-job detection, never a job skip.** GitHub's docs are explicit: *"If a workflow is skipped due to path filtering, branch filtering or a commit message, then checks associated with that workflow will remain in a 'Pending' state. A pull request that requires those checks to be successful will be blocked from merging."* whereas *"If, however, a job within a workflow is skipped due to a conditional, it will report its status as 'Success.'"* ([troubleshooting-required-status-checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks)). So workflow-level `paths:`/`paths-ignore:` is ruled out for required checks. It is doubly ruled out here: the sole required check is the `ci-status` aggregate, which fails unless every lane's `result` is exactly `success` — a conditionally-*skipped* job's result is `skipped`, not `success`, so even a job-level `if:` would red the aggregate. The safe pattern is therefore the one #593/#609 already use: the job always runs, self-tests its detector first, and gates only its inner expensive steps, staying `success` and reporting to branch protection either way. New files: - **`scripts/check-docs-only.sh`** — emits `docs_only=true|false` to `$GITHUB_OUTPUT`; `true` iff every path changed vs the base ref matches an allowlist prefix. Fail-closed toward *running* the full suite: an unresolvable base ref, an empty/missing allowlist, or an empty diff all emit `false` with a stderr note and exit 0 (a code PR is not an error — "has code" is never encoded as a non-zero exit that would red every code PR). - **`scripts/docs-only-paths.txt`** — the allowlist as external, extensible **data** (same shape as #609's token list). **Allowlist, not blocklist, by design**: a blocklist ("skip on any doc the validators don't currently read") silently turns false-green the day a validator starts reading a new doc; a positive list fails safe — any unlisted path, including a brand-new code input, forces the full suite. Seeded with `docs/topics/` only. Adding an entry is a two-part proof (inert + a self-test case). - **`scripts/check-docs-only.test.sh`** — the honesty proof, run unconditionally in both wrapped jobs before anything expensive. In `ci.yml`, `plugin-gate` and `miro-plugin` each gain: `fetch-depth: 0`, an always-run detector self-test, a PR-only detect step (`id: scope`), an `if: steps.scope.outputs.docs_only != 'true'` guard on every install/test step, and an explicit *"not applicable to a docs-only diff — reporting success"* log step. On `push` the detect step doesn't run, the output is empty, and every real step runs — main always gets the full suite. **Job classification** (only jobs that *cannot* be affected by a docs-only diff are scoped; when unsure, not scoped): | Lane | Scoped? | Rationale | |---|---|---| | `plugin-gate` | **Yes** | Heaviest lane. `run-plugin-tests.sh` is hermetic (tests build their own fixture repos); `validate-plugins.sh` reads only `README.md`, `docs/PLUGIN-ARTIFACT-PROTOCOL.md`, `docs/CATALOG-TAXONOMY.md` (all excluded from the allowlist) + `plugins/**` + toolchain files. A `docs/topics/`-only diff touches none of these. | | `miro-plugin` | **Yes** | Every step is `working-directory: plugins/miro`; a docs-only diff provably cannot touch it. | | `hygiene` | No | Markdownlint / typos / comment-hygiene **do** read the changed docs — this is the lane that *should* run on a docs PR. | | `skill-quality-gate` | No | Already self-scopes to changed skills (empty set on a non-skill docs diff); eval-schema step is cheap. | | `zizmor` | No | Advisory reusable workflow; scoping a reusable call partly belongs to `ci-workflows`. | | `hook-utils-sync`, `standards-contract-sync`, `cross-plugin-source-drift`, `silent-skip-gate`, `runner-policy` | No (deferred) | Provably docs-inert, but off the critical path (fast bash tests, no heavy installs) — wrapping them adds surface for negligible latency gain. Trigger to revisit: if any becomes install-bearing. | **Deferred with trigger:** scoping `miro-plugin` to `plugins/miro/**` specifically (skipping it on *any* non-miro diff, not just docs-only) is a larger, separate optimization broader than #617's docs-only framing; trigger: a dedicated follow-up issue for per-lane path scoping. ## Verification In the worktree (`ubuntu`/Git Bash): - **`bash scripts/check-docs-only.test.sh` → `PASS=16 FAIL=0`.** Cases pin: `docs/topics/`-only (incl. new nested files) → `true`; `README.md`, `docs/PLUGIN-ARTIFACT-PROTOCOL.md`, `docs/CATALOG-TAXONOMY.md` → **`false`** (the grep payoff — these are consumed by `plugin-gate`); any non-topics `docs/**`, any `plugins/**` incl. `SKILL.md`, `scripts/`, `.github/`, toolchain lockfiles, a sibling prefix (`docs/topics-archive/`), and mixed docs+code → `false`; unresolvable base ref → `false` exit 0; empty allowlist → `false`; `$GITHUB_OUTPUT` written. - **`shellcheck --rcfile .shellcheckrc`** on both scripts → clean. **`shfmt -d`** → no diff. **`actionlint .github/workflows/ci.yml`** → clean. - **Provenance of the "inert" claim:** `git grep` over `plugins/**/*.test.sh`, `validate-plugin-contracts.mjs`, `generate-catalog.mjs`, and the manifests confirmed `generate-catalog.mjs --check` consumes `README.md` and `docs/CATALOG-TAXONOMY.md` and `validate-plugin-contracts.mjs` requires `docs/PLUGIN-ARTIFACT-PROTOCOL.md` — which is exactly why those are excluded from the allowlist and the docs-only win is `docs/topics/`-scoped, not "all markdown". - **zizmor:** base ref flows via `env: BASE_REF` (no `${{ }}` interpolation inside `run:`), matching the existing `hook-utils-sync`/`skill-quality-gate` steps; `persist-credentials: false` retained; no new permissions. Honest scope note: the win is narrow by construction — it covers `docs/topics/`-only PRs and deliberately does **not** cover README/philosophy edits, `SKILL.md` edits (code), or plugin-`CHANGELOG` edits (code). Narrow-but-sound is the point. ## Related - #532 — false-green / liveness-assertion convention this honors (evaluated-and-not-applicable success, not a silent skip). - #593 — the merged `skill-quality-gate` whose never-skip, self-test-first, event-gated job shape this mirrors. - #609 — open PR also touching `ci.yml` (adds the `portability-lint` lane + one line to the `ci-status` needs list). Different file regions from this PR (new job block + needs line vs. the `plugin-gate`/`miro-plugin` step bodies); composes without conflict. Whichever merges second rebases. - No `plugins/<name>/` directory is touched, so no version bump / CHANGELOG entry (matching CI-only precedent #490/#593). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Reconciliation flag — concurrent-lane collision on the escape-grammar findings. A prior lane resolved the two open Codex P2 threads (
These are active (not staged-class) code paths in This leaves a code-vs-disposition inconsistency: the resolved threads read "deferred to #624 / not fixed inline," but head now contains the fix. Flagging for human / control-tower reconciliation before merge — either accept the inline fix at 6c7fe98 and rescope #624 (and the #611 tracking item), or revert 6c7fe98 to honor the scoped-diff deferral. I have not touched the resolved threads and am not merging (human-gated). |
## Summary Two repo-local CI gates from the 2026-07-20 merged-PR quality audit, both wired into `.github/workflows/ci.yml` as new required lanes. Each follows the established repo-local gate precedent (#593/#609/#622/#628): a self-testable script, a dedicated CI step, fail-closed behavior, and clear diagnostics — with a stale-guarded baseline that grandfathers pre-existing debt without red-lining fixtures/plugins a member issue already owns. Placement is repo-local only; promotion to `ci-workflows` is explicitly deferred ("if it stabilizes") and out of scope. No plugin version bump: the CI scripts and `ci.yml` carry no plugin version to bump. The only `CHANGELOG.md` edits in this PR are a format-only normalization of five pre-existing unbracketed changelogs (see Gate 2) — no version fields change. This repo has no repo-root version artifact. ## Fix **Gate 1 — orphaned-fixture (`scripts/check-orphaned-fixtures.sh`, lane `orphaned-fixture-gate`).** Repo-wide static scan of every file under a skill's `**/evals/fixtures/`. A fixture is *consumed* when its skill-relative path appears in the sibling `evals/evals.json` (an eval `files[]` entry), or its basename appears — **bounded by non-filename characters, so a referenced `valid.json` does not also mark a `valid.json.bak` sibling consumed** — in that `evals.json` or any `*.test.*` file in the plugin. Consumption matching errs toward not-blocking a legitimate fixture. Scope is `evals/fixtures/` specifically — unit-test fixture dirs (`tests/fixtures`, `scripts/fixtures`) are excluded because those are often generated or loaded by directory, not named, and a name-matcher cannot honestly grade them. `--check` fails on an un-grandfathered orphan **and** on a stale baseline entry (one shadowing no orphan), so the baseline cannot outlive its debt. Existing debt grandfathered in `scripts/orphaned-fixtures-baseline.txt` — **exact fixture paths, matched by full-string equality (not prefix)**, so the baseline is a snapshot of today's known orphans: a new orphan under an already-listed directory, or a `<name>.json.bak`/`<name>.jsonl` sibling of a listed file, matches no line and is red-lined rather than silently grandfathered. Every entry cites an owning issue: - The `plugins/autonomy/skills/setup/evals/fixtures/` corpus (security-binding golden suite + `otlp-demo` sample corpus), enumerated file-by-file — tracked by #662; autonomy is WP-lane-owned. - `plugins/knowledge/skills/youtube-digest/evals/fixtures/variation-matrix-backlog.json` — a data file named only from `SKILL.md`/vendor prose, consumed by no grader — tracked by #688 (grade-or-demote). **Gate 2 — CHANGELOG-parity (`scripts/check-changelog-parity.sh`, lane `changelog-parity-gate`).** Covers both gaps the audit named: - `--check` (static, every event): a `plugins/<name>/.claude-plugin/plugin.json` carrying a `version` must ship `plugins/<name>/CHANGELOG.md`. Catches the cited violator — `autonomy` shipped 5 minor bumps with no CHANGELOG.md. - `--check-bump <base>` (PR-only, mirrors the `sync-*.sh --check-bump` gates): a manifest version change must **add** a `## [<version>]` release heading for the new version — present at head, absent at `<base>`. The heading is matched as a fixed string anchored to line start, so the version appearing in prose or a fenced example never satisfies (or falsely pre-exists) the entry, and SemVer build metadata (`1.0.1+build.1`) never leaks into a regex. Three distinct failures, never conflated: `UNDOCUMENTED BUMP` (no entry — an unrelated whitespace/title/old-release edit cannot satisfy the gate), `CHANGELOG FORMAT` (the version is present but written unbracketed, `## <version>`), and `PRE-EXISTING CHANGELOG ENTRY` (the heading already existed at `<base>`, so the bump shipped no fresh note). Applies to **every** plugin, grandfathered or not. Enforcing the bracketed `## [<version>]` Keep-a-Changelog heading surfaced five pre-existing changelogs still on the unbracketed `## <version>` form (`discovery`, `docs-hygiene`, `knowledge`, `playbooks`, `session-flow`); this PR normalizes them to the documented convention (format-only, headings only — no version or entry-content changes). Reconstructing `autonomy`'s history is autonomy-plugin (WP-lane) work, so it is grandfathered by name in `scripts/changelog-parity-baseline.txt` (stale-guarded: `--check` fails if it later gains a CHANGELOG.md or drops its version). The baseline never relaxes `--check-bump`. Both lanes are registered in the `ci-status` `needs:` aggregate (the single source of truth for required lanes). ## Verification All output below is real, run in the worktree against `origin/main`. **Self-tests (run unconditionally in CI so a broken detector cannot mask a regression):** ``` =========== ORPHAN GATE SELF-TEST =========== ok: files[]-referenced fixture passes --check ok: test-asserted fixture passes --check ok: suffix sibling of a referenced fixture red-lines (bounded basename match) ok: un-consumed fixture fails --check (synthetic orphan caught) ok: grandfathered orphan (exact path) passes --check ok: prefix-sibling of a baselined path red-lines (exact-match, no grandfather leak) ok: stale baseline entry fails --check ok: discover labels CONSUMED and ORPHAN ok: bad mode -> exit 2 PASS=9 FAIL=0 =========== CHANGELOG GATE SELF-TEST =========== ok: versioned plugin with CHANGELOG passes --check ok: versioned plugin without CHANGELOG fails --check (synthetic gap caught) ok: grandfathered missing-changelog passes --check ok: stale baseline entry fails --check (reported exactly once) ok: bump + '## [x.y.z]' entry passes --check-bump ok: bump + unbracketed heading -> FORMAT error (not UNDOCUMENTED) ok: bump adding a NEW '## [x.y.z]' entry (absent at base) passes --check-bump ok: bump reusing a base-pre-existing '## [x.y.z]' entry fails --check-bump ok: SemVer build-metadata version with a proper entry passes (no regex leak) ok: version string in prose/indented example does not satisfy the anchored heading match ok: bump + unrelated changelog edit (no new-version entry) fails --check-bump ok: bump without changelog fails --check-bump (synthetic undocumented bump caught) ok: unchanged version passes --check-bump ok: new plugin skipped by --check-bump ok: unresolvable base ref -> exit 2 ok: --check-bump without base ref -> exit 2 ok: bad mode -> exit 2 PASS=17 FAIL=0 ``` **Both gates pass on current HEAD (existing debt grandfathered):** ``` --- orphaned-fixture-gate: --check --- No orphaned eval fixtures (every file under **/evals/fixtures/ is consumed by a grader or grandfathered). --- changelog-parity-gate: --check --- Every versioned plugin has a CHANGELOG.md (or a stale-guarded baseline entry). --- changelog-parity-gate: --check-bump vs origin/main --- Every plugin whose version changed vs origin/main has a '## [<version>]' CHANGELOG.md entry. ``` **Live synthetic catch — Gate 1 (orphaned fixture, no baseline):** ``` ORPHANED FIXTURE: plugins/demo/skills/s/evals/fixtures/never-referenced.json is under evals/fixtures/ but no eval case references it and no test asserts on it. Reference it from a grader (an eval files[] entry or a test), delete it, or grandfather it in /nonexistent with the owning issue. exit=1 ``` **Live synthetic catch — Gate 2 (missing changelog, no baseline):** ``` MISSING CHANGELOG: plugins/demo carries a versioned plugins/demo/.claude-plugin/plugin.json but no plugins/demo/CHANGELOG.md. Add plugins/demo/CHANGELOG.md, or grandfather 'demo' in /nonexistent with its owning issue. exit=1 ``` **Static analysis:** `shellcheck --rcfile .shellcheckrc` clean on all four new scripts; `actionlint .github/workflows/ci.yml` clean; new `.sh` files committed mode 100755, all files LF. Closes #663 ## Related - #663 — this issue. - #662 — sibling autonomy issue owning the security-binding orphan burn-down (grandfathered by Gate 1's baseline). - #688 — grade-or-demote triage for the `knowledge` youtube-digest orphan (grandfathered by Gate 1's baseline). - #593 / #609 / #622 / #628 — the repo-local gate precedent (self-testable script + dedicated `ci.yml` lane + fail-closed) this follows structurally. 🤖 Generated with a Claude Code implementation subagent (issue #663) --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ents (#1338) ## Summary - `scan_file`'s awk block (`scripts/check-skill-portability.sh`) treated any line containing an HTML comment-open marker (`<!` followed by `--`) as a comment line for `pending_annot` carry-forward purposes — including a CONTENT line that merely happens to carry an inline comment marker mid-line (e.g. `diff against origin/main <!-- some other note -->`) and is not itself annotated with `portability-ok:`. - Because a non-annotated "comment" line left `pending_annot` unchanged instead of resetting it to 0, such a content line could silently extend a prior genuine annotation's coverage past itself to a later, unannotated line — a false-negative risk in the fail-closed gate. - Fix: anchor the HTML-comment branch of `is_comment()` to require the marker at the start of the line (modulo leading whitespace) — block-level only — per the reviewer's suggested fix recorded on #611, so only a genuine dedicated comment line can set or carry `pending_annot`. ## Test plan - Added a regression case to `scripts/check-skill-portability.test.sh` reproducing the reported scenario: an annotated comment line, followed by a content line with a hardcoded token *and* an unrelated inline comment marker mid-line (`diff against origin/main here <!-- unrelated inline note, not an annotation -->`), followed by a third unannotated content line with the same hardcoded token. Confirmed the new case fails against the pre-fix script (line 3 wrongly excused) and passes after the fix (line 3 correctly flagged, line 2 still excused via `annotated_above`). - Full self-test suite: `PASS=17 FAIL=0` (was `PASS=16` before this change; the 16 pre-existing cases still pass unchanged). - `shellcheck` (repo `.shellcheckrc`) and `shfmt -d` clean on both modified files. Closes #611 ## Related - #609 — the portability-lint gate this fix hardens (post-green review deferred this finding out of that PR's scope to keep its diff scoped to the gate's initial ship). - #1342 — follow-up filed from this PR's own post-green review (Codex bot): the `^[[:space:]]*<!--` anchor doesn't recognize a block comment nested inside a Markdown list/blockquote container (e.g. `- <!-- portability-ok: ... -->`). Verified not a live regression (no `portability-ok:` usage exists in the corpus today); deferred as new scope beyond #611's literal acceptance criteria. - #624 — sibling escape-hatch grammar rigor pair from PR #609's re-review (same file/area, also deferred, `needs-human`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

Summary
Skills declared ecosystem/forge/tracker-agnostic ship bare hardcoded
stack/forge/branch/tracker defaults because agnosticism was asserted in prose and
never enforced mechanically — the dominant review-churn class, re-caught by the
external reviewer PR after PR (branch/remote hardcodes, seam-conditional eval,
.NET-flavored examples). This adds a fail-closed CI lane that mechanically detects
that coupling instead of paying for it at review time again and again.
Detection only — this PR builds the gate. Fixing the existing violations stays
with the member issues below.
Fix
A new
portability-lintlane (.github/workflows/ci.yml) wired into the requiredci-statusaggregate, plus a repo-local detector:scripts/check-skill-portability.sh— scans skill files for coupling tokens.On a PR it scans only the skill files the change touches (mirroring the
skill-quality-gatechanged-diff pattern), so enabling a token class preventsnew coupling without red-lining pre-existing violations — main's push event
scans nothing (self-test is the push path), and existing hits wait for their
owning follow-up fix or the file's next edit.
scripts/skill-portability-tokens.txt— the token list as external,extensible data (not logic buried in bash), so a reviewer re-catch is a
one-line data edit. Seeded with one active class — the branch/default-branch
hardcode (
origin/main/origin/master) — per the ratified one-token-class-at-a-time rollout; further classes (
dotnet/Clean Arch,raw.githubusercontent,bare
ghtracker calls) are staged as commented entries with enable-triggers.event-gated, and a self-test step runs first so a broken detector can never mask
a real violation behind a green gate.
Design decisions (resolved per the ratified plan, not invented):
is agnostic by default (the Design boundary already binds every plugin), so the
gated set is the files a change touches. A hit is excused three ways, all
reviewer-visible comments (reusing the silent-skip gate's annotated-exemption
shape): an auto-recognized detection-first / presence-gated use; a per-site
portability-ok: <reason>; or a whole-fileportability-scope: <reason>declaring an inherent narrower boundary (the forge-locked-under-a-neutral-name
case). This distinguishes guarded refs (fine) from bare ones, as the
guarded-forward-ref disposition requires.
docs/PLUGIN-PHILOSOPHY.mdgains one doctrine sentence extending the existingdeclared-narrower-boundary allowance from OS platform to the
forge/ecosystem/tracker axis.
No
plugins/<name>/directory is touched, so no version bump / CHANGELOG entry isneeded (matching the CI-gate precedent).
Verification
Self-test suite (
scripts/check-skill-portability.test.sh, runs in CI):PASS=12 FAIL=0— covers bare-token FAIL with file:line, detection-ladder pass,same-line and comment-block-above
portability-ok, annotation non-leak pastintervening code, whole-file
portability-scope, staged tokens staying inactiveunder the shipped list, fail-closed exit 2 on missing token list / invalid base
ref, the vendor/evals/
*.test.shexclusion set, and empty scope.Catches a real violation, passes a real legit use (the bare-vs-guarded
discrimination that drives this class), on live corpus files:
Calibrated against the full corpus (
--all): the active branch class flagsexactly 3 genuine bare hardcodes in 2 files (owned by member issues; changed-file
scoping keeps them off main) and correctly passes the detection-ladder uses in
review/*and excludes evals/test fixtures.Changed-file CI path proven against a real base ref (16 changed skills gated,
all clean). Local checks green:
shellcheck(repo.shellcheckrc),shfmt,actionlint, comment-residue detector (T1=T2=T3=0),typos.Closes #620
Part of #531 (umbrella stays open — stages remain; child #620 carries this PR's shipped scope per the #603 pattern).
Related
implementation: .NET-only marketplace-skill references in every mode context (origin coupling, low severity) #405 implementation: TDD-by-default fires when consumer CLAUDE.md is silent — no userConfig toggle #406 discovery: ecosystem-discovery table is a closed set with no consumer-extension seam #408 toolchain: publisher org/repo/branch URL propagates into consumer-tracked .claude/ecosystems/*.yaml headers #410 toolchain: dotnet.md references dotnet-msbuild:* plugin skills that do not exist in this marketplace #412 planning: branch-name grammar hardcoded to Conventional Commits types, no consumer seam #415 planning: wayfind hardcodes GitHub Issues/gh as the only tracker backend, bypassing the work-item-tracker seam #416 planning: plan close-out and tracker-write phases emit gh-hardcoded tracker writes; close-out hardcodes docs/adr + docs/specs #418 verification: .NET/Blazor globs shipped as "portable defaults" — silent-skip for non-.NET consumers #421 verification: bare unguarded cross-plugin references to dotnet-*/cloudflare plugins in measure contexts #422 verification: consumer override seams gestured ("when the project documents…") but no declared location exists #423 testing: evals grade against the private source monorepo's layout and house test stack (+ ghost testing.md ref) #428 testing: C#/.NET idioms are the fallback defaults and only worked examples in ecosystem-agnostic skills #429 session-flow: topic-docs placement contract resolved via hardcoded raw.githubusercontent URL instead of bundled copy #432 source-control: babysit worktree pruner hard-depends on ghq (undeclared, author-personal repo-layout tool) #438 source-control: PR-body "Generated with Claude Code" attribution is unconditional — no trailer_policy-equivalent seam #439
source-control: pull-request + babysit-prs are gh/GitHub-locked with no forge seam, under a forge-neutral plugin name #441 source-control: remote name
originhardcoded in pull-request create flow (low severity) #442coupling)
portability-scopemechanism serves🤖 Generated with Claude Code
https://claude.ai/code/session_01KyLj6oaFVTE6xFuoYCC2KC