diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 1ff2bf0f7..310377485 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -48,6 +48,19 @@ reviews: instructions: > Docusaurus documentation site. Check MDX/Markdown correctness, working internal links, and that documented APIs match the library. + - path: '.github/workflows/**' + instructions: > + Review the workflow logic normally. Their COMMENTS are reviewed for + false claims about triggers, actors, or credentials only: do not + request added qualifiers, counts, run ids, or dates in a comment; + suggest deleting an overstated clause instead. Scripts and composite + actions elsewhere under .github/ get ordinary correctness and security + review. + - path: '.github/CLAUDE.md' + instructions: > + The workflow security contract. Flag a claim that is false about + triggers, actors, or credentials; do not request added qualifiers, + counts, run ids, or dates. - path: '**/*.{ts,tsx}' instructions: > Follow the repo ESLint + Prettier config (Prettier 3.9.4). Prefer diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 93dc0b7d0..ce1e4f032 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -336,18 +336,17 @@ resting place. exit 1 ``` -Copy the full version from any block job — the three shapes above are each load-bearing: +Copy the full version from any block job — the shapes above are each load-bearing: - **The `-e` check comes first** because harden-runner has deliberate paths that install nothing and still exit 0 (a StepSecurity outage, the `skip-harden-runner` repo property, a container or - slim runner). Without it a vendor outage fails every block job at once — nineteen of them as of - the inventory below, not the seven this line said before the `ai-scan`/`ai-triage` job splits — - with a bare `jq: could not open file`. It still fails, because a job holding a credential must - not run unprotected, but it says why. Do not hand-maintain that number: it is the count of - block-mode jobs, and it has been wrong once already. Derive it, and grep for the **key** rather - than the string — `grep -rn "^ *egress-policy: block$"`. A plain `grep egress-policy: block` - returns twenty: `deploy-worker.yml` names the policy in a comment, which is the same way a - `node -e` in a comment misclassifies a job below. + slim runner). Without it a vendor outage fails every block job at once, with a bare + `jq: could not open file`. It still fails, because a job holding a credential must not run + unprotected, but it says why. Do not write down how many block jobs that is: an earlier version + of this line did, and it was wrong before the `ai-scan`/`ai-triage` job splits. Derive it, and + grep for the **key** rather than the string — `grep -rn "^ *egress-policy: block$"`. A plain + `grep egress-policy: block` counts one more: `deploy-worker.yml` names the policy in a comment, + which is the same way a `node -e` in a comment misclassifies a job below. - **The status check polls** rather than testing once. The pre-step waits only for the file to _exist_ and gives up after ~9s, while the agent resolves every allow-listed host before writing its status, so a cold resolver or a long list can leave it absent or empty at this point. @@ -424,12 +423,12 @@ revert is the agent's; the pre-step only makes one visible by printing `agent.lo debugging a red assertion, `Reverted changes` is in the agent's output, not harden-runner's. **What the pair does NOT prove is that the policy stays armed.** In `step-security/agent`, the -sequence is `writeStatus("Initialized")` (agent.go:307) and then a serve loop whose -`case e := <-errc:` (:313) calls `RevertChanges` (:315). So any runtime error after +sequence is `writeStatus("Initialized")` in `agent.go` and then a serve loop whose +error branch calls `RevertChanges`. So any runtime error after initialization tears the firewall down while `agent.status` still reads `Initialized` and `agent.json` still reads `block` — **both assertion lines pass, nothing is enforcing.** Separately, `refreshDNSEntries` re-resolves every 30s and on failure only logs -`failed to insert new ipaddress in firewall` (:357), so an allow-listed host whose IPs rotate can +`failed to insert new ipaddress in firewall`, so an allow-listed host whose IPs rotate can quietly stop being reachable. Two further false-pass modes are known and not covered, both latent for this repo but worth @@ -474,22 +473,22 @@ and the jobs adjacent to them, per job, not every job in the directory** — the jobs (`ci.yml`, `deploy.yml`, `test-deploy.yml`, `visual-regression.yml`, `story-screenshots.yml`, `scorecard.yml`, `dependency-review.yml`) carry no harden-runner and are out of scope here. -- **Enforcing and asserted** — all three `ai-scan` jobs (`gate`, `scan`, `label`), all four - `ai-triage` jobs (`gate`, `triage`, `publish`, `cleanup`), `claude-repro` (`author` **only**), +- **Enforcing and asserted** — every `ai-scan` job (`gate`, `scan`, `label`), every + `ai-triage` job (`gate`, `triage`, `publish`, `cleanup`), `claude-repro` (`author` **only**), `deploy-worker` (`deploy`), `supply-chain` (`consumer-sbom` and `sign-sbom`), `security-txt-expiry` (`check`), `auto-close-duplicates` (`auto-close`), `claude` (`claude`), `claude-implement` (`implement`), `bestaxbot-reply` (`respond`), `claude-review` (`review`), - `claude-pr-loop` (`fix` and `verify`). Nineteen jobs; the command below is the check. + `claude-pr-loop` (`fix` and `verify`). The command below lists them. - **Audit, deliberately, pending a measured allowlist** — none, as of #578. That issue closed the - group by measuring all six of its members instead of guessing for them. All six now **use** the - same nine-host allowlist, and the gap between that and what the measurement produced is the part - worth keeping: the runs surfaced six application hosts, a strict subset of the eight the - Claude-session jobs above already used, and `nodejs.org` appeared in no run at all. It is on the - list anyway, and that ninth host is why "measured" is not the same as "complete". None + group by measuring every member instead of guessing for them. They all now **use** the same + allowlist, and the gap between that and what the measurement produced is the part worth + keeping: the runs surfaced a strict subset of the application hosts the Claude-session jobs + above already used, and `nodejs.org` appeared in no run at all. It is on the list anyway, and + that unmeasured host is why "measured" is not the same as "complete". None of `ai-scan`/`scan`, `ai-triage`/`triage` or `claude-repro`/`author` runs `actions/setup-node`, - so their eight-host list never needed it; all six of #578's jobs do run it, and setup-node falls + so their list never needed it; all six of #578's jobs do run it, and setup-node falls back to `nodejs.org/dist` when the toolcache misses and the `actions/node-versions` lookup fails. - Six audit runs could not surface that, because the toolcache hit every time and an unexercised + The audit runs could not surface that, because the toolcache hit every time and an unexercised path leaves no endpoint in any log. Review caught it, not measurement. Keep the group here rather than deleting it: audit remains the correct starting point for an **existing** live job whose egress has never been observed, and the recipe under this rule is what keeps it a short state @@ -508,8 +507,8 @@ jobs (`ci.yml`, `deploy.yml`, `test-deploy.yml`, `visual-regression.yml`, `story `verify-provenance` (installs published packages, runs verification scripts). Calling any of these API-only — as this list did twice — understates the unmonitored execution and egress surface in the one inventory meant to state it precisely. `auto-close-duplicates` was the - fifth here until it was given an enforced policy; it is now in the first group, and it is the - only one of the five that has moved. + fifth here until it was given an enforced policy; it is now in the first group, and it is + the only one that has moved. Do not maintain any of this by hand; it has now been wrong three times, and each time the error moved a code-executing job into the harmless-looking group. Derive it: @@ -537,7 +536,7 @@ are not "API-only" in the harmless sense either. `claude-repro` deserves the emphasis: only its `author` job is hardened. **`publish` — the job that runs the sanitizer over attacker-influenced text and holds `issues: write` — has no egress -policy at all.** Anyone citing "claude-repro enforces egress" is overstating it by three jobs. +policy at all.** Anyone citing "claude-repro enforces egress" is overstating it by every job but `author`. Keep this inventory correct when you add a job, and qualify per job rather than per workflow. Its first version omitted `bestaxbot-reply` and named `claude-repro` unqualified — a table that @@ -572,11 +571,11 @@ Two things about reading its output, both learned assembling the #578 lists: `productionresultssa.blob.core.windows.net`, `run-actions--azure-*.actions.githubusercontent.com` and `hosted-compute-*.githubapp.com` are the runner talking to its own control plane. The blob host cannot be pinned even in - principle — its name rotates per run (sa3, sa6, sa7, sa9, sa11, sa13 and sa17 so far, and the - list keeps growing every time anyone looks). Run + principle — its name rotates per run (`productionresultssa`, and N varies between runs). + Run 33221210633 is the evidence that omitting them is right: `auto-close-duplicates` at `block` - with the five-host list observed only `api.github.com` and `github.com`, and completed all - fifteen of its API calls under the firewall. + with its list observed only `api.github.com` and `github.com`, and completed every one of + its API calls under the firewall. **Leaving them out costs nothing, and that is measured rather than assumed.** harden-runner does not gate the runner's own control-plane traffic, so the Actions cache keeps working: @@ -587,8 +586,8 @@ Two things about reading its output, both learned assembling the #578 lists: cost. That was a guess, it was wrong, and the run is what showed it. If you catch yourself writing down what a policy will cost, go and read a run instead. -- **One run is not a measurement.** Vendor telemetry samples, so a host can be absent from four - runs and present in the fifth — `telemetry.vercel.com` (turbo) showed up exactly once across +- **One run is not a measurement.** Vendor telemetry samples, so a host can be absent from a + run and present in the next — `telemetry.vercel.com` (turbo) showed up exactly once across the six #578 runs. Prefer denying telemetry at the source (`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC`, `TURBO_TELEMETRY_DISABLED`) over allow-listing a host you would rather not talk to; that is the standing call here, and it also keeps the next audit report clean for whoever reads it. @@ -618,6 +617,34 @@ reviewers never see it, a session called "read-only" when its token was write-sc encoding check described as a proof when it is a backstop. A comment that overstates its control is worse than no comment, because the next reader stops checking. +### How to be exact: claim less + +Exact means claiming less, not explaining more. The way to make a security comment true is to +delete the clause that overstates it, not to add the qualifiers that would make it true. + +- A security comment names the control and, when the control is a third party's, cites where + it lives, in one line: `# matched against the run actor by check-human-actor in +anthropics/claude-code-action at the pinned SHA`. Do not restate the third party's + internals; the restatement rots the moment the pin moves, and nobody re-reads it then. +- A count lives in a command, never in a sentence. Write the grep that produces it (rule 10's + `egress-policy: block` grep is the model). A hand-maintained number is wrong within months, + and this file has been caught doing exactly that. +- Evidence (dates, tallies, measurements) goes on the issue and is linked. A comment says what + the mechanism is; the issue says how we know. A run id may stand in a workflow comment as the + receipt for the flip it justified, and nowhere in a guide. +- One home per fact. A mechanism is explained once, where it is configured; every other + mention is a pointer (`# same list as fix; rationale there`). Two copies are two things to + keep equal, and a reviewer will find the day they differ. +- Claim the case in front of you. "Every", "only", "always" and "exactly one" are one + counterexample from false; "the review arm" survives, "every arm" does not. +- Never cite a line number in prose. Lines move on the next edit; a step `id`, a job name, a + rule number, or a heading does not. +- A PR body says what changed and why. State an invariant as the command that checks it + (`grep -c` the pin), not as today's count of what it found. + +The `fragile-prose` conformance check enforces the count, run-id, and line-reference rules; +mark a deliberate exception with `bestax:count-ok` on the same line and say why. + ## Where the rest is documented - **Design and operation of the AI loop, and the repository-variable table** — the diff --git a/.github/workflows/ai-scan.yml b/.github/workflows/ai-scan.yml index 17c748269..9f49b2f2a 100644 --- a/.github/workflows/ai-scan.yml +++ b/.github/workflows/ai-scan.yml @@ -100,7 +100,7 @@ name: AI Security Scan # scheduled (runner outage, cancellation between jobs) leaves quota spent for a # session that never ran. Pre-split, a job that never started charged nothing. # -# A second, accepted: three named jobs are three public check runs, so `scan`'s +# A second, accepted: each named job is a public check run, so `scan`'s # conclusion (`skipped` when the gate declined, `success` when a session ran) # is now readable without log access. That leaks WHETHER an item was scanned — # and since the gate fails open, a budget-exhausted day is exactly the window @@ -560,7 +560,7 @@ jobs: # # The parsing lives in scripts/parse-scan-verdict.mjs (rule 9; the # fail-closed contract and its rationale are in that file's header, the - # seven-shape trace in its test sibling). The script always exits 0 and + # shape-by-shape trace in its test sibling). The script always exits 0 and # prints exactly `clean` or `flagged `; ANY other outcome — node # missing because the checkout failed under `always()`, a crashed script, # garbled output — yields an empty `verdict`, which the label job's enum diff --git a/.github/workflows/ai-triage.yml b/.github/workflows/ai-triage.yml index 23e1e5e12..87c0a3fe0 100644 --- a/.github/workflows/ai-triage.yml +++ b/.github/workflows/ai-triage.yml @@ -69,7 +69,7 @@ name: AI Triage # skip, zero usage. The counter is read-modify-write, so its correctness # DEPENDS on the global single-flight concurrency group below — do not # re-scope the group per item, and note the job split did not change that: -# `concurrency` is workflow-level, so it still serializes all four jobs. +# `concurrency` is workflow-level, so it still serializes every job. # # AI_TRIAGE_AUTOCLOSE (`on` / `dry-run` / unset ⇒ `off`) is passed to the # RENDERER, not to the session: when active, a dedupe comment that names a @@ -160,8 +160,8 @@ permissions: {} # third run arrives while one is in progress and one is pending, the PENDING # one is CANCELLED. So in a burst, only the first and last items get triaged, # and the ones in between leave no trace at all — cancelled, not skipped, so -# there is no gate log and no budget charge to explain the gap. The four-job -# split widens the in-progress window from one runner spin-up to four, which +# there is no gate log and no budget charge to explain the gap. The per-job +# split widens the in-progress window from one runner spin-up to one per job, which # makes the burst that loses items smaller. `cancel-in-progress: false` only # stops a NEW run killing a RUNNING one; it does not protect the queue. # @@ -940,7 +940,7 @@ jobs: NUMBER: ${{ needs.gate.outputs.number }} # The item-type pin binds this job too. Enforcing it only in the render # job put the guarantee in the job that cannot write, while this one - # holds the PAT and invokes all three commands. + # holds the PAT and invokes the commands. IS_PR: ${{ needs.gate.outputs.is_pr }} TRIGGER: ${{ github.event.action }} DEDUPE_BODY: ${{ needs.triage.outputs.dedupe_body }} diff --git a/.github/workflows/auto-close-duplicates.yml b/.github/workflows/auto-close-duplicates.yml index 56664bb23..a3cc2d3ab 100644 --- a/.github/workflows/auto-close-duplicates.yml +++ b/.github/workflows/auto-close-duplicates.yml @@ -63,7 +63,7 @@ jobs: # fail-open, where block was already observed arming on the nearest # sibling (security-txt-expiry.yml, run 31952287389). # - # The list is this job's needs, and it is deliberately the same five hosts + # The list is this job's needs, and it is deliberately the same hosts # that sibling allows: checkout and setup-node reach GitHub and nodejs.org, # and scripts/auto-close-duplicates.mjs is dependency-free (node: builtins # + global fetch) against api.github.com and nothing else. Adding no new diff --git a/.github/workflows/bestaxbot-reply.yml b/.github/workflows/bestaxbot-reply.yml index dbb7c8b51..694d9003d 100644 --- a/.github/workflows/bestaxbot-reply.yml +++ b/.github/workflows/bestaxbot-reply.yml @@ -78,15 +78,15 @@ jobs: id-token: write # OIDC exchange for the subscription token steps: # BLOCK, with the allowlist MEASURED rather than guessed (#578). This job's - # audit run was 33231488110; all six runs the flip is built on are recorded + # audit run was 33231488110; the runs the flip is built on are recorded # on that issue, each read out of its own `Post Harden runner` log rather # than transcribed from the StepSecurity UI. # - # The eight hosts are the set already enforcing on ai-scan/scan, + # These hosts are the set already enforcing on ai-scan/scan, # ai-triage/triage and claude-repro/author, and the measurement came out a # strict subset of it: objects.githubusercontent.com, # release-assets.githubusercontent.com and nodejs.org never appeared, because - # the Node 24 toolcache hit on every run. All three stay for the + # the Node 24 toolcache hit on every run. They stay for the # toolcache-miss path, and they are not interchangeable: the first two carry # the actions/node-versions manifest and its release assets, while nodejs.org # is setup-node v7 own fallback when that lookup misses. @@ -102,7 +102,7 @@ jobs: # telemetry, denied via TURBO_TELEMETRY_DISABLED below rather than # allow-listed — the same call this repo already makes for the CLI's Datadog # host. The Actions cache blob host cannot be pinned at all: its name rotates - # per run (productionresultssa3/6/7/9/11/13 across six runs). It does not need + # per run (productionresultssa, a different N per run). It does not need # to be. harden-runner does not gate the runner own control-plane traffic, and # run 33286967625 is the proof rather than the assumption: claude-review at # block, with neither that host nor results-receiver.actions.githubusercontent.com @@ -285,7 +285,7 @@ jobs: env: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' # Same call, second vendor: turbo phones telemetry.vercel.com from the - # pnpm commands this session runs. It appeared in only one of the six + # pnpm commands this session runs. It appeared in only one of the # audit runs because turbo samples, which is precisely the intermittent # that reddens a block job weeks after a flip. Denied, not allow-listed # (#578). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f22d8579..140b59bf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -220,7 +220,7 @@ jobs: # The npm pin is gone: it existed because OIDC trusted publishing needs # npm >= 11.5.1, and nothing here runs `npm publish` any more. Every # package publishes with `pnpm publish` (#436 for bestax-migrate, #532 - # for the other three), which carries its own OIDC exchange. + # for the others), which carries its own OIDC exchange. # # This does NOT mean the job has no use for npm. @semantic-release/npm # keeps its prepare step, which shells out to `npm version` to write the @@ -279,8 +279,8 @@ jobs: # No NPM_TOKEN: publishing authenticates via OIDC trusted publishing. # A present NPM_TOKEN takes precedence over OIDC and reintroduces the # EOTP (one-time password) failure on 2FA-protected accounts. - # All four release steps are identical on purpose: each package's - # release.config.js decides how it publishes, and since #532 all four + # All release steps are identical on purpose: each package's + # release.config.js decides how it publishes, and since #532 all of them # answer `pnpm publish` through the same shared helper. - name: Semantic Release (bulma-ui) working-directory: bulma-ui diff --git a/.github/workflows/claude-implement.yml b/.github/workflows/claude-implement.yml index 1343d3b0b..dd210d921 100644 --- a/.github/workflows/claude-implement.yml +++ b/.github/workflows/claude-implement.yml @@ -45,15 +45,15 @@ jobs: timeout-minutes: 60 steps: # BLOCK, with the allowlist MEASURED rather than guessed (#578). This job's - # audit run was 33230068020; all six runs the flip is built on are recorded + # audit run was 33230068020; the runs the flip is built on are recorded # on that issue, each read out of its own `Post Harden runner` log rather # than transcribed from the StepSecurity UI. # - # The eight hosts are the set already enforcing on ai-scan/scan, + # These hosts are the set already enforcing on ai-scan/scan, # ai-triage/triage and claude-repro/author, and the measurement came out a # strict subset of it: objects.githubusercontent.com, # release-assets.githubusercontent.com and nodejs.org never appeared, because - # the Node 24 toolcache hit on every run. All three stay for the + # the Node 24 toolcache hit on every run. They stay for the # toolcache-miss path, and they are not interchangeable: the first two carry # the actions/node-versions manifest and its release assets, while nodejs.org # is setup-node v7 own fallback when that lookup misses. @@ -65,11 +65,11 @@ jobs: # repo that runs setup-node allows it (auto-close-duplicates, # security-txt-expiry, deploy-worker, supply-chain/consumer-sbom). # - # Three observed hosts are deliberately absent. telemetry.vercel.com is + # Some observed hosts are deliberately absent. telemetry.vercel.com is # turbo's telemetry, denied via TURBO_TELEMETRY_DISABLED below rather than # allow-listed — the same call this repo already makes for the CLI's Datadog # host. The Actions cache blob host cannot be pinned at all: its name rotates - # per run (productionresultssa3/6/7/9/11/13 across six runs). It does not need + # per run (productionresultssa, a different N per run). It does not need # to be. harden-runner does not gate the runner own control-plane traffic, and # run 33286967625 is the proof rather than the assumption: claude-review at # block, with neither that host nor results-receiver.actions.githubusercontent.com @@ -77,7 +77,7 @@ jobs: # --frozen-lockfile. An earlier revision of this comment predicted a cache miss # here; it was wrong, and measuring it is what showed that. # - # The third is specific to this job, the only one of the six that touched it: + # The third is specific to this job, the only one that touched it: # private-user-images.githubusercontent.com, the session fetching an image # embedded in an issue body. The cost of leaving it out is real and small — a # `claude-fix` issue carrying a screenshot degrades, because the session @@ -259,9 +259,9 @@ jobs: # 250 turns is ~27m at the observed 6.4s/turn, still well inside # timeout-minutes: 60. # - # Four allowlist entries were added for those denials. The property - # each one is claimed on is narrow and stated per entry below; the - # only thing true of all four is that none can MUTATE the remote. + # Allowlist entries were added for those denials. The property each + # one is claimed on is narrow and stated per entry below; the only + # thing true of all of them is that none can MUTATE the remote. # `git fetch` does reach it, read-only and authenticated with the # ambient git credential, exactly as the checkout already is. # - `git fetch` DOES write, but only locally: FETCH_HEAD and diff --git a/.github/workflows/claude-pr-loop.yml b/.github/workflows/claude-pr-loop.yml index 654edf012..69c33a817 100644 --- a/.github/workflows/claude-pr-loop.yml +++ b/.github/workflows/claude-pr-loop.yml @@ -2,12 +2,9 @@ name: Claude PR Loop # Review → fix → verify → converge loop for AI-authored PRs (label: ai-loop) # ONLY. Triggered by CodeRabbit reviews, CI completions on claude/* branches, -# or manual dispatch; Copilot reviews are eligible too, but every run one has -# started so far died before creating a job, jobs have appeared only when a -# maintainer re-ran it, and since 2026-09-06 its reviews have created no run -# at all (the ai-development guide has the data). The `gate` job is cheap -# shell that re-derives the PR's live state and picks a mode — Claude usage -# is spent only in fix/verify. +# or manual dispatch; Copilot reviews are eligible too (#612). The `gate` job +# is cheap shell that re-derives the PR's live state and picks a mode — +# Claude usage is spent only in fix/verify. # # Modes: # fix-ci CI is red on the head → Claude fixes the root cause, pushes. @@ -332,18 +329,18 @@ jobs: actions: write # read CI logs + self-dispatch in the terminal step steps: # BLOCK, with the allowlist MEASURED rather than guessed (#578). This job's - # audit run was 33233097667; all six runs the flip is built on are recorded + # audit run was 33233097667; the runs the flip is built on are recorded # on that issue, each read out of its own `Post Harden runner` log rather # than transcribed from the StepSecurity UI. # # The sibling sweep/gate/handoff/halt jobs make GitHub API calls only and # carry no harden-runner; only `fix` and `verify` execute repo code. # - # Eight of the nine hosts are the set already enforcing on ai-scan/scan, + # Apart from one addition, these hosts are the set already enforcing on ai-scan/scan, # ai-triage/triage and claude-repro/author, and the measurement came out a # strict subset of it: objects.githubusercontent.com, # release-assets.githubusercontent.com and nodejs.org never appeared, because - # the Node 24 toolcache hit on every run. All three stay for the + # the Node 24 toolcache hit on every run. They stay for the # toolcache-miss path, and they are not interchangeable: the first two carry # the actions/node-versions manifest and its release assets, while nodejs.org # is setup-node v7's own fallback when that lookup misses. @@ -361,8 +358,8 @@ jobs: # host. That one matters most here: this job's session runs `pnpm test` and # `pnpm run` on nearly every iteration, so turbo is invoked constantly, and # turbo samples its telemetry. The Actions cache blob host cannot be pinned - # at all: its name rotates per run (productionresultssa3/6/7/9/11/13 across - # six runs). It does not need to be — harden-runner does not gate the + # at all: its name rotates per run (productionresultssa, a different N per + # run). It does not need to be — harden-runner does not gate the # runner's own control-plane traffic, and run 33286967625 showed # claude-review at block restoring its pnpm cache and completing # pnpm install --frozen-lockfile with neither that host nor @@ -496,7 +493,7 @@ jobs: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' # Same call, second vendor: turbo phones telemetry.vercel.com from the # pnpm commands this session runs, and this loop runs them on nearly - # every iteration. It appeared in only one of the six audit runs because + # every iteration. It appeared in only one of the audit runs because # turbo samples, which is precisely the intermittent that reddens a # block job weeks after a flip. Denied, not allow-listed (#578). TURBO_TELEMETRY_DISABLED: '1' @@ -513,37 +510,14 @@ jobs: bot_id: '300268469' claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # Trigger actors are bots by design: coderabbitai[bot] and Copilot - # (reviews), claude[bot] (CI workflow_run after its own pushes), and - # github-actions[bot] (the loop's self-dispatch continuation). The - # action matches this list against the RUN's actor by exact - # normalised login, and fix/verify run in the gate's own run, so a - # login the gate triggers on but this list omits fails the session's - # human-actor check: a red run rather than a wait (#612). The - # action's write-permission check is a second, narrower consumer: - # it runs only in PR and issue contexts (here, the review arm) and - # returns true for any `[bot]`-suffixed actor before reading this - # list, so it reaches the list only for `Copilot`, which has no - # suffix. On workflow_run, dispatch and schedule only the - # human-actor check consults it. - # - # `copilot` is the Copilot reviewer app (id 175728472), which - # renders as `Copilot` to Actions (the run actor, verified across - # every pull_request_review run this repo has had from it) and as - # `copilot-pull-request-reviewer[bot]` in the event payload, which - # is what the gate's `if:` matches; each list carries only the - # spelling it is compared against. Matching here is by normalised - # login, not by id: GitHub's other Copilot app, - # copilot-swe-agent[bot] (id 198982749), also renders as `Copilot`, - # so this entry admits either. What confines it is the gate, not - # the precision of this string, and each arm holds different parts - # of it: the review and workflow_run arms of the `if:` carry the - # same-repo and claude/* head tests, the workflow_dispatch arm - # carries neither and relies on GitHub only letting write-access - # actors dispatch (see the note under the `if:`), and the ai-loop - # label, PR-open and protected-path tests are re-derived in the - # gate job's shell on every arm while the two head tests are not. - # A CI completion with Copilot as the actor, on a claude/* head, - # therefore still passes all of them before fix runs. + # (reviews), claude[bot] (CI workflow_run after its own pushes), + # github-actions[bot] (the loop's self-dispatch). The action matches + # this list against the RUN's actor (check-human-actor in + # anthropics/claude-code-action at the pinned SHA). `copilot` is the reviewer app's actor spelling; the + # payload spelling `copilot-pull-request-reviewer[bot]` is what the + # gate's `if:` matches. copilot-swe-agent[bot] renders the same, so + # the gate's same-repo and claude/* head tests are the confinement, + # not this string. Evidence: #612. allowed_bots: 'coderabbitai,claude,github-actions,copilot' use_commit_signing: true # Stream the full turn-by-turn (tool calls + permission denials) into @@ -927,13 +901,14 @@ jobs: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' # Same call, second vendor: turbo phones telemetry.vercel.com from the # pnpm commands this session runs, and this loop runs them on nearly - # every iteration. It appeared in only one of the six audit runs because + # every iteration. It appeared in only one of the audit runs because # turbo samples, which is precisely the intermittent that reddens a # block job weeks after a flip. Denied, not allow-listed (#578). TURBO_TELEMETRY_DISABLED: '1' uses: anthropics/claude-code-action@51c47629174b5dbca955cf93690fd39c8a41dadf # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + # Same list as fix; the rationale is on fix's entry. allowed_bots: 'coderabbitai,claude,github-actions,copilot' # Stream tool calls + denials to the log (blob-hosted artifact is # blocked by the proxy) so a rebuttal pass is diagnosable. diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 5b84dfb91..227ab59a3 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -67,15 +67,15 @@ jobs: timeout-minutes: 30 steps: # BLOCK, with the allowlist MEASURED rather than guessed (#578). This job's - # audit runs were 33230210532 and 33230112308; all six runs the flip is + # audit runs were 33230210532 and 33230112308; the runs the flip is # built on are recorded on that issue, each read out of its own # `Post Harden runner` log rather than transcribed from the StepSecurity UI. # - # Eight of the nine hosts are the set already enforcing on ai-scan/scan, + # Apart from one addition, these hosts are the set already enforcing on ai-scan/scan, # ai-triage/triage and claude-repro/author, and the measurement came out a # strict subset of it: objects.githubusercontent.com, # release-assets.githubusercontent.com and nodejs.org never appeared, because - # the Node 24 toolcache hit on every run. All three stay for the + # the Node 24 toolcache hit on every run. They stay for the # toolcache-miss path, and they are not interchangeable: the first two carry # the actions/node-versions manifest and its release assets, while nodejs.org # is setup-node v7 own fallback when that lookup misses. @@ -91,7 +91,7 @@ jobs: # telemetry, denied via TURBO_TELEMETRY_DISABLED below rather than # allow-listed — the same call this repo already makes for the CLI's Datadog # host. The Actions cache blob host cannot be pinned at all: its name rotates - # per run (productionresultssa3/6/7/9/11/13 across six runs). It does not need + # per run (productionresultssa, a different N per run). It does not need # to be, and this job is where that was proven: run 33286967625 ran it at # block on a branch, with neither that host nor # results-receiver.actions.githubusercontent.com allow-listed, and setup-node @@ -486,7 +486,7 @@ jobs: env: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' # Same call, second vendor: turbo phones telemetry.vercel.com from the - # pnpm commands this session runs. It appeared in only one of the six + # pnpm commands this session runs. It appeared in only one of the # audit runs because turbo samples, which is precisely the intermittent # that reddens a block job weeks after a flip. Denied, not allow-listed # (#578). diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index e1fb7715c..7f228cec5 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -62,7 +62,7 @@ jobs: timeout-minutes: 30 steps: # BLOCK, with the allowlist MEASURED rather than guessed (#578). This job's - # audit run was 33231511797; all six runs the flip is built on are recorded + # audit run was 33231511797; the runs the flip is built on are recorded # on that issue, each read out of its own `Post Harden runner` log rather # than transcribed from the StepSecurity UI. # @@ -71,11 +71,11 @@ jobs: # to the jobs that do. `pnpm/action-setup` pulls pnpm from registry.npmjs.org # either way, so the missing install step narrows nothing. # - # The eight hosts are the set already enforcing on ai-scan/scan, + # These hosts are the set already enforcing on ai-scan/scan, # ai-triage/triage and claude-repro/author, and the measurement came out a # strict subset of it: objects.githubusercontent.com, # release-assets.githubusercontent.com and nodejs.org never appeared, because - # the Node 24 toolcache hit on every run. All three stay for the + # the Node 24 toolcache hit on every run. They stay for the # toolcache-miss path, and they are not interchangeable: the first two carry # the actions/node-versions manifest and its release assets, while nodejs.org # is setup-node v7 own fallback when that lookup misses. @@ -91,7 +91,7 @@ jobs: # telemetry, denied via TURBO_TELEMETRY_DISABLED below rather than # allow-listed — the same call this repo already makes for the CLI's Datadog # host. The Actions cache blob host cannot be pinned at all: its name rotates - # per run (productionresultssa3/6/7/9/11/13 across six runs). It does not need + # per run (productionresultssa, a different N per run). It does not need # to be. harden-runner does not gate the runner own control-plane traffic, and # run 33286967625 is the proof rather than the assumption: claude-review at # block, with neither that host nor results-receiver.actions.githubusercontent.com @@ -237,7 +237,7 @@ jobs: env: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' # Same call, second vendor: turbo phones telemetry.vercel.com from the - # pnpm commands this session runs. It appeared in only one of the six + # pnpm commands this session runs. It appeared in only one of the # audit runs because turbo samples, which is precisely the intermittent # that reddens a block job weeks after a flip. Denied, not allow-listed # (#578). diff --git a/.github/workflows/security-txt-expiry.yml b/.github/workflows/security-txt-expiry.yml index 8034cf1a6..a1e539294 100644 --- a/.github/workflows/security-txt-expiry.yml +++ b/.github/workflows/security-txt-expiry.yml @@ -80,10 +80,10 @@ jobs: # reach rather than from a measured run; widen it from a StepSecurity # report if one is ever refused. The nearest sibling # (auto-close-duplicates.yml, also a scheduled issue-writer) now carries - # the same policy and the same five hosts — its script is dependency-free + # the same policy and the same hosts — its script is dependency-free # and API-only too, so the list covers both. # - # Do not upgrade that to "these five always resolve" — the list is derived + # Do not upgrade that to "they always resolve" — the list is derived # from documented reach and confirmed by observed runs, which is not a # guarantee about future ones, and the two failure modes are not symmetric. A host that fails to resolve at STARTUP makes the agent # revert the firewall and never write `Initialized`, so the assertion below diff --git a/.github/workflows/story-screenshots.yml b/.github/workflows/story-screenshots.yml index d194295af..56ad1f1a8 100644 --- a/.github/workflows/story-screenshots.yml +++ b/.github/workflows/story-screenshots.yml @@ -415,7 +415,7 @@ jobs: # when capture fails — and a skipped publish is silence, which is exactly # what a healthy "nothing to screenshot" run looks like to the reviewer. A # Playwright install that 500s, or the 20-minute timeout expiring on a slow - # runner at the 24-story cap, would read as "no CSS to look at here". Say it + # runner at the story cap, would read as "no CSS to look at here". Say it # out loud instead, through the same upserted comment so the notice also # supersedes images from an earlier run rather than leaving them to pass as # current. diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index 2c0650f49..db74b033b 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -83,7 +83,7 @@ jobs: # How much smaller, measured rather than asserted — read out of the documents # produced by run 33260691933 (2026-08-29), not carried forward: # - # @allxsmith/bestax-bulma 5 packages (7 entries shipped) + # @allxsmith/bestax-bulma 5 packages (7 entries shipped) bestax:count-ok: a scale, not a checksum # create-bestax 12 (14) # bestax-migrate 98 (100) # bestax-mcp 93 (95) @@ -98,7 +98,7 @@ jobs: # # These are LIVE REGISTRY closures and they drift whenever any transitive # dependency publishes, so treat them as a scale, not a checksum — bestax-mcp - # read 94 here until it was re-measured, against 95 entries it could never + # read 94 here until it was re-measured, against 95 entries it could never (bestax:count-ok: the drift is the point) # have been consistent with. Nothing enforces them, deliberately: pinning # counts as a gate is what 48c57d5 reverted. Re-measure from a dispatch when # touching this job rather than editing the numbers to taste. @@ -108,8 +108,8 @@ jobs: # orders of magnitude, and obscures a genuinely tiny runtime closure that is # a selling point. # - # Per package, not one combined tree. Installing all four together resolves to - # 204 packages, which would bury bulma-ui's five-package closure under the + # Per package, not one combined tree. Installing them together resolves to + # 204 packages, which would bury bulma-ui's five-package closure under the (bestax:count-ok: scale) # CLIs' dependencies and reproduce the exact problem this job exists to fix. # # Generation only, like `sbom`. Stated precisely: this job does hold a @@ -124,7 +124,7 @@ jobs: permissions: contents: read strategy: - # One package failing must not hide the other three. They are independent + # One package failing must not hide the rest. They are independent # documents; there is nothing to be gained by cancelling the siblings. fail-fast: false matrix: @@ -139,8 +139,8 @@ jobs: slug: bestax-mcp steps: - # Rule 10: a new job ships at `block`. This file has five jobs; the three - # that carry no harden-runner (`sbom`, `attach-sbom`, `verify-provenance`) + # Rule 10: a new job ships at `block`. The jobs in this file that carry + # no harden-runner (`sbom`, `attach-sbom`, `verify-provenance`) # predate the rule and are grandfathered by its "existing live job" clause. # This one and `sign-sbom` are not, and both enforce. # @@ -266,7 +266,7 @@ jobs: # Syft to read. # # An earlier draft installed with `--omit=peer` and deleted the lockfile - # before scanning, to publish only "what we add" — three packages for + # before scanning, to publish only "what we add" — three packages for (bestax:count-ok: scale) # bulma-ui instead of five. That was wrong on every axis, which is worth # recording so nobody reintroduces it: # @@ -293,11 +293,11 @@ jobs: # evidence. # # The point of #424 is already carried by scanning a consumer tree at all: - # five packages for bulma-ui against the whole dev toolchain. Trading - # away digests to reach three was a bad bargain. + # five packages for bulma-ui against the whole dev toolchain. Trading (bestax:count-ok: scale) + # away digests to reach a smaller tree was a bad bargain. # # `--omit=optional` is deliberately NOT used either. It would cut - # bestax-migrate from 98 packages to 57, but consumers really do get + # bestax-migrate from 98 packages to 57, but consumers really do get (bestax:count-ok: scale) # optional dependencies, and an SBOM that omits them understates the tree. # `--ignore-scripts` is a deliberate divergence from a real consumer's # install, not an oversight. It matches this repository's standing posture @@ -334,7 +334,7 @@ jobs: # replace — leaving the release permanently carrying two # contradictory consumer SBOMs for the same package. # - # The other three legs necessarily stay on `latest`: a release says + # The remaining legs necessarily stay on `latest`: a release says # nothing about them, and a consumer SBOM describes what a consumer # installs today. That asymmetry is why the decision is a script with a # test rather than an expression here — the tag has to be matched to its @@ -357,7 +357,7 @@ jobs: PACKAGE: ${{ matrix.package }} EVENT: ${{ github.event_name }} TAG: ${{ github.event.release.tag_name }} - # Writes `spec` and `expect` itself. `expect` is empty for the three + # Writes `spec` and `expect` itself. `expect` is empty for the # legs no release names, and the workflow does no parsing of its own — # deriving the version from the spec here would reimplement the # last-`@` split in untested YAML, one character from being wrong for @@ -405,8 +405,8 @@ jobs: # A scan directory holding ONLY the lockfile (#530 item 5). syft used # to be pointed at the consumer tree and recursively walked every file - # under node_modules — thousands of them for bestax-migrate's - # 98-package closure, twice per matrix leg — to satisfy a cataloger + # under node_modules for bestax-migrate's + # 98-package closure, twice per matrix leg — to satisfy a cataloger (bestax:count-ok: scale) # that reads the root lockfile and nothing else. # # A copied directory rather than the lockfile itself, and that is not @@ -443,7 +443,7 @@ jobs: # syft's github-actions cataloger was picking up `.github/workflows` # YAML that some upstream packages ship inside their npm tarballs, so # the SBOM listed `actions/checkout@v4` and `actions/setup-node@v4` as - # if they were dependencies — bestax-migrate at 111 entries against a + # if they were dependencies — bestax-migrate at 111 entries against a (bestax:count-ok: scale) # real closure of 98. # # Selecting the whole `javascript` group fixed that and broke something @@ -455,7 +455,7 @@ jobs: # ends up as SPDX downloadLocation. # # 2. `source.name` — every document was named - # `/home/runner/work/_temp/consumer`, identical across all four, so the + # `/home/runner/work/_temp/consumer`, identical across every leg, so the # artifact leaked the runner's filesystem layout and did not say which # package it described. It also appeared as a package entry with a null # version. @@ -464,7 +464,7 @@ jobs: # configured source as its own SPDX package, so naming it plainly after # the target made the target appear TWICE — once from the lock # cataloger with its registry URL, once as the source with - # NOASSERTION. Seven entries for a five-package closure. The prefix + # NOASSERTION. Seven entries for a five-package closure. The prefix (bestax:count-ok: scale) # keeps the document self-identifying while ensuring every actual # closure package appears exactly once. # @@ -717,14 +717,14 @@ jobs: # mode is "SBOMs attach unsigned plus a ::warning::". Widen the list from # the first real run's report rather than by guessing again. # - # Sigstore hosts are the four in its published signing config + # Sigstore hosts are those in its published signing config # (tuf-repo-cdn serves that config; fulcio issues the cert; rekor logs it; # timestamp countersigns the bundle). `oauth2.sigstore.dev` is the fifth # entry there and is deliberately omitted: it serves the interactive OIDC # flow, and this job uses the ambient GitHub Actions credential instead. # # `*.blob.core.windows.net` is deliberately absent, and this note exists - # because three separate review passes have now proposed adding it. The + # because review passes keep proposing to add it. The # artifact actions do reach Azure blob storage, but #487 measured a real # run and found those hosts (`results-receiver.actions.githubusercontent # .com`, `productionresultssa9.blob.core.windows.net`) already covered by @@ -739,8 +739,8 @@ jobs: # egress policy in this repository into something close to open egress. If # a real run ever shows a blocked blob endpoint, add that exact host. # - # Of this file's four other jobs, three (`sbom`, `attach-sbom`, - # `verify-provenance`) predate rule 10 and are grandfathered by its + # Of this file's other jobs, `sbom`, `attach-sbom` and + # `verify-provenance` predate rule 10 and are grandfathered by its # "existing live job" clause. `consumer-sbom` is not grandfathered and # enforces, same as this job — do not read the grandfather clause as # covering it. @@ -964,7 +964,7 @@ jobs: # The provenance assertion moved out of this file and into # scripts/verify-attestation.mjs (#526), because checking what an # attestation *says* means decoding a base64 DSSE payload and comparing - # nine fields — rule 9 logic, not a shell loop. So the job now needs the + # the required provenance fields — rule 9 logic, not a shell loop. So the job needs the # repository on disk. # # This adds a second third-party action to a job that had one, which is @@ -1040,7 +1040,8 @@ jobs: # digest is the one the attestation signs, and that the build it describes # is this repository, this workflow, this ref, on a GitHub-hosted runner. # The logic lives in scripts/ with a node --test sibling, per rule 9, - # because it decodes a base64 DSSE payload and compares nine fields; that + # because it decodes a base64 DSSE payload and compares the required + # provenance fields; that # is not something a shell loop can be tested for. # # Fails closed everywhere: a fetch error, an unparsable payload, or a diff --git a/CLAUDE.md b/CLAUDE.md index cfc40736b..3c553ac6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,8 +110,8 @@ Full versioning details (breaking-change footers, tag formats): `VERSIONING.md`. `pnpm publish` instead (#436 for bestax-migrate, #532 for the rest), through the shared `scripts/lib/pnpm-publish.mjs`, which buys each a **narrow** exemption: `workspace:`/`catalog:` in **devDependencies** only. `jsr:` becomes an aliased - `npm:@jsr/…` specifier and `link:`/`portal:`/`file:` are not rewritten at all, so those - four are a violation in **any** section, exemption or not. `workspace:`/`catalog:` are + `npm:@jsr/…` specifier and `link:`/`portal:`/`file:` are not rewritten at all, so each + of those is a violation in **any** section, exemption or not. `workspace:`/`catalog:` are additionally a violation in a section consumers resolve, since pnpm resolving them does not stop every consumer being made to install the dependency. Which packages publish with pnpm is **declared** in `check:conformance` rather than inferred from their release config — diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86bd7bfea..1419cec33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -313,14 +313,24 @@ This repo uses AI reviewers and an autonomous fix loop — full details in the d The short version for contributors: - **Every PR gets a CodeRabbit review** automatically. Address or refute its comments — it - re-reviews on each push and marks addressed comments "✅ Addressed". A human maintainer still - reviews and merges everything. + reviews incrementally and marks addressed comments "✅ Addressed". It also rate-limits, so a + push during a spent window waits for the next one; the AI-assisted section below says how to + nudge it. A human maintainer still reviews and merges everything. - **`@claude` mentions are maintainer-only** (they spend the maintainer's Claude usage). External contributors don't need them — just push your changes. - **Issues labeled `claude-fix`** are implemented autonomously: Claude opens a PR labeled `ai-loop` and iterates with the AI reviewers until it converges, then a human reviews and squash-merges. Don't add or remove the loop labels (`ai-loop`, `needs-human-review`, `ai-loop-paused`) on PRs you don't own — they are the loop's state machine. +- **Hand-driven PRs that want a deep review**: apply `deep-review` once at open, fix everything + it raised, then re-apply it once. The re-run verifies its own threads and reviews the commits + since its last review, or falls back to a full review whenever it cannot establish a + trustworthy linear delta between them — a rewritten history, a merge from the base, or a + retarget that moves the merge base. CodeRabbit reviews incrementally + on its own and rate-limits, so let it go last and nudge it with `@coderabbitai review` when + its window reopens. + Do not relabel per push: each application spends a full opus session, and relabeling after + every fix is what turned #643 into 14 review rounds. - **PR titles must be scoped conventional commits** — the title becomes the squash commit and drives semantic-release (see [Commit Message Guidelines](#commit-message-guidelines)). diff --git a/bestax-mcp/CLAUDE.md b/bestax-mcp/CLAUDE.md index 827ca0e54..f0fba2d8f 100644 --- a/bestax-mcp/CLAUDE.md +++ b/bestax-mcp/CLAUDE.md @@ -12,7 +12,7 @@ Skill. Run it with `npx bestax-mcp`. `pnpm gen:mcp` (root) runs `scripts/gen-mcp-index.mjs`, which reuses the same extraction the API docs use — `scripts/lib/props-extract.mjs` in `markdown: false` mode, `scss-vars.mjs`, `api-sources.mjs` — plus the -hand-written `## Usage` / `## Accessibility` sections of the 87 API pages. +hand-written `## Usage` / `## Accessibility` sections of the API pages. - **Never hand-edit `data/`.** CI fails on staleness (`pnpm gen:mcp:check`). - `data/catalog.json` and `data/components/*.json` are **committed**. @@ -21,7 +21,7 @@ hand-written `## Usage` / `## Accessibility` sections of the 87 API pages. `test:watch` and `prepack`. Only the manifest (`data/skills.json`) is committed — enough for the staleness gate to catch a new or renamed skill, without putting a second copy of ~390 KB of markdown in every skill diff. - Turbo runs the first three of those concurrently, so that script takes a lock + Several Turbo tasks can invoke that script at the same time, so it takes a lock and no-ops when the tree already matches the source; keep both properties if you touch it, and see its trailing "Concurrency" note for why the freshness check is required rather than merely an optimisation. @@ -62,12 +62,12 @@ the API pages and the skill catalog. ## Dependencies -Three. `@modelcontextprotocol/sdk` and `zod` are the two that are imported (the +`@modelcontextprotocol/sdk` and `zod` are the ones that are imported (the SDK's schema types are zod, and pnpm's isolated linker means anything imported -must be declared). The SDK pulls ~90 transitive packages, most of them for HTTP -transports this server does not use — that was measured against -`pnpm audit --audit-level=high` before adopting it, and it comes back clean. -Re-check if that ever changes. +must be declared). The SDK pulls a large tree of transitive packages, most of them +for HTTP transports this server does not use. That footprint was weighed against +what the server needs before adopting it, and `pnpm audit --audit-level=high` +comes back clean on the result. Re-check both if that ever changes. `@allxsmith/bestax-bulma` is declared, not imported (#644): the server is built for the library and its manifest says so, the way bulma-ui declares `bulma`. diff --git a/bestax-migrate/CLAUDE.md b/bestax-migrate/CLAUDE.md index 2604fc586..c31da3b4f 100644 --- a/bestax-migrate/CLAUDE.md +++ b/bestax-migrate/CLAUDE.md @@ -2,7 +2,7 @@ jscodeshift-based CLI (`pnpm dlx bestax-migrate `) that migrates existing apps from other React Bulma libraries to `@allxsmith/bestax-bulma`. Multi-source by design: -each source library registers in `src/sources/registry.ts`. Three are shipped — +each source library registers in `src/sources/registry.ts`. The shipped ones are — `react-bulma-components` (v4 only), `rbx` (v2 only) and `bloomer` (0.6 only). ## Hard rules @@ -28,13 +28,13 @@ each source library registers in `src/sources/registry.ts`. Three are shipped import), `imports.ts` (binding collection and import aliasing), `specials-utils.ts` (`alignTarget`, `mergeClassName`, `parseIconClasses`, `modifierClass`, `restrictAsToTargets`, the `stripModifierProps` factory and the `makeStructuralHelpers` factory behind - `replaceWithPlain`/`collapseOntoChild`), `viewports.ts` (Bulma's nine viewports → bestax's + `replaceWithPlain`/`collapseOntoChild`), `viewports.ts` (Bulma's viewports → bestax's prop suffixes), and `make-styles-transform.ts` (the whole Bulma 0.9→v1 stylesheet transform, parameterised by the source package's own specifiers). What stays per-source is the data — `mapping.ts`, `specials.ts`, `responsive.ts`, `deps.ts` — plus a `transform.ts` that orchestrates them. bloomer's is the plain one: its exports are flat, so it has no destructuring pass, no alias registry and no wrapping pass; the rbx and RBC copies still - carry all three. + carry all of them. - `''` must be added to `MIGRATE_SOURCE_VALUES` in `telemetry-worker/src/schema.ts` or its events are dropped at ingest; `check:conformance --only=telemetry-allowlists` fails until it is. That worker deploys to production on merge, so the two land together. @@ -47,7 +47,7 @@ each source library registers in `src/sources/registry.ts`. Three are shipped - **A TODO message is a claim about bestax; read the component before writing it.** Four shipped on #613 were false (Modal "always closes on Escape"; a Field TODO naming `isGrouped`). The rbx e2e fails on an undocumented rule (RBC's does not); nothing checks the guidance is true. -- **A defect in one source's `transform.ts` is almost certainly in the others.** Nine fixes were +- **A defect in one source's `transform.ts` is almost certainly in the others.** The fixes were ported RBC↔rbx on #613 and review kept finding the unported half. Fix the siblings in the same commit, or move the logic into `_shared/` (the alias registry, the literal/object helpers and the structural-handler helpers went there on #410 for exactly this reason). @@ -59,7 +59,7 @@ each source library registers in `src/sources/registry.ts`. Three are shipped bulma" in most manifest shapes where nothing was bumped. Track each mutation; phrase from it. - **The stylesheet and manifest passes must agree on what is removable.** Both report rather than remove `bulma-*` extensions, since markup outside the source may still use their classes. - rbx enumerates its four in `deps.ts` and `transform.ts`; the shared Sass pass flags any `bulma-*`. + rbx enumerates them in `deps.ts` and `transform.ts`; the shared Sass pass flags any `bulma-*`. - **Never change `bulma-ui` to make a migration cleaner.** Map onto the library as it is and emit a TODO otherwise. A gap earns a `bulma-ui` issue only if it is a bestax defect or the source is genuinely better, not merely different (#616 to #622 are the worked example). @@ -110,7 +110,7 @@ each source library registers in `src/sources/registry.ts`. Three are shipped - `validate:corpus` — react-bulma-components' own MIT Storybook stories → `.e2e-tmp/corpus-out/`. - `validate:corpus:rbx` — rbx's own MIT docs. rbx used **docz**, not Storybook, so the - script extracts the `` blocks out of its 43 `*.docs.mdx` pages (254 blocks) + script extracts the `` blocks out of its `*.docs.mdx` pages and rebuilds each page as one synthetic module written the way a consumer writes rbx. Output lands in `.e2e-tmp/corpus-out-rbx/`. - `validate:corpus:bloomer` — bloomer's own MIT docs: 39 React "Scene" `.tsx` files that @@ -118,7 +118,7 @@ each source library registers in `src/sources/registry.ts`. Three are shipped specifier becomes `'bloomer'`. Output lands in `.e2e-tmp/corpus-out-bloomer/`. The corpus is the only check that sees breadth; the kitchen-sink e2e is the only one that - sees bestax's _real_ prop names. Both are needed — the rbx e2e's typecheck caught six + sees bestax's _real_ prop names. Both are needed — the rbx e2e's typecheck caught mapping errors that 254 clean Playgrounds had not. - After editing a test file, check the total test count did not drop: a range replacement diff --git a/bulma-ui/CLAUDE.md b/bulma-ui/CLAUDE.md index 2fe111573..5d3e9fe70 100644 --- a/bulma-ui/CLAUDE.md +++ b/bulma-ui/CLAUDE.md @@ -17,7 +17,7 @@ npm package so consumers can build the styles themselves. ## The component anatomy rule -A new or changed component is **five artifacts, not one**. Touch all of: +A new or changed component is **five artifacts, not one**. Touch all of: 1. `src//Foo.tsx` — the component 2. `src//__tests__/Foo.test.tsx` — tests (coverage threshold: **99%**, `jest.config.js`) @@ -52,7 +52,7 @@ improvising. name plus a `**Types:**` footnote built from the alias's own TSDoc. - Multi-part components attach sub-components as statics via `withSubComponents` (`src/helpers/withSubComponents.ts`) — it must mutate the base (identity-preserving), - never wrap it. A compound family ships four artifacts beyond the base anatomy rule: + never wrap it. A compound family ships these artifacts beyond the base anatomy rule: an identity test per static (`expect(Parent.Sub).toBe(Sub)`, or `toBeDefined()` + dot-path render for module-private subs) in a `describe('Compound components')` block, a `CompoundUsage` story, and a `### Compound (dot-notation) usage` live example at the diff --git a/bulma-ui/src/components/CLAUDE.md b/bulma-ui/src/components/CLAUDE.md index ea6ab57be..5e1e44529 100644 --- a/bulma-ui/src/components/CLAUDE.md +++ b/bulma-ui/src/components/CLAUDE.md @@ -11,7 +11,7 @@ Conventions: - Multi-part components use the compound pattern — `Card.Header`, `Navbar.Item`, `Modal.Card` — statics attached via `withSubComponents` (`../helpers/withSubComponents.ts`, mutates the - base, never wraps), exported from the same file. Each compound family ships the four + base, never wraps), exported from the same file. Each compound family ships the compound artifacts listed under Conventions in `bulma-ui/CLAUDE.md`: identity tests in a `describe('Compound components')` block, a dot-path render test, a `CompoundUsage` story, and a `### Compound (dot-notation) usage` docs example. diff --git a/bulma-ui/src/scss/CLAUDE.md b/bulma-ui/src/scss/CLAUDE.md index edfbec664..a1a375efb 100644 --- a/bulma-ui/src/scss/CLAUDE.md +++ b/bulma-ui/src/scss/CLAUDE.md @@ -63,6 +63,6 @@ Rules the pattern encodes: prefix together: name your variables `-*` or the matcher assigns them to nobody. After adding one, run `pnpm gen:api-sources` and confirm the component's `SCSS_SOURCES` entry is non-empty — an empty entry silently suppresses the API page's - CSS & Sass Variables section, which is how LinkButton shipped four variables invisibly + CSS & Sass Variables section, which is how LinkButton shipped its variables invisibly (#464). The orphan rule in `check:conformance` now fails on a partial that no component claims, so the miss is loud, but the fix is still yours to make. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index df5106988..fab66d952 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -75,7 +75,7 @@ it, so a novel non-standard `package.json` key and extra release churn weren't w empty and grows one category per PR, so a page is only generated once its category lands. - Section order on a managed page: Overview, Import, Usage, _(page-specific extras)_, Accessibility, Related Components, Additional Resources, Props, CSS & Sass Variables. - Every category except `helpers/` is managed; the four hook pages there have no `## Props` + Every category except `helpers/` is managed; the hook pages there have no `## Props` at all, and `config.md`/`theme.md` follow the order but keep their hand-written tables. - The `props` region owns the tables and nothing else. Prose inside `## Props` is preserved above the opening marker or below the closing one — but prose sitting _between_ two tables @@ -106,7 +106,7 @@ it, so a novel non-standard `package.json` key and extra release churn weren't w are required — without them MDX treats it as literal text, not a code block. The component derives the command back out of the fence and throws during the prerender if the round trip isn't exact, so a non-canonical fence (`npm install foo`, odd spacing) fails the build rather - than rendering three tabs derived from something the page never showed. Docs-tree pages + than rendering tabs derived from something the page never showed. Docs-tree pages only — a syndicated blog post must not use it (JSX reaches dev.to as raw text); see the Syndication section of `blog/CLAUDE.md`. @@ -114,6 +114,18 @@ it, so a novel non-standard `package.json` key and extra release churn weren't w `docusaurus.config.js` does not override it. That is load-bearing but easy to miss: setting `format: 'detect'` would make every `.md` page render its tags as literal text. - Markdown is prettier-formatted (`pnpm format:check` covers `md`/`mdx`). +- **Guides are canonical reference, not lab notebooks.** No run statistics, run ids, dated + observations or "as of" tallies in `docs/docs/**`: the LLM index serves it as current fact, + and a count is stale the day after it is written. Dated snapshots belong in `blog/`, which + is excluded from the index (see `blog/CLAUDE.md`). Evidence goes on the issue or PR and the + guide links it. A claim that needs a number to be true belongs in a test or a generator, not + a sentence. A guide section says what the operator can do and what each switch is compared + against; it does not re-derive a third party's internals. Claim the case in front of you: + "every" and "only" are one counterexample from false. Never cite a line number; cite a + heading, a step id, or a flag. `check:conformance --only=fragile-prose` enforces the count, + run-id, and line-reference parts over `docs/docs/guides/**`; the migration guides are exempt + because their counts describe a frozen upstream, and the API pages are generated. The rule + still holds everywhere in `docs/docs/**` — the check is a net, not the contract. - Scripts in `docs/scripts/` are covered by the root `pnpm lint` — run it before pushing even a docs-only PR (#471 broke CI on exactly this). Playwright `page.evaluate` callbacks execute in the browser, so declare the browser globals each callback actually uses diff --git a/docs/blog/CLAUDE.md b/docs/blog/CLAUDE.md index 7e7887e66..a272a6eb4 100644 --- a/docs/blog/CLAUDE.md +++ b/docs/blog/CLAUDE.md @@ -35,13 +35,13 @@ version bump) and the intro of the first State of React edition: candid, plain, input](…#368)", not "#368: form label wires no htmlFor". A mechanism catalog stitched with insider anchors reads disjointed to everyone but the maintainer (#478's draft was rewritten for exactly this). -- **Work all three appeals.** A post should persuade on ethos, pathos, and logos together. +- **Work every appeal.** A post should persuade on ethos, pathos, and logos together. _Ethos_: write from first-hand maintainer experience, link your sources, and own the mistakes (the candor above is the credibility play). _Pathos_: name the pain a change removes and the small joy it adds; let the humor and era nods carry feeling, and never manufacture drama. _Logos_: back every claim with a reason, a number, a table, or runnable code (a live demo is an argument the reader can poke). Before publishing, check the draft - lands all three: all logos reads like a changelog, and all pathos reads like marketing. + lands each of them: all logos reads like a changelog, and all pathos reads like marketing. - **Structure the middle.** Hyphen bullets for detail dumps, numbered lists only for ordered procedures, bold on the load-bearing word, section headings even in medium-length posts. - **MLA conventions.** MLA title case for the post title and headings (first word, last word, @@ -147,7 +147,7 @@ mainstream React component libraries (Mantine, MUI, Chakra UI, shadcn/ui, React- react-bulma-components). Each edition is a point-in-time capture; we publish a fresh one on a roughly **monthly** cadence rather than editing an old post. -Three files back every edition: +These files back every edition: - `docs/src/data/componentComparison.js` — the matrix (capabilities × libraries), the per-cell link resolvers, and `lastReviewed`. **This is the source of truth** — the only file that @@ -170,7 +170,7 @@ Three files back every edition: markdown / dev.to), and `hide_table_of_contents: true` (the wide table needs the room — this removes the right-hand TOC; the left blog sidebar is collapsed automatically via a scoped `:has(.sor-comparison)` rule in `src/css/custom.css`, so no per-post action is needed). -- Keep the four admonitions: top `:::info` (snapshot date + latest-edition link), `:::tip` +- Keep the admonitions: top `:::info` (snapshot date + latest-edition link), `:::tip` (headline insight), `:::note` (shadcn is a copy-paste registry, not a dependency), `:::caution` (corrections → GitHub issues). diff --git a/docs/docs/guides/features/bulma-v1.md b/docs/docs/guides/features/bulma-v1.md index 6fe2ea846..d29de7ea4 100644 --- a/docs/docs/guides/features/bulma-v1.md +++ b/docs/docs/guides/features/bulma-v1.md @@ -17,7 +17,7 @@ Most React Bulma packages on npm still target Bulma v0.9.4. That means no CSS va | Feature | Other Libraries | bestax-bulma | | --------------------------- | ------------------------ | --------------------------------------------------------------------------------- | | **Bulma Version** | v0.9.4 or older | v1.0+ (latest) | -| **CSS Variables** | ❌ Not supported | ✅ Full support (500+ variables) | +| **CSS Variables** | ❌ Not supported | ✅ Full support | | **Runtime Theming** | ❌ Limited | ✅ Complete `Theme` component | | **CSS Grid** | ❌ Missing | ✅ True CSS Grid via `Grid` / `Cell` | | **Skeleton Loading** | ❌ Not available | ✅ Universal `skeleton` helper prop + dedicated `Skeleton` element | @@ -169,7 +169,7 @@ function FixedGrid() { ### Skeleton Loading States -bestax exposes Bulma v1's skeletons through three complementary APIs: +bestax exposes Bulma v1's skeletons through complementary APIs: 1. **`skeleton` helper prop** — available on every component that uses `useBulmaClasses` (so: Button, Box, Icon, Image, Notification, Tag, Input, TextArea, and most others). Renders Bulma's `is-skeleton` class for an in-place loading state. 2. **`` element** — a standalone placeholder for custom shapes and multi-line blocks (``). @@ -258,7 +258,7 @@ function SkeletonExamples() { | Feature | Description | Implementation | | -------------------- | --------------------------- | ------------------- | -| **CSS Variables** | 500+ customizable variables | Theme component | +| **CSS Variables** | Customizable variables | Theme component | | **Runtime Theming** | Dynamic theme switching | Theme + CSS vars | | **Class Prefixing** | Namespace CSS classes | ConfigProvider | | **Dark Mode** | Built-in dark theme support | Theme component | diff --git a/docs/docs/guides/features/configuration.md b/docs/docs/guides/features/configuration.md index e3c9c45a0..673459a04 100644 --- a/docs/docs/guides/features/configuration.md +++ b/docs/docs/guides/features/configuration.md @@ -215,7 +215,7 @@ function CustomComponent() { ## Configuration Hooks -Four hooks expose the active configuration so your own components can honor the same settings as bestax's built-ins. +These hooks expose the active configuration so your own components can honor the same settings as bestax's built-ins. ### useConfig diff --git a/docs/docs/guides/features/css-variables.md b/docs/docs/guides/features/css-variables.md index da375a2ec..dcc117a39 100644 --- a/docs/docs/guides/features/css-variables.md +++ b/docs/docs/guides/features/css-variables.md @@ -6,7 +6,7 @@ sidebar_position: 2 # CSS Variables -[Bulma v1 introduces comprehensive support for CSS custom properties (CSS variables)](https://bulma.io/documentation/features/css-variables/), enabling runtime customization without Sass compilation. bestax-bulma gives you a first-class React surface for all 500+ of them through the `Theme` component: named, camelCase props for the common variables, a `bulmaVars` escape hatch for the long tail, and automatic scoping so themes compose via React context — no manual style-sheet injection, no string concatenation, fully type-checked. +[Bulma v1 introduces comprehensive support for CSS custom properties (CSS variables)](https://bulma.io/documentation/features/css-variables/), enabling runtime customization without Sass compilation. bestax-bulma gives you a first-class React surface for all of them through the `Theme` component: named, camelCase props for the common variables, a `bulmaVars` escape hatch for the long tail, and automatic scoping so themes compose via React context — no manual style-sheet injection, no string concatenation, fully type-checked. :::info Bulma's canonical reference The authoritative documentation for every Bulma CSS variable lives on the Bulma site: @@ -150,7 +150,7 @@ function AdvancedTheming() { ## Complete CSS Variables Catalog -Bulma v1 provides 500+ CSS variables organized by category. Here are the key categories — for the full, authoritative list see the [Bulma CSS Variables reference](https://bulma.io/documentation/features/css-variables/) and the per-component pages under [bulma.io/documentation](https://bulma.io/documentation/). +Bulma v1 provides its CSS variables organized by category. Here are the key categories — for the full, authoritative list see the [Bulma CSS Variables reference](https://bulma.io/documentation/features/css-variables/) and the per-component pages under [bulma.io/documentation](https://bulma.io/documentation/). ### Scheme Variables diff --git a/docs/docs/guides/features/responsiveness.md b/docs/docs/guides/features/responsiveness.md index 3d9b87fc6..c4d6824f2 100644 --- a/docs/docs/guides/features/responsiveness.md +++ b/docs/docs/guides/features/responsiveness.md @@ -81,7 +81,7 @@ Use the `isMobile` property to maintain horizontal layout even on mobile devices ## Breakpoints -Bestax uses Bulma's 4 breakpoints to define 5 screen sizes: +Bestax uses Bulma's breakpoints to define its screen sizes: | Size | Range | CSS Variable | | -------------- | ----------- | -------------------- | diff --git a/docs/docs/guides/features/routing.md b/docs/docs/guides/features/routing.md index 4eb31f2dc..001a4107d 100644 --- a/docs/docs/guides/features/routing.md +++ b/docs/docs/guides/features/routing.md @@ -8,7 +8,7 @@ sidebar_position: 6 How to wire bestax-bulma components to a client-side router — React Router, Next.js, TanStack Router, or any library whose navigation primitive is a component you render `as`. This page -covers the three first-hour questions: making `Navbar.Item`/`Menu.Item` navigate, making a +covers the first-hour questions: making `Navbar.Item`/`Menu.Item` navigate, making a `Button` navigate, and styling the active route. All examples use React Router v6/v7 (`react-router-dom`); the Next.js differences are at the diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index 2d8eb6f60..0ed050926 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -47,19 +47,19 @@ the review-time requirements (Storybook story for UI changes, docs page for API PRs authored by the loop move through a small label lifecycle: -| Label | Where | Meaning | -| ----------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | -| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | -| `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge | -| `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene | -| `deep-review` | PRs | Opt-in: a triage+ user applies it to run the Claude deep review on any PR (re-apply to re-run). Optionally steer it by pre-posting a PR comment starting with `deep-review:` (focus areas, suspected weak spots) — only comments from triage+ authors are used | -| `ai-triage` | PRs & issues | Runs one-shot AI triage (related issues + duplicates): automatic on new issues/PRs in auto mode (daily budget), or applied by a triage+ user (budget-exempt; label auto-removes) | -| `claude-repro` | issues | Triage+ user applies it: Claude drafts a candidate reproduction test and github-actions[bot] posts it for a human to run (author-only — CI does not run it). Auto-removes | -| `needs-security-review` | PRs & issues | Auto-applied by the security scanner when it flags an item; blocks every entry point this repo controls — `claude-repro`, `claude-fix`, `@claude`, `@bestaxbot` — until a maintainer removes it (third-party reviewers are not gated) | -| `claude-assisted` | PRs | Auto-applied provenance for AI-assisted PRs (bestaxbot or the Claude footer) | -| `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes. Claude-assisted PRs skip this sweep — a separate closer sweeps them after 90 days instead | -| `neverstale` | PRs | Exempts a PR from all stale automation (both the 30/14-day sweep and the 90-day Claude-assisted closer) | +| Label | Where | Meaning | +| ----------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `claude-fix` | issues | Maintainer-approved: Claude implements this issue and opens a PR | +| `ai-loop` | PRs | The PR is inside the autonomous review/fix loop | +| `needs-human-review` | PRs | The loop converged (or hit a disagreement) — awaiting maintainer review/merge | +| `ai-loop-paused` | PRs | The loop hit its iteration cap or a guardrail — a maintainer must intervene | +| `deep-review` | PRs | Opt-in: a triage+ user applies it to run the Claude deep review on any PR (re-apply once, after fixes: it re-checks its own threads and reviews the commits since its last review, falling back to a full review whenever it cannot establish a trustworthy linear delta between them, such as a rewritten history, a merge from the base, or a retarget that moves the merge base — and a steer starting `deep-review: fresh` forces one). Optionally steer it by pre-posting a PR comment starting with `deep-review:` (focus areas, suspected weak spots) — only comments from triage+ authors are used | +| `ai-triage` | PRs & issues | Runs one-shot AI triage (related issues + duplicates): automatic on new issues/PRs in auto mode (daily budget), or applied by a triage+ user (budget-exempt; label auto-removes) | +| `claude-repro` | issues | Triage+ user applies it: Claude drafts a candidate reproduction test and github-actions[bot] posts it for a human to run (author-only — CI does not run it). Auto-removes | +| `needs-security-review` | PRs & issues | Auto-applied by the security scanner when it flags an item; blocks every entry point this repo controls — `claude-repro`, `claude-fix`, `@claude`, `@bestaxbot` — until a maintainer removes it (third-party reviewers are not gated) | +| `claude-assisted` | PRs | Auto-applied provenance for AI-assisted PRs (bestaxbot or the Claude footer) | +| `stale` | PRs | Auto-applied after 30 days of inactivity; closes 14 days later unless activity resumes. Claude-assisted PRs skip this sweep — a separate closer sweeps them after 90 days instead | +| `neverstale` | PRs | Exempts a PR from all stale automation (both the 30/14-day sweep and the 90-day Claude-assisted closer) | ### AI triage @@ -75,7 +75,7 @@ words, escaped and stripped of anything that could mention, link or re-trigger. **Only same-repo PRs are triaged** — PRs opened from forks are always skipped, automatic and label alike (the workflow deliberately avoids GitHub's `pull_request_target` trigger, so fork-originated events can never run with repository -secrets); issues have no such restriction. Three repository variables control it: +secrets); issues have no such restriction. Repository variables control it: - **`AI_TRIAGE_MODE`** — `auto` (new issues/PRs are triaged automatically and the label still works), `label` (opt-in only; the default when unset), or `off` (disables both automatic @@ -160,7 +160,7 @@ output, so a re-run deep review that finds nothing can still meet an open thread pass and route to `verify` on that. And it picks a single mode per run: red or pending CI and threads still awaiting the fixer outrank `verify`. A paused loop stops it outright, since the gate requires the `ai-loop` label — but the iteration cap does not, because the cap only -rewrites `fix-ci`/`fix-reviews` into `halt`, so a `verify` selected at iteration 4 still runs. +rewrites `fix-ci`/`fix-reviews` into `halt`, so a `verify` selected at the cap still runs. **Screenshots at handoff.** When the loop flips a PR to `needs-human-review` it also dispatches a screenshot pass (`story-screenshots.yml`): Playwright captures the Storybook @@ -213,84 +213,24 @@ separate store and none are documented here. | `AI_SCAN_DAILY_LIMIT` | `20` | integer | Auto scans per UTC day | | `AI_LOOP_COPILOT` | `false` | must be exactly `true` | Requests a Copilot review on loop PRs (Copilot's own automatic review skips bot-authored PRs on personal repos, so it has to be asked for) | -The gate job's `if:` names Copilot alongside CodeRabbit: a submitted review from either is -**eligible** to fire the gate on an `ai-loop` PR. That condition reads no `AI_LOOP_COPILOT`, only -`AI_LOOP_ENABLED`, so it is live for any Copilot review, including one a maintainer requests by -hand, which is how every Copilot review on a loop PR has arrived so far (#573, #624). -`AI_LOOP_COPILOT` decides only whether `claude-implement.yml` asks for the review; unsetting it is -not a kill switch for this trigger. `AI_LOOP_ENABLED=false` and removing `ai-loop` are. Eligible is -the honest word, and the split between runs that start and runs that do not is not random. Whether a -`Claude PR Loop` run for a Copilot review ever creates a job is decided by whether a maintainer -re-ran it. Every run Copilot has triggered arrived as `run_attempt: 1` with `triggering_actor: -Copilot` and died before a job existed (100 `failure` with zero jobs and 21 `action_required`, 121 -so far), so no job `if:` was evaluated. The only ones that reached a job are among the 19 a -maintainer re-ran by hand: on a re-run `triggering_actor` becomes the re-runner, which is what the -`allxsmith` rows in that query are, and not a record of who requested the review. Eighteen of those -created all six jobs and evaluated the gate's `if:`, which before this change matched only -`coderabbitai[bot]`; run `33041194214` was cancelled with none. Runs `33586960606`, `33232938014` -and `33035152465` are three of the eighteen, on `claude/*` heads, and `33586960606` is the worked -example: attempt 1 was `action_required` with zero jobs, attempt 2 produced the six. So there is no -observed case of a Copilot review starting a loop run unaided. Since 2026-09-06 the shape has -changed again: Copilot's reviews on PR #643 that day (all of them its "encountered an error" -submissions) created no `Claude PR Loop` run at all, so the query below can come back with nothing -new rather than a fresh startup failure, and absence of a run is the expected reading, not a broken -query. Whether `AI_LOOP_COPILOT=true` changes any of this is untested: the variable has never been -on, so no review requested by `claude-implement.yml` has been observed. The request itself starts -nothing (it is made under the workflow's `GITHUB_TOKEN`, and nothing here listens for -`review_requested`), but the review Copilot then submits is its own event under its own credentials, -so treat that path as unknown rather than ruled out. In every case observed so far, a Copilot-only -finding has still waited for the next natural event: a CI or deep-review completion, a CodeRabbit -review, or the 2-hourly watchdog sweep. The widened condition removes our side of the obstacle; it -is not a latency guarantee. Treat a `Claude PR Loop` run **with jobs** as the proof. Keep the -`--paginate` and the `run_attempt` column: the most recent page alone is all startup failures, which -is how the absolute version of this claim was first written, and without the attempt number the -re-runs look like a second kind of trigger. - -```bash -gh api --paginate \ - "repos/allxsmith/bestax/actions/workflows/claude-pr-loop.yml/runs?event=pull_request_review" \ - --jq '.workflow_runs[] | select(.actor.login=="Copilot") - | [.id, .run_attempt, .triggering_actor.login, .conclusion, .head_branch] | @tsv' -``` - -Two lists have to name the reviewer for that to work end to end, and they are matched against -different strings. The `pull_request_review` branch of the gate job's `if:` is matched against the -event payload (`review.user.login`); the `allowed_bots` list on the fix and verify sessions is -matched against the **run's actor**, and nothing else. A reviewer in one but not the other either -waits for an unrelated event (a CI or deep-review completion, the other reviewer, or the 2-hourly -watchdog sweep) or fails the session's human-actor check outright. The action's write-permission -check also reads `allowed_bots`, but only in PR and issue contexts and only for an actor without a -`[bot]` suffix, which here means `Copilot` on the review arm; every other entry and every other arm -is decided by the human-actor check alone. - -Those two strings are not always the same. The Copilot reviewer app renders as -`copilot-pull-request-reviewer[bot]` in the payload and as `Copilot` to Actions, so the payload -spelling is what makes the gate condition match and the actor spelling is what makes `allowed_bots` -match. `allowed_bots` compares normalised logins, not account ids, and GitHub's other Copilot app -(`copilot-swe-agent[bot]`) also renders as `Copilot`, so that entry admits either; the confinement -is the gate, not the precision of the string, and each arm holds different parts of it. The review -and CI-completion arms of the `if:` carry the same-repo and `claude/*` head tests; the -manual-dispatch arm carries neither and relies on GitHub only letting write-access users dispatch. -The `ai-loop` label, PR-open and protected-path tests are re-derived in the gate job's shell on -every arm, and the two head tests are not. So a CI completion with `Copilot` as the actor, on a -`claude/*` head, still passes all of them before `fix` runs. Each list carries only the spelling it -is compared against; a hedge entry in the other place would match nothing today and, in -`allowed_bots`, would widen an allowlist on the job holding `AI_LOOP_PAT` for no observed reason. -CodeRabbit needs one spelling because both surfaces agree on it. When adding a reviewer, read its -login off a real run (`gh api repos//actions/runs/ --jq .actor.login`, since `gh run list` -exposes no actor field) as well as off the API, and put each spelling where it is actually compared -(#612). - -One divergence deliberately remains. The gate's `if:` names exact logins, while the `ACTIONABLE` -counter, and the fix job's terminal check that repeats it, match the case-insensitive prefix -`^(coderabbitai|copilot)`. A thread opened by any other `copilot-*` app, `copilot-swe-agent[bot]` -today or a future reviewer app, is therefore still counted as work the loop owes while its review -fires no gate run (its run actor can still reach `fix` through the CI-completion arm described -above, where the gate confines it), which is the same wait of up to two hours that #612 describes. -That is the safe direction: a new `copilot-*` app cannot admit itself to a session holding -`AI_LOOP_PAT` just by existing. But it means the two agree only for the logins named above, so -adding a reviewer means adding it to the `if:` and to `allowed_bots`, not only to the counter's -regex. +The gate job's `if:` names Copilot alongside CodeRabbit, so a submitted review from either is +eligible to fire the gate on an `ai-loop` PR. That condition reads `AI_LOOP_ENABLED` only: +`AI_LOOP_COPILOT` decides whether `claude-implement.yml` requests the review, and unsetting it +is not a kill switch for the trigger. `AI_LOOP_ENABLED=false` and removing `ai-loop` are. + +The reviewer is named in two places, compared against a different string in each. The review branch of the +gate's `if:` is matched against the event payload (`review.user.login`, +`copilot-pull-request-reviewer[bot]`), and the other branches read their own events; +`allowed_bots` on the fix and verify sessions is matched against the run's actor (`Copilot`). +Each list carries only the spelling it is compared against. When adding a reviewer, read its +login off a real run (`gh api repos//actions/runs/ --jq .actor.login`) and put each +spelling where it is compared. The `ACTIONABLE` counter matches the prefix +`^(coderabbitai|copilot)` deliberately: a new `copilot-*` app is counted as work owed, but it +cannot admit itself to a session holding `AI_LOOP_PAT` just by existing. + +Do not assume a Copilot review starts a loop run by itself; whether GitHub creates a run for +one is outside this repo's control, and what has been observed is recorded +[on #612](https://github.com/allxsmith/bestax/issues/612#issuecomment-5563800114). Anything that spends model usage is **explicit opt-in** — it must be present and set, and deleting it turns the feature off rather than on: diff --git a/docs/docs/guides/getting-started/alternative-icons.md b/docs/docs/guides/getting-started/alternative-icons.md index 754a9ff32..329cb650b 100644 --- a/docs/docs/guides/getting-started/alternative-icons.md +++ b/docs/docs/guides/getting-started/alternative-icons.md @@ -6,7 +6,7 @@ sidebar_position: 3 # Icons -bestax-bulma supports five icon libraries out of the box: **Font Awesome**, **Material Design Icons**, **Ionicons**, **Google Material Icons**, and **Material Symbols**. Font Awesome is the default when no `library` prop is set. +bestax-bulma supports these icon libraries out of the box: **Font Awesome**, **Material Design Icons**, **Ionicons**, **Google Material Icons**, and **Material Symbols**. Font Awesome is the default when no `library` prop is set. :::tip Easiest path: `create-bestax` The [Quick Start](/docs/guides/intro) installer prompts you to pick an icon library (or none) and handles the install, CSS import, and any needed `ConfigProvider` wiring automatically. Use the manual steps below if you're adding icons to an existing project or want to switch libraries later. @@ -105,7 +105,7 @@ function MaterialIconExample() { ## Ionicons -Ionicons is a modern icon library with 1,300+ icons designed specifically for web, iOS, Android, and desktop apps. The library now uses web components for better performance and loading. +Ionicons is a modern icon library designed specifically for web, iOS, Android, and desktop apps. The library now uses web components for better performance and loading. **Install:** @@ -163,7 +163,7 @@ function IoniconExample() { **Available Icon Variants:** -Ionicons v8 provides three variants for most icons: +Ionicons v8 provides these variants for most icons: ```tsx live import { Icon, Columns, Column, Block } from '@allxsmith/bestax-bulma'; @@ -388,7 +388,7 @@ function MaterialSymbolExample() { **Available Symbol Styles:** -Material Symbols come in three styles. The default import includes all styles, but you can import selectively: +Material Symbols come in several styles. The default import includes all of them, but you can import selectively: ```tsx live import { Icon, Columns, Column } from '@allxsmith/bestax-bulma'; @@ -506,7 +506,7 @@ function MaterialSymbolStyles() { :::info Material Symbols vs Material Icons Material Symbols is Google's newer icon system with: -- More comprehensive icon coverage (2,500+ icons) +- More comprehensive icon coverage - Better optical sizing and variable font support - Consistent design across all platforms - Recommended for new projects diff --git a/docs/docs/guides/getting-started/installation.md b/docs/docs/guides/getting-started/installation.md index 911d63f96..a89bc4a54 100644 --- a/docs/docs/guides/getting-started/installation.md +++ b/docs/docs/guides/getting-started/installation.md @@ -416,7 +416,7 @@ import { Button } from '@allxsmith/bestax-bulma'; Components should have Bulma styling applied. If components appear unstyled: - Verify bestax CSS is imported -- Check browser console for 404 errors +- Check browser console for 404 errors {/* bestax:count-ok: an HTTP status, not a tally */} - Ensure CSS import order is correct ### 4. Check Icons (if using) diff --git a/docs/docs/guides/getting-started/modular.md b/docs/docs/guides/getting-started/modular.md index db2549979..34b714f54 100644 --- a/docs/docs/guides/getting-started/modular.md +++ b/docs/docs/guides/getting-started/modular.md @@ -64,7 +64,7 @@ import { The categories above are illustrative, not exhaustive. See the [Elements](/docs/category/elements), [Components](/docs/category/components), [Form](/docs/category/form), [Layout](/docs/category/layout), [Columns](/docs/api/columns), and [Grid](/docs/api/grid) API sections for the complete list, including newer additions like `Dialog`, `Carousel`, `Tooltip`, `Steps`, `Sidebar`, `Toast`, `Loading`, `Collapse`, `Autocomplete`, `Taginput`, `Rate`, `Slider`, `NumberInput`, `Switch`, `Checkboxes`, and `Radios`. ::: -## CSS: three ways to load styles +## CSS: the ways to load styles ### Option A — combined bundle (recommended) diff --git a/docs/docs/guides/getting-started/optimizing-css.md b/docs/docs/guides/getting-started/optimizing-css.md index bd5f80f1a..ec35eb2be 100644 --- a/docs/docs/guides/getting-started/optimizing-css.md +++ b/docs/docs/guides/getting-started/optimizing-css.md @@ -12,7 +12,7 @@ all of Bulma plus the bestax extras, whatever your app uses. With the default `c a production build, essentially constant regardless of how many components you render. That's a reasonable default — every component just works, helpers included — but if CSS weight -matters to you, this page gives you the three levers, cheapest first. +matters to you, this page gives you the levers, cheapest first. _Sizes below are the minified files shipped in the current package; expect small drift between releases._ @@ -116,6 +116,6 @@ of maintaining the import list as your usage grows. levers to your app (measure, flavor switch, modular build) via Claude Code or any skills.sh-compatible agent. - [CSS Variations](./variations.md) — what each flavor includes. -- [Modular](./modular.md) — JS tree-shaking and the three CSS loading strategies. +- [Modular](./modular.md) — JS tree-shaking and the CSS loading strategies. - [Dark Mode & Contrast](../features/css-variables.md#dark-mode--contrast) — pin the scheme before reaching for `no-dark-mode`. diff --git a/docs/docs/guides/helpers/color.md b/docs/docs/guides/helpers/color.md index 24dd443b0..c0c468961 100644 --- a/docs/docs/guides/helpers/color.md +++ b/docs/docs/guides/helpers/color.md @@ -105,7 +105,7 @@ function ColorExamples() { ## Background Color -Use the `bgColor` prop to apply background colors. It accepts the same color values as `textColor` and renders Bulma's `has-background-*` helpers — plus, on six surface components, the `scheme-*` values below, which render as a dark-mode-safe inline style instead of a class. A few components accept the raw `backgroundColor` helper name instead, and `Notification` has no background prop at all: its `color` variant fills the background, so pair it with `textColor`. Each component's Props table lists the names it takes. +Use the `bgColor` prop to apply background colors. It accepts the same color values as `textColor` and renders Bulma's `has-background-*` helpers — plus, on the surface components listed below, the `scheme-*` values, which render as a dark-mode-safe inline style instead of a class. A few components accept the raw `backgroundColor` helper name instead, and `Notification` has no background prop at all: its `color` variant fills the background, so pair it with `textColor`. Each component's Props table lists the names it takes. ### Standard Background Colors @@ -148,7 +148,7 @@ Use the `bgColor` prop to apply background colors. It accepts the same color val ### Scheme Backgrounds (adapt to dark mode) -Bulma ships no `has-background-scheme-*` classes, so these values emit **no class at all** — the component renders a dark-mode-safe inline style that tracks Bulma's scheme CSS variables. They are supported on six surface components: `Section`, `Hero` (and `Hero.Head`/`Hero.Body`/`Hero.Foot`), `Container`, `Footer`, `Box`, and `Card` (the parent, not its subcomponents). `backgroundColorShade` is ignored for scheme values — no shaded scheme variables exist. +Bulma ships no `has-background-scheme-*` classes, so these values emit **no class at all** — the component renders a dark-mode-safe inline style that tracks Bulma's scheme CSS variables. They are supported on the surface components: `Section`, `Hero` (and `Hero.Head`/`Hero.Body`/`Hero.Foot`), `Container`, `Footer`, `Box`, and `Card` (the parent, not its subcomponents). `backgroundColorShade` is ignored for scheme values — no shaded scheme variables exist. | Property | Emitted Inline Style | Background Value | | ----------------------------- | -------------------------------------------------- | ---------------------------------- | diff --git a/docs/docs/guides/helpers/typography.md b/docs/docs/guides/helpers/typography.md index fb7c25f10..bce3984e0 100644 --- a/docs/docs/guides/helpers/typography.md +++ b/docs/docs/guides/helpers/typography.md @@ -28,7 +28,7 @@ All components in bestax-bulma have access to these typography properties throug ## Size -Control text size using the `textSize` prop. Bulma provides 7 size levels with size 1 being the largest and size 7 being the smallest. +Control text size using the `textSize` prop. Bulma provides size levels with size 1 being the largest and size 7 being the smallest. | Property | Bulma Class | Value | | -------------- | ----------- | -------------- | diff --git a/docs/docs/guides/llms/index.md b/docs/docs/guides/llms/index.md index 915fd2f7d..2c52dae26 100644 --- a/docs/docs/guides/llms/index.md +++ b/docs/docs/guides/llms/index.md @@ -49,7 +49,7 @@ name), so a Claude Code session picks them up automatically. See the ## In the npm package -The published `@allxsmith/bestax-bulma` tarball also carries three small pointer +The published `@allxsmith/bestax-bulma` tarball also carries small pointer files at the package root, so an agent that explores `node_modules` by filename (`find` / `ls` for `AGENTS.md`, `CLAUDE.md`, `llms.txt`) lands on these resources even if it never opens the README or reaches the network first: @@ -68,7 +68,7 @@ tarball cannot go stale between releases. `bestax-mcp` is the first-party [Model Context Protocol](https://modelcontextprotocol.io) server. Where `llms.txt` gives an agent the docs to read, the MCP server lets it **ask -questions** — every component's props (including compound parts like `Navbar.Brand`), ~900 +questions** — every component's props (including compound parts like `Navbar.Brand`), the working examples, the `--bulma-*` variables behind each component, the helper props that replace inline styles, and the Agent Skills as invocable prompts. @@ -116,7 +116,7 @@ Start with `list_components` — its output names the tool to call next. Reach f | `get_examples` | Working `tsx` examples from the component's documentation page | | `get_css_variables` | The `--bulma-*` custom properties a component reads, with Sass names and defaults | | `get_helper_props` | Spacing, colour, typography, flexbox and visibility props — the alternative to hand-written styles | -| `list_skills` | The seven [Agent Skills](/docs/skills/intro) | +| `list_skills` | The [Agent Skills](/docs/skills/intro) | | `get_skill` | A skill's instructions, or one of its reference documents | It also exposes each skill as an MCP **prompt** (`theming`, `form`, `layout-scaffold`, …) and @@ -150,7 +150,7 @@ especially welcome. The LLM docs are generated at build time by [`docusaurus-plugin-llms`](https://github.com/rachfop/docusaurus-plugin-llms) (configured in `docs/docusaurus.config.js`), following the -[llmstxt.org](https://llmstxt.org) standard. Three artifacts are produced and served +[llmstxt.org](https://llmstxt.org) standard. These artifacts are produced and served from the site root: | File | What it is | @@ -159,4 +159,4 @@ from the site root: | [`/llms-full.txt`](https://bestax.io/llms-full.txt) | The **entire documentation** concatenated into a single plain-text file. | | Per-page `.md` | Every page is also served as clean Markdown at `.md`, e.g. [`/docs/guides/intro.md`](https://bestax.io/docs/guides/intro.md). | -All three are regenerated on every docs build, so they always match the deployed site. +They are regenerated on every docs build, so they always match the deployed site. diff --git a/scripts/check-conformance.mjs b/scripts/check-conformance.mjs index 147aed4be..793238f89 100644 --- a/scripts/check-conformance.mjs +++ b/scripts/check-conformance.mjs @@ -54,9 +54,14 @@ * copies are byte-identical * telemetry-allowlists worker schema enums are a superset of the CLI values * (templates, flavors, icons, sources, css modes, PMs) + * fragile-prose no hand-maintained counts or line references in + * workflow comments, CLAUDE.md files, or guides, and no + * run ids in a guide (a workflow comment may cite the + * run that justified a flip); a count lives in a + * command and evidence on the issue (#643) */ import { readFile, readdir, writeFile, access } from 'node:fs/promises'; -import { join, relative, dirname, isAbsolute, extname } from 'node:path'; +import { join, relative, dirname, isAbsolute, extname, sep } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; // The registration parser lives in lib/ so the API-docs generator shares it — @@ -102,6 +107,7 @@ import { parseBypassEntries, findExpired, } from './lib/bypass-annotations.mjs'; +import { scanFragileProse, describeHit } from './lib/fragile-prose.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); @@ -3520,6 +3526,73 @@ export async function checkDocsApiUrls(root = REPO) { return violations; } +// Prose that goes stale on the next unrelated edit: a count of things in a +// sentence, a run id pasted as evidence, a path:line reference. Scans the +// three places such prose accumulates — workflow comments, the CLAUDE.md +// contracts, and the published guides — and nothing else: blog posts are +// dated snapshots by design, API pages are generated, and code is where the +// commands live. What "exact" means instead is in .github/CLAUDE.md ("How to +// be exact") and docs/CLAUDE.md; the scanner is in lib/fragile-prose.mjs. +const FRAGILE_PROSE_SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'coverage', + 'storybook-static', +]); + +async function claudeMdFiles(dir, out = []) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + // Hidden directories hold worktrees, caches and tool state, none of + // which is this repo's prose. `.github` is the exception: its CLAUDE.md + // is the workflow contract the rule was written for. + if (entry.name.startsWith('.') && entry.name !== '.github') continue; + if (FRAGILE_PROSE_SKIP_DIRS.has(entry.name)) continue; + await claudeMdFiles(join(dir, entry.name), out); + } else if (entry.name === 'CLAUDE.md') { + out.push(join(dir, entry.name)); + } + } + return out; +} + +// `root` is injectable so scripts/fragile-prose.test.mjs can walk a temporary +// tree and prove the target set (and every exclusion) rather than only the +// scanner. +export async function checkFragileProse(root = REPO) { + const ifPresent = async (dir, fn) => { + try { + await access(dir); + } catch { + return []; + } + return fn(dir); + }; + const targets = [ + ...( + await ifPresent(root ? join(root, '.github') : root, async d => [ + ...(await walk(d, '.yml')), + ...(await walk(d, '.yaml')), + ]) + ).map(f => [f, 'yaml']), + ...(await claudeMdFiles(root)).map(f => [f, 'markdown']), + // Migration guides are exempt: their counts describe a frozen upstream + // (a vendored version of another library), and the ones that matter are + // asserted by the mapping-coverage tests in bestax-migrate. + ...(await ifPresent(join(root, 'docs', 'docs', 'guides'), mdFiles)) + .filter(f => !f.includes(`${sep}migration${sep}`)) + .map(f => [f, 'guide']), + ]; + const violations = []; + for (const [file, kind] of targets) { + const rel = relative(root, file).split('\\').join('/'); + const hits = scanFragileProse(await readFile(file, 'utf8'), { kind }); + for (const hit of hits) violations.push(describeHit(rel, hit)); + } + return violations; +} + const CHECKS = { 'listings-sync': checkListingsSync, 'docs-sections': checkDocsSections, @@ -3539,6 +3612,7 @@ const CHECKS = { 'telemetry-core': checkTelemetryCore, 'telemetry-allowlists': checkTelemetryAllowlists, 'docs-api-urls': checkDocsApiUrls, + 'fragile-prose': checkFragileProse, 'inline-style': null, // handled below (takes the flag) }; diff --git a/scripts/fragile-prose.test.mjs b/scripts/fragile-prose.test.mjs new file mode 100644 index 000000000..2f287ba45 --- /dev/null +++ b/scripts/fragile-prose.test.mjs @@ -0,0 +1,596 @@ +/** + * Holds the `fragile-prose` scanner to the rule it enforces: a count, a run + * id, or a line reference in prose is a hit; the same digits inside code, a + * date, a version, a SHA, an issue number, a percentage, a duration, or a size + * are not. The false-positive cases matter as much as the true ones, because + * a rule that cries wolf gets marked `bestax:count-ok` reflexively, and a + * reflexive marker is worth less than none. + */ +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { scanFragileProse, describeHit } from './lib/fragile-prose.mjs'; +import { checkFragileProse } from './check-conformance.mjs'; + +const md = text => scanFragileProse(text, { kind: 'markdown' }); +const guide = text => scanFragileProse(text, { kind: 'guide' }); +const yaml = text => scanFragileProse(text, { kind: 'yaml' }); +const whys = hits => hits.map(h => h.why); + +// --- counts ------------------------------------------------------------------- + +test('a number word before a counted noun is a count', () => { + assert.deepEqual( + whys(md('Without it a vendor outage fails nineteen jobs at once.')), + ['count'] + ); + assert.deepEqual(whys(md('All six now use the same nine-host allowlist.')), [ + 'count', + ]); +}); + +test('digits before a counted noun are a count', () => { + assert.deepEqual(whys(md('There are 87 documented components.')), ['count']); + assert.deepEqual(whys(md('the pin appears in 9 occurrences, one SHA')), [ + 'count', + ]); +}); + +test('the documented example shapes are all hits', () => { + for (const text of [ + 'that issue closed the group by measuring all six of its members', + 'the runs surfaced six application hosts', + 'Nineteen jobs; the command below is the check', + 'All 87 components fit in the catalog', + 'the SDK pulls dozens of transitive packages', + 'walks every file under node_modules, thousands of them', + 'forty hosts across the fleet', + 'completed all fifteen of its API calls under the firewall', + 'the repo ships seven Agent Skills today', + 'five icon libraries are supported', + 'the release uploads five artifacts', + ]) { + assert.deepEqual(whys(md(text)), ['count'], text); + } +}); + +test('a counting idiom without the noun is a count', () => { + assert.deepEqual(whys(md('nineteen of them as of the inventory below')), [ + 'count', + ]); + assert.deepEqual(whys(md('twelve so far, and the list keeps growing')), [ + 'count', + ]); +}); + +test('one and two are ordinary English, not counts', () => { + assert.deepEqual( + md('Two lists name the reviewer. One home per fact, in one line.'), + [] + ); +}); + +test('a number with no counted noun nearby is not a count', () => { + assert.deepEqual(md('The cap is 4 and the sweep is 2-hourly.'), []); + assert.deepEqual( + md('Node 22 locally, React 19 in the matrix, port 3000.'), + [] + ); +}); + +// --- things that look like numbers but are not counts ---------------------------- + +test('dates, versions, SHAs, issue refs, percentages, durations, sizes are skipped', () => { + assert.deepEqual( + md( + 'Since 2026-09-06 at 15:28 UTC, pnpm@11.9.0 on #643 at c46324d kept 99% ' + + 'coverage (78% branches) after 30 days and ~800KB of CSS.' + ), + [] + ); +}); + +test('a 40-hex SHA is not a run id', () => { + assert.deepEqual( + md('pinned at 51c47629174b5dbca955cf93690fd39c8a41dadf'), + [] + ); +}); + +// --- run ids ------------------------------------------------------------------ + +test('a bare integer of nine or more digits is a run id, in a guide', () => { + assert.deepEqual(whys(guide('see run 33586960606 for the six jobs')), [ + 'count', + ]); + assert.deepEqual(whys(guide('see run 33586960606')), ['run id']); +}); + +test('a run id in a workflow comment or a CLAUDE.md is a receipt, not a hit', () => { + assert.deepEqual( + yaml('# audit run was 33260691933; the flip is built on it'), + [] + ); + assert.deepEqual( + md('run 33221210633 is the evidence that omitting them is right'), + [] + ); +}); + +test('a line citing an issue or PR records history and is not a hit', () => { + assert.deepEqual( + md('#361 shipped two workflows pinned twelve commits behind'), + [] + ); + assert.deepEqual( + md('deleted 868 lines on #613 and the suite stayed green'), + [] + ); + assert.deepEqual(whys(md('deleted 868 lines and the suite stayed green')), [ + 'count', + ]); +}); + +test('a URL is not scanned, so an issue-comment anchor is not a run id', () => { + assert.deepEqual( + guide( + 'evidence is on https://github.com/x/y/issues/612#issuecomment-5563800114 today' + ), + [] + ); +}); + +test('a hyphenated duration is not a count', () => { + assert.deepEqual(md('the 30-day workflow and the 2-hourly sweep'), []); +}); + +// --- line references ---------------------------------------------------------- + +test('path:line and "line N" references are hits', () => { + assert.deepEqual( + whys(md('the guard at claude-pr-loop.yml:224 and again at :758')), + ['line reference'] + ); + assert.deepEqual(whys(md('see line 224, lines 224-229, and L42')), [ + 'line reference', + ]); +}); + +test('a bare filename is not a line reference', () => { + assert.deepEqual( + md('edit turbo.json and rerun, then see App.jsx and index.mjs'), + [] + ); +}); + +test('the remedy names something that would actually fix the finding', () => { + const [ref] = md('the guard at foo.yml:42'); + assert.match(describeHit('x.md', ref), /cite a heading, a step id/); + const [count] = md('nineteen jobs share this'); + assert.match(describeHit('x.md', count), /the command that produces it/); +}); + +test('a rule number or a step id is not a line reference', () => { + assert.deepEqual( + md("rule 10's grep, the `dedupe` step, and heading 3.2"), + [] + ); + assert.deepEqual( + md('it is step 4 of the anatomy rule in the component guide'), + [] + ); +}); + +test('a source path with any common extension is a line reference', () => { + for (const text of [ + 'the sequence is writeStatus (agent.go:307) and then a serve loop', + 'see App.jsx:42 for the callback', + 'the helper at scripts/lib/skills.mjs:88', + ]) { + assert.deepEqual(whys(md(text)), ['line reference'], text); + } +}); + +test('a ticket excuses a count but never a line reference', () => { + assert.deepEqual( + md('deleted 868 lines on #613 and the suite stayed green'), + [] + ); + assert.deepEqual(whys(md('the old guard at foo.yml:42 was fixed in #643')), [ + 'line reference', + ]); +}); + +test('a count wrapped across two lines is caught, at its first line', () => { + const [hit] = md( + 'fine\nthe repo keeps three small pointer\nfiles for agents' + ); + assert.equal(hit.line, 2); + assert.equal(hit.why, 'count'); + assert.deepEqual(md('fine\nfine\nfine'), []); +}); + +test('the reason must sit inside the marker comment, and the token must end', () => { + assert.deepEqual( + whys(md('87 components trailing prose')), + ['count'] + ); + assert.deepEqual(whys(md('87 components ')), [ + 'count', + ]); + assert.deepEqual(md('87 components '), []); +}); + +test('the allow marker needs a reason', () => { + assert.deepEqual( + md('87 components today '), + [] + ); + assert.deepEqual(whys(md('87 components today ')), [ + 'count', + ]); + assert.deepEqual(whys(yaml('# 19 jobs; bestax:count-ok')), ['count']); +}); + +test('a standalone ranged locator is a line reference', () => { + assert.deepEqual(whys(md('see lines 224-229')), ['line reference']); +}); + +test('a ticket on the next line excuses only a count that wraps into it', () => { + assert.deepEqual(md('deleted 868\nlines on #613 and it stayed green'), []); + assert.deepEqual(whys(md('nineteen jobs remain\nSee #613 for details')), [ + 'count', + ]); +}); + +test('a comment delimiter inside inline code opens nothing', () => { + assert.deepEqual(whys(md('write `{/*` in the page\nnineteen jobs remain')), [ + 'count', + ]); +}); + +test('the legacy HTML comment terminator closes a comment too', () => { + assert.deepEqual(md(' fine'), []); + assert.deepEqual(whys(md('87 components ')), [ + 'count', + ]); +}); + +test('a second comment on the line after one closes is still masked', () => { + assert.deepEqual(md('{/* a */} fine {/*\nnineteen jobs\n*/} done'), []); +}); + +test('a tally standing alone as a sentence is a count', () => { + assert.deepEqual(whys(md('Three. The first is the SDK, the second is zod')), [ + 'count', + ]); + assert.deepEqual(md('Two. That is fine, since two is ordinary English'), []); +}); + +test('the ticket exemption reads across a wrapped line', () => { + assert.deepEqual( + md('deleted 868\nlines on #613 and the suite stayed green'), + [] + ); + assert.deepEqual(whys(md('deleted 868\nlines and the suite stayed green')), [ + 'count', + ]); +}); + +test('a number word carrying the verb is a count', () => { + assert.deepEqual(whys(md('Three are shipped: rbx, bloomer and RBC')), [ + 'count', + ]); +}); + +test('a code span needs a run of exactly its own length to close', () => { + // A double-backtick span holds a single backtick; the inner one must not + // close it, and the count after the real close is still prose. + assert.deepEqual(whys(md('text ``a `b` c`` then 19 jobs')), ['count']); + assert.deepEqual(md('a ``19 jobs`` span'), []); +}); + +test('a code span that opens on one line covers the next', () => { + assert.deepEqual(md('run `command\n19 jobs` to see'), []); + assert.deepEqual(whys(md('run `command`\n19 jobs remain')), ['count']); +}); + +test('a line reference is not capped at four digits', () => { + for (const t of ['see large-file.ts:10000', 'see line 10000', 'see L10000']) { + assert.deepEqual(whys(md(t)), ['line reference'], t); + } +}); + +test('a reference-link definition is not prose', () => { + assert.deepEqual(md('[nineteen jobs]: /archive'), []); + assert.deepEqual(whys(md('the nineteen jobs are listed')), ['count']); +}); + +test('an HTTP status is not a count, but a bare error tally is', () => { + assert.deepEqual(md('the page returns HTTP 404 when missing'), []); + assert.deepEqual(md('watch for 404 responses'), []); + assert.deepEqual(whys(md('the scan found 500 errors')), ['count']); + assert.deepEqual(whys(md('the run logged nine errors')), ['count']); +}); + +test('an MDX closer split from its brace still closes', () => { + assert.deepEqual(whys(md('{/*\ncomment\n*/\n}\n19 jobs remain')), ['count']); + assert.deepEqual(whys(md('{/* comment */}\n19 jobs remain')), ['count']); + assert.deepEqual(md('{/*\n19 jobs\n*/\n}'), []); +}); + +test('an empty comment does not swallow the rest of the file', () => { + assert.deepEqual(whys(md('\n19 jobs remain')), ['count']); + assert.deepEqual(whys(md('\n19 jobs remain')), ['count']); +}); + +test('a backtick inside a comment does not leave either state open', () => { + assert.deepEqual(whys(md('\n19 jobs remain')), ['count']); + assert.deepEqual(whys(md('\n19 jobs remain')), [ + 'count', + ]); +}); + +test('a comment marker inside a code span stays inert', () => { + assert.deepEqual(whys(md('a span `{/*\n*/}` then\n19 jobs remain')), [ + 'count', + ]); + assert.deepEqual(whys(md('a span `` then\n19 jobs remain')), [ + 'count', + ]); +}); + +test('a grouped number reads as one count', () => { + const [hit] = md('a library with 2,500+ icons'); + assert.equal(hit.text, '2,500+ icons'); +}); + +test('a determiner-led back-reference is a count', () => { + for (const t of [ + 'these five always resolve', + 'only one of the five', + 'its four live here', + 'must not hide the other three', + ]) { + assert.deepEqual(whys(md(t)), ['count'], t); + } + // A determiner in front of a value is not a tally. + assert.deepEqual(md('the 30/14 sweep and the 1 rebuttal round'), []); +}); + +test('an all-N back-reference is a count', () => { + assert.deepEqual(whys(md('All three are regenerated on every docs build')), [ + 'count', + ]); + assert.deepEqual(whys(md('the three hosts stay; all four legs agree')), [ + 'count', + ]); +}); + +test('a count wrapped across several narrow lines is caught', () => { + const [hit] = md('three\nsmall\npointer\nfiles for agents'); + assert.equal(hit.line, 1); + assert.equal(hit.why, 'count'); +}); + +test('an approximate count is a count, with or without a leading all', () => { + assert.deepEqual(whys(md('500+ variables are exposed')), ['count']); + assert.deepEqual(whys(md('all 500+ variables are exposed')), ['count']); +}); + +test('a large count is still a count, but an id is not', () => { + assert.deepEqual(whys(md('10000 files are scanned')), ['count']); + assert.deepEqual(whys(md('99999999 files are scanned')), ['count']); + assert.deepEqual(whys(guide('see run 33586960606')), ['run id']); +}); + +test('the window stops at a new block', () => { + assert.deepEqual( + md('- set the size to 3 for large text\n- props are forwarded'), + [] + ); +}); + +test('only a real ticket excuses a count', () => { + assert.deepEqual(whys(md('500 variables https://example.test/#613')), [ + 'count', + ]); + assert.deepEqual(whys(md('500 variables `#613`')), ['count']); + assert.deepEqual(md('500 variables, fixed in #613'), []); +}); + +test('a ticket below the match does not excuse it', () => { + assert.deepEqual(whys(md('deleted 868\nlines today\nper #613')), ['count']); + assert.deepEqual(md('deleted 868\nlines on #613 today'), []); +}); + +test('a maximally wrapped count is caught', () => { + const [hit] = md('three\nvery\nsmall\npointer\nfiles for agents'); + assert.equal(hit.line, 1); + assert.equal(hit.why, 'count'); +}); + +test('a spelled-out line reference wrapped across lines is caught', () => { + assert.deepEqual(whys(md('see line\n224 for the guard')), ['line reference']); +}); + +test('naming the marker in prose or code does not exempt', () => { + assert.deepEqual( + whys(md('Use `bestax:count-ok` when documenting 87 components')), + ['count'] + ); + assert.deepEqual(md('87 components '), []); +}); + +test('a count inside a link or emphasis is caught', () => { + assert.deepEqual( + whys(md('The seven [Agent Skills](/docs/skills) ship with it')), + ['count'] + ); + assert.deepEqual(whys(md('**three levers**, cheapest first')), ['count']); + assert.deepEqual(whys(md('The seven [Agent Skills][skills] ship with it')), [ + 'count', + ]); + assert.deepEqual(whys(md('The seven [Agent Skills][] ship with it')), [ + 'count', + ]); +}); + +test('a run id is not read as a count of the words after it', () => { + assert.deepEqual( + whys(yaml('# run 33286967625 showed claude-review at block')), + [] + ); +}); + +test('a double-backtick code span is masked like a single one', () => { + assert.deepEqual( + md('Use ``nineteen jobs`` literally, and `87 components` too'), + [] + ); +}); + +test('a parenthesised line reference is a hit', () => { + assert.deepEqual( + whys(md('the serve loop (:313) calls RevertChanges (:315)')), + ['line reference'] + ); +}); + +// --- masking -------------------------------------------------------------------- + +test('fenced code, inline code, HTML comments and front matter are not scanned', () => { + // Built as one string: a bare comment delimiter on its own line reads to a + // static analyser like a hand-rolled HTML parser rather than test data. + const text = `--- +title: nineteen jobs +--- +Prose before. +\`\`\`bash +grep -c "egress-policy: block" # prints 19 jobs +\`\`\` +Run \`gh api runs/33586960606\` yourself. + +~~~ +87 components +~~~`; + assert.deepEqual(md(text), []); +}); + +test('a comment spanning lines is masked in both spellings', () => { + assert.deepEqual(md(' fine'), []); + assert.deepEqual(md('{/*\nnineteen jobs\nand 87 components\n*/} fine'), []); + assert.deepEqual(md('{/* nineteen jobs */}\nfine'), []); + // The text after a closing delimiter is still prose. + assert.deepEqual(whys(md('{/*\nhidden\n*/} nineteen jobs remain')), [ + 'count', + ]); +}); + +test('an unterminated fence masks to end of file', () => { + assert.deepEqual(md('```\nnineteen jobs\nstill code'), []); +}); + +test('yaml scans whole-line comments only', () => { + const text = [ + 'jobs:', + ' gate:', + ' # nineteen jobs share this allowlist', + ' run: |', + ' echo "nineteen jobs" # 87 components', + ' COUNT=19 # the 19 jobs above', + ' # and 87 components in a shell comment', + ' prompt: |', + ' Read all 87 components.', + ].join('\n'); + const hits = yaml(text); + assert.deepEqual( + hits.map(h => [h.line, h.why]), + [ + [3, 'count'], + [7, 'count'], + ] + ); +}); + +// --- the allow token ------------------------------------------------------------ + +test('bestax:count-ok on the line skips it', () => { + assert.deepEqual( + md( + '87 components today. ' + ), + [] + ); + assert.deepEqual( + yaml('# 19 jobs; bestax:count-ok — a scale, not a checksum'), + [] + ); +}); + +// --- reporting ---------------------------------------------------------------- + +test('a hit reports its line and the first pattern that matched', () => { + const [hit] = md('fine\nfine\nnineteen jobs here'); + assert.equal(hit.line, 3); + assert.equal(hit.text, 'nineteen jobs'); + assert.match( + describeHit('docs/x.md', hit), + /^docs\/x\.md line 3: "nineteen jobs" — a count in prose/ + ); + assert.match(describeHit('docs/x.md', hit), /bestax:count-ok/); +}); + +// --- the walk --------------------------------------------------------------------- + +test('walks a real tree: reports every target kind and honours every exclusion', async () => { + const root = await mkdtemp(join(tmpdir(), 'fragile-prose-')); + const write = async (rel, body) => { + await mkdir(join(root, dirname(rel)), { recursive: true }); + await writeFile(join(root, rel), body); + }; + const count = 'nineteen jobs share this list\n'; + const runId = 'see run 33586960606\n'; + + await write( + '.github/workflows/a.yml', + `jobs:\n # ${count} x: echo "nineteen jobs"\n` + ); + await write('.github/CLAUDE.md', count + runId); + await write('pkg/CLAUDE.md', count); + await write('docs/docs/guides/g.md', runId); + await write('docs/docs/guides/migration/m.md', count); + await write('docs/docs/api/a.md', count); + await write('docs/blog/post.md', count); + await write('node_modules/dep/CLAUDE.md', count); + await write('.claude/worktrees/other/CLAUDE.md', count); + await write('pkg/dist/CLAUDE.md', count); + await write('README.md', count); + + const violations = await checkFragileProse(root); + const files = [...new Set(violations.map(v => v.split(' line ')[0]))].sort(); + + assert.deepEqual(files, [ + '.github/CLAUDE.md', + '.github/workflows/a.yml', + 'docs/docs/guides/g.md', + 'pkg/CLAUDE.md', + ]); + // The run id is a hit in the guide and a receipt in the contract. + assert.equal(violations.filter(v => v.includes('run id')).length, 1); + assert.ok( + violations.find( + v => v.startsWith('docs/docs/guides/g.md') && v.includes('run id') + ) + ); + // The yaml file's shell string is not scanned, only its comment line. + assert.equal( + violations.filter(v => v.startsWith('.github/workflows/a.yml')).length, + 1 + ); +}); diff --git a/scripts/lib/fragile-prose.mjs b/scripts/lib/fragile-prose.mjs new file mode 100644 index 000000000..39454f632 --- /dev/null +++ b/scripts/lib/fragile-prose.mjs @@ -0,0 +1,460 @@ +/** + * Find prose that goes stale on the next unrelated edit. + * + * Three shapes of text kept drawing review findings on #643 and were each + * fixed by adding more of the same: a count of things written into a + * sentence ("nineteen jobs", "9 occurrences"), a workflow run id pasted as + * evidence, and a `path:123` line reference. None of them is wrong the day it + * is written; all of them are wrong soon after, and nothing re-measures them. + * The rules that replace them are in `.github/CLAUDE.md` ("How to be exact") + * and `docs/CLAUDE.md`: a count lives in a command, evidence lives on the + * issue, a reference names a step id or a heading. + * + * This scanner reads only prose: `#` comment lines in YAML, and markdown with + * fenced blocks, inline code, HTML comments, and front matter masked out. A + * count inside a shell string, a jq filter, or a code sample is never a hit, + * because those are the commands the rule points people at. + * + * Numbers that are not counts of things are masked before matching: dates, + * versions, SHAs, issue and PR references, percentages, durations, clock + * times, and sizes. Number words start at "three" and include the magnitude + * forms ("dozens of", "thousands of"): "one" and "two" are ordinary English + * far more often than they are a tally. + * + * A line carrying `bestax:count-ok` is skipped. The marker is for the few + * deliberate cases (a number the surrounding text already calls a scale, not + * a checksum) and should say why on the same line. + */ + +export const ALLOW_TOKEN = 'bestax:count-ok'; + +const NUMBER_WORDS = + 'three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|' + + 'fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|' + + 'thirty|forty|fifty|sixty|seventy|eighty|ninety|dozens?|hundreds?|' + + 'thousands?'; + +// Nouns whose count changes when code changes. A curated list, not a general +// plural rule: English spells plural nouns and third-person verbs the same, so +// matching any word ending in s turns "the script removes 8" and "4000 covers +// it" into counts. Precision matters more than recall here, because this gate +// blocks CI — a false positive teaches people to work around the check, while +// a miss only means the rule went uncaught once. Durations, sizes and ports +// are not counts of things and are masked below. Nor is a rhetorical +// enumeration: "three consequences" or "three things" introduces the list that +// follows it, and the list is right there to check, so those nouns stay out. +const COUNTED_NOUNS = + 'jobs?|hosts?|runs?|entries|entry|occurrences?|components?|props?|' + + 'packages?|workflows?|checks?|files?|lines?|rows?|copies|copy|attempts?|' + + 'reviews?|threads?|commits?|exports?|examples?|pages?|apps?|stories|' + + 'story|tests?|rules?|steps?|variables?|labels?|reviewers?|actors?|' + + 'members?|items?|services?|calls?|requests?|endpoints?|secrets?|' + + 'tokens?|branches|scripts?|generators?|sections?|tables?|bullets?|' + + 'skills?|libraries|library|artifacts?|targets?|sources?|hooks?|' + + 'flags?|inputs?|outputs?|fields?|keys?|paths?|variants?|levers?|' + + 'questions?|apis?|options?|ways?|kinds?|modes?|reasons?|cases?|' + + 'surfaces?|helpers?|classes|utilities|styles?|strategies|strategy|' + + 'shapes?|blocks?|viewports?|fixes|breakpoints?|tabs?|icons?|levels?|' + + 'errors?|warnings?|failures?'; + +// Up to eight digits, grouped or not, optionally approximate ("500+ +// variables", "2,500+ icons"): a longer run is an id, and the run-id rule +// owns those. +const NUMBER = `(?:${NUMBER_WORDS}|\\d{1,3}(?:,\\d{3})+\\+?|\\d{1,8}\\+?)`; + +const PATTERNS = [ + { + why: 'count', + // "nineteen jobs", "9 occurrences", "fifteen of its API calls" — a + // number followed within three words by a counted noun. + re: new RegExp( + // `(?!\\w)` rather than `\\b` after the number: a word boundary cannot + // fall between the `+` of an approximate count and the space after it. + `\\b${NUMBER}(?!\\w)(?:[\\s-]+[\\w']+){0,3}?[\\s-]+(?:${COUNTED_NOUNS})\\b`, + 'i' + ), + }, + { + why: 'count', + // "nineteen of them", "all three are regenerated", "only one of the five", + // "its four" — idioms that count without naming what they count. A + // determiner in front is the tell: it points back at a list that grows. + // Words only again — "the 30/14 sweep" and "the 1 rebuttal round" are + // values a determiner happens to precede, not tallies. + re: new RegExp( + `\\b(?:all\\s+)?${NUMBER}\\s+(?:of\\s+(?:them|those|these)|so\\s+far)\\b` + + `|\\b(?:all|both|these|those|the|its|their|our|other)\\s+(?:${NUMBER_WORDS})(?!\\w)` + + // A tally standing alone as its own sentence: "Three." opening a + // section counts what the section then lists. Words only — a digit + // at the start of a line is an ordered-list marker. + `|^\\s*(?:${NUMBER_WORDS})\\s*[.:;]` + + // "Three are shipped" — a number word carrying the sentence's verb, + // with the things it counts named only in the list that follows. + `|\\b(?:${NUMBER_WORDS})\\s+(?:are|is|were|was|have|has|remain|ship|shipped|exist|apply|live)\\b`, + 'i' + ), + }, + { + why: 'run id', + // A bare integer of nine or more digits is a workflow run or job id. + // Guides only: in a workflow comment or a CLAUDE.md a run id is the + // receipt for the policy flip it justified, which `.github/CLAUDE.md` + // asks for; in a published guide it is a statistic nothing re-measures. + re: /\b\d{9,}\b/, + kinds: ['guide'], + }, + { + why: 'line reference', + // "claude-pr-loop.yml:224", "(:729)" style, or the words "line 224", + // "lines 224-229", "L224". + re: /(?:[\w./-]+\.(?:ya?ml|mdx?|[cm]?jsx?|tsx?|json|sh|scss|css|go|py|rs)|[\s(]):\d+(?:-\d+)?\b|\b(?:lines?|L)\s?\d+(?:\s?[-–]\s?\d+)?\b/i, + }, +]; + +// Numbers that are never counts of things. Each is blanked to spaces (length +// preserved so column positions in a message stay honest) before matching. +const NOT_A_COUNT = [ + /\b\d{4}-\d{2}-\d{2}\b/g, // dates + /\b\d{1,2}:\d{2}(?::\d{2})?(?:Z|\s?(?:UTC|am|pm))?\b/gi, // clock times + /\bv?\d+\.\d+(?:\.\d+)*(?:[-+][\w.]+)?\b/g, // versions + /\b(?=[0-9a-f]*[a-f])[0-9a-f]{7,40}\b/gi, // SHAs: a hex run with at least one letter, so a run id stays visible + /#\d+\b/g, // issue and PR references + /\b\d+(?:\.\d+)?\s?%/g, // percentages + /\b\d+(?:\.\d+)?[\s-]?(?:ms|s|sec|secs|seconds?|min|mins|minutes?|h|hrs?|hours?|hourly|days?|weeks?|months?|years?)\b/gi, // durations, including "30-day" + /\b\d+(?:\.\d+)?\s?(?:B|KB|MB|GB|KiB|MiB)\b/g, // sizes + /\b(?:port|node|react|bulma|docusaurus|typescript|es|next\.?js|vite|jest|storybook)\s?\d+\+?/gi, // named versions and ports + /\b(?:step|rule|phase|stage|point|item|no\.|number)\s?\d+\b/gi, // identifiers, not tallies + /\bHTTP\s?\d{3}\b|\b[1-5]\d{2}s?\s+(?:status(?:es)?|codes?|responses?)\b/gi, // HTTP statuses, not tallies + /\b\d+\s?[-–—]\s?\d+\b/g, // a range is guidance ("a 1-3 sentence hook"), not a tally + /\b\d+-(?:column|row|cell|bit|byte|core|only)\b/gi, // named systems, not inventories +]; + +function blank(str, re) { + return str.replace(re, m => ' '.repeat(m.length)); +} + +function maskNotCounts(line) { + let out = line; + for (const re of NOT_A_COUNT) out = blank(out, re); + return out; +} + +// Inline code, HTML comments and URLs on one line (an issue-comment anchor +// is a nine-digit number too). Multi-line HTML comments are handled by the +// markdown masker below; the comment pattern still spans newlines so it +// cannot half-match a comment if this is ever handed more than one line. +function maskInline(line) { + // A reference-link definition is not prose — it never renders — so the whole + // line goes, label and destination together. + if (/^\s{0,3}\[[^\]]+\]:\s/.test(line)) return ' '.repeat(line.length); + let out = blank( + blank(line, /(?)/g + ); + // A link's visible text is prose and its destination is not, so keep the + // text and blank the rest; emphasis markers go the same way. Without this a + // count inside a link or in bold reads as markup and slips the detector. + // Inline links keep their text and lose the destination; reference-style + // links (`[text][ref]` and `[text][]`) render the same way, so they are + // unwrapped too. + out = out.replace( + /\[([^\]]*)\](?:\([^)]*\)|\[[^\]]*\])/g, + (m, text) => ' ' + text + ' '.repeat(m.length - text.length - 1) + ); + // MDX comments, which the docs tree uses instead of the HTML form. + out = blank(out, /\{\s*\/\*[\s\S]*?\*\/\s*\}/g); + out = out.replace(/(\*\*|__|\*|_)(?=\S)/g, m => ' '.repeat(m.length)); + out = out.replace(/(?<=\S)(\*\*|__|\*|_)/g, m => ' '.repeat(m.length)); + return blank(out, /https?:\/\/\S+/g); +} + +/** + * Blank the code spans and comments in one line, carrying state across lines. + * + * One interleaved pass rather than two: the grammars interact, and splitting + * them fails OPEN. Comments first would let a backtick inside a comment open a + * span; code first would let a span's close be blanked before comment + * detection saw it, leaving a comment open to end of file and hiding every + * violation after it. Here each step looks at whichever delimiter comes first + * and consumes it, so neither state can be entered from inside the other. + * + * `state` is null, `{ code: run }` while a backtick run of that length is + * open, or `{ close }` while a comment is open. Returns the masked line + * (length preserved) and the state for the next line. + */ +function stripSpans(line, state) { + const OPENERS = [ + ['` and ``; treated as a + // generic opener, their close would never be found and the comment would + // swallow the rest of the file. + const EMPTY = /^