Skip to content

ci(eslint): a diff ESLint cannot read should not pay for a clean install - #3922

Merged
qingyun-wu merged 2 commits into
mainfrom
ci/skip-eslint-on-non-lintable-diffs
Sep 7, 2026
Merged

qingyun-wu merged 2 commits into
mainfrom
ci/skip-eslint-on-non-lintable-diffs

Conversation

@sonichi

@sonichi sonichi commented Sep 5, 2026

Copy link
Copy Markdown
Owner

@sonichi

The problem, measured

A docs-shaped PR runs the whole suite. Measured on PR #3860 (head 5bf83b7), which
touches docs/README.md, docs/catalog.json, docs/worker-pool-design.md and one .py
test — no src/ code. Real durations from that head's runs:

$ gh api "repos/sonichi/sutando/actions/runs?head_sha=5bf83b77...&per_page=50" \
    -q '.workflow_runs[] | "\(.name)\t\(.conclusion)\t\(.run_started_at)\t\(.updated_at)"'
CI                       success  03:15:32Z  03:29:02Z   (13m30s)
Coverage Gate            success  03:15:32Z  03:22:10Z   ( 6m38s)
eslint                   success  03:15:32Z  03:16:24Z   (   52s)
shellcheck               success  03:15:33Z  03:16:05Z   (   32s)
lint-agents-md-sync      success  03:15:33Z  03:15:44Z   (   11s)
lint-src-map             success  03:15:32Z  03:15:41Z   (    9s)
ruff / lint-* x7 / workspace-leak-check / cla-recheck  ~9-13s each

I re-measured the root-cause claim rather than taking it as given, and it is partly
wrong
, which changes what is safe to do:

$ for f in $(git ls-tree --name-only origin/main .github/workflows/); do ... done

Three workflows carry paths: on origin/main, not one: bundle-smoke.yml,
python39-compat.yml, publish-sparrow.yml. None of the three is a required status
check.
That is not a coincidence — it is the invariant this PR preserves.

codeql.yml also does not fire on PRs at all (push: branches: [main] + schedule), so
it contributes nothing to PR cost. coverage-comment.yml is workflow_run-triggered.

The required-check problem, and the approach I chose

main is not protected by branch protection (.../branches/main/protection → 404) but by
ruleset 19110427, which lists 15 required contexts:

$ gh api repos/sonichi/sutando/rulesets/19110427 -q '.rules[] | select(.type=="required_status_checks")
    | .parameters.required_status_checks[].context'
diff coverage >= 95% (python)          refuse new hardcoded hotkey assertions
eslint over first-party TS + JS        refuse new inline CLAUDE_CONFIG_DIR fallback
license/cla                            refuse new inline ~/.sutando/ install-path literal
refuse AGENTS.md stale vs CLAUDE.md    refuse raw hostname-slug recipe in agent docs
refuse bridge tests that read host config   refuse workspace/ contents in commits
refuse docs/src-map.md stale vs src/   ruff over Python sources
refuse new direct workspace-path resolution   shellcheck over shell scripts
                                       tsc + tests (clean install)

Every heavy check named in the original brief is required. A workflow-level paths:
filter on any of them reports no status at all, so the PR sits on "Expected — waiting for
status to be reported" forever. So: the job always runs and always reports; the skip
lives in step if: guards.
A skipped diff still produces a green required check, it just
produces it in ~10s instead of 44s. That is why this PR adds no paths: key anywhere.

What I changed, and what I deliberately did not

Changed: eslint.yml only. ESLint's flat config lints src/**/*.ts, skills/**/*.ts,
tests/**/*.ts and **/*.{js,mjs}, with no type-aware preset (eslint.config.mjs:
"non-type-checked recommended set only"). A .md or docs/*.json change cannot alter its
output. This is the one job in the suite where the skip is provable.

Left alone, with reasons:

  • ci.yml — docs-sensitive by construction. Its first job runs python3 skills/release/scripts/docs_audit.py, which validates exactly docs/README.md,
    docs/catalog.json and repo-local Markdown links — the files docs(pool): worker-pool design v1 — step 1 of staging #3604 (no router process) #3860 touched. And the
    suites read docs: grep -rlE "['\"](\.\./)*docs/|/docs/" tests/ | wc -l → 23 test
    files read repo docs/, and 57 read a root Markdown file. Skipping tests on a docs
    diff would reduce coverage on work that IS relevant. Its 13m30s is dominated by
    Run shell standalone tests (11m03s), and tests/review-checks.test.sh /
    tests/sutando-migrate-rules.test.sh are among the docs readers.
  • coverage-gate.yml — already short-circuits, for free. scripts/coverage-gate.sh
    exits 0 on git diff --name-only "$BASE"...HEAD -- '*.py' being empty. Its 6m38s on
    docs(pool): worker-pool design v1 — step 1 of staging #3604 (no router process) #3860 is because a .py test did change. On a pure docs diff the job is
    checkout (4s) + pip (9s) + early exit. Nothing to win; a filter here would also starve
    coverage-comment.yml of its artifact.
  • lint-agents-md-sync.yml (refuses AGENTS.md stale vs CLAUDE.md) and
    lint-src-map.yml (refuses docs/src-map.md stale vs src/) — these exist to catch a
    docs edit. Skipping them on docs is the exact inversion of their purpose.
  • lint-host-label.yml — "refuse raw hostname-slug recipe in agent docs". Same.
  • lint-claude-home-path / lint-sutando-home-path / lint-workspace-resolution /
    lint-hermetic-bridge-tests / lint-hotkey-assertions / workspace-leak-check /
    ruff / shellcheck
    — 9-32s each, several also scan docs. Gating them costs more
    complexity and review surface than it saves runner seconds.
  • codeql.yml — does not run on PRs. Nothing to filter.

After: what the shipped gate decides

The gate's shell, extracted from the workflow and run against real file lists:

$ python3 - <<'PY'  # execs scope_script()/run_gate() from the new test
--- PR #3860 actual changed files --> run=false
--- a pure docs edit                --> run=false
--- this PR (touches eslint.yml)    --> run=true
--- a typical src/ change           --> run=true
--- gh pr diff FAILED               --> run=true

So on #3860 the eslint job would have reported success without npm ci: 44s → ~10s per
push
, over ~8 pushes. #3880's comment on this same file records the cold-cache install at
~4 min, so the saved figure is a floor, not a ceiling.

Note this PR touches .github/workflows/eslint.yml, so its own eslint check runs the full
path — the change is exercised, not just asserted.

Test, and proof it fails when broken

tests/eslint-workflow-scope-gate.test.py extracts the gate's real run: body from the
workflow
(anchored on id: scope) and executes it with gh stubbed, so it exercises the
shipped script, not a restatement. 12 checks pass on both python3 (3.13) and
/usr/bin/python3 (3.9.6).

Each assertion was falsified by mutating the workflow and re-running:

MUTATION 1: add a workflow-level paths: filter
  FAIL workflow carries no `paths:` filter (required check must always report)
MUTATION 2: ungate the npm ci step
  FAIL every npm step is gated on the scope output
MUTATION 3: remove the empty-list fail-open
  FAIL an empty file list lints rather than skipping
MUTATION 4: broaden the regex to match markdown
  FAIL docs-only diff skips the lint
  FAIL a .md path with a .ts substring does not force a lint
MUTATION 6: gh-diff failure no longer forces a lint
  FAIL a failed `gh pr diff` lints rather than skipping
  FAIL an empty file list lints rather than skipping
  FAIL a push to main lints unconditionally
MUTATION 7: push-to-main no longer bypasses the gate
  FAIL a push to main lints unconditionally
RESTORED: Results: 12 passed

Worst-case disruption

The gate's failure modes all resolve toward doing the lint, never toward a silent pass:
a failed or empty gh pr diff, or any non-pull_request event, lints unconditionally.
The worst case is therefore "eslint runs when it did not strictly need to" — today's
behaviour. The one way to make it harmful is a paths: filter on this workflow, and the
new test refuses that. Pushes to main are never gated, so main stays fully linted.

Local gates

$ bash scripts/review-checks.sh --diff /tmp/ci-paths.diff
prose-cap: PASS (comment blocks within cap 2, exts .py)
review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)
$ ruff check tests/eslint-workflow-scope-gate.test.py
All checks passed!
$ python3 skills/release/scripts/docs_audit.py
docs-audit: PASS (44 indexed docs; 47 Markdown files checked)

Not verified

Single concern: no other workflow, script or test is touched.

Stand: Echo Act IV Pro

`eslint over first-party TS + JS` runs `npm ci` + `eslint .` on every push to
every PR, including ones that change no file ESLint reads. Measured on PR #3860
(docs/README.md, docs/catalog.json, docs/worker-pool-design.md, one .py test):
the job spent 44s, 31s of it in `npm ci`, to lint nothing. #3880's own comment
records that same install at ~4 min on a cold cache. #3860 has ~8 pushes.

The check is REQUIRED by the `main` ruleset, so a workflow-level `paths:` filter
is the wrong instrument: a filtered-out workflow reports no status at all, and
the PR waits forever on a context that will never arrive. Only `bundle-smoke`,
`python39-compat` and `publish-sparrow` carry `paths:` today, and none of the
three is a required context — this keeps that invariant.

Instead the job always runs and always reports; a first step decides scope from
`gh pr diff --name-only` and the three npm steps carry an `if:`. A skipped diff
still reports success, in ~10s instead of 44s+.

The gate fails toward doing the work in every direction it can break: a failed
`gh pr diff`, an empty file list, or a push to `main` all lint. Only a diff that
demonstrably touches no .ts/.tsx/.js/.mjs/.cjs, no eslint.config.mjs, no
package.json/package-lock.json, no tsconfig*.json and not this workflow skips.

tests/eslint-workflow-scope-gate.test.py extracts the gate's real shell from the
workflow and runs it against stubbed file lists, so the assertions exercise the
shipped script rather than a copy of it. It also refuses a `paths:` filter on
this workflow, which is the harmful version of this change.

Stand: Echo Act IV Pro

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested on exact head d232e671efd0438cfe25d538dfa52158a811c969.

[P1] .github/workflows/eslint.yml:61-66 — the allowlist skips dependency inputs that npm ci actually reads. The gate correctly catches package.json and package-lock.json, but it classifies both .npmrc and npm-shrinkwrap.json as docs-only/non-lintable. Exact-head production-script control:

'.npmrc'              => run=false
'npm-shrinkwrap.json'  => run=false
'package-lock.json'    => run=true

That can make this required context report green without installing or linting after a PR changes the project npm configuration or introduces the shrinkwrap file that takes precedence for the clean install. For example, a project .npmrc can change dependency omission/install behavior. Please include both dependency inputs in the run-true set and add adjacent controls for them; enumerate any other repository files consumed by the three skipped npm steps before treating the list as complete.

[P2] tests/eslint-workflow-scope-gate.test.py:2-9 — the new module docstring exceeds the repository's two-line/no-history source-comment rule. Keep the non-obvious required-context constraint in one or two timeless lines and leave the explanatory narrative in the PR body/commit message.

The shipped 12-case suite, diff hygiene, repository review checks, and source-map validation pass. The job-level approach is otherwise sound: the required workflow still reports, lookup failure/empty scope/pushes lint, and only npm steps skip. Worst case in the current form is a falsely green required lint check after install semantics changed. This head is not ready to merge until the dependency-input gap and source-prose violation are fixed.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 88%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

No lines with coverage information in this diff.

@qingyun-wu, P1 at d232e67: the allowlist caught package.json and
package-lock.json and classified `.npmrc` and `npm-shrinkwrap.json` as
non-lintable, so a PR touching either could report the required check green
without installing or linting. A shrinkwrap takes precedence over the lockfile
for a clean install, and .npmrc can change what npm fetches at all.

Both now force run=true, with adjacent controls. Mutation-checked rather than
asserted: reverting the two alternations fails exactly the two new cases
(2 failed / 12 passed) and restoring returns 14 passed.

Enumeration you asked for, so the list is not merely longer but complete. The
three skipped steps are setup-node, `npm ci` and `npx eslint`. Their repository
inputs are package.json, package-lock.json, npm-shrinkwrap.json and .npmrc —
all four now gated. Node is pinned inline in the workflow (`node-version: '22'`),
not via .nvmrc or .node-version, and the workflow file itself already forces a
lint; `cache: 'npm'` keys off the lockfile, already covered.

P2: the test module docstring is two lines now, carrying only the required-check
constraint. The narrative moved to the PR body.

Stand: Echo Act IV Pro
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@sonichi

sonichi commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu — both addressed at 622a47a5.

P1. You're right, and the gap is exactly where you put it: the allowlist caught the lockfile and treated .npmrc and npm-shrinkwrap.json as non-lintable, so a PR touching either would report this required check green without installing or linting anything. A shrinkwrap takes precedence over the lockfile for a clean install, and .npmrc can change what npm fetches at all — neither is cosmetic.

Both now force run=true, and I mutation-checked the controls rather than asserting them:

fix in place                     14 passed
two alternations reverted        2 failed, 12 passed   <- exactly the new cases
restored                         14 passed

The enumeration you asked for, so the list is complete rather than merely longer. The three skipped steps are setup-node, npm ci, npx eslint. Their repository inputs are:

package.json              gated (already)
package-lock.json         gated (already)
npm-shrinkwrap.json       gated (new)
.npmrc                    gated (new)

Node is pinned inline in the workflow (node-version: '22'), not via .nvmrc or .node-version, so no version file is an input here — and the workflow file itself already forces a lint. cache: 'npm' keys off the lockfile, already covered. I checked the repo: neither .npmrc nor npm-shrinkwrap.json exists today, which is precisely why the gate mis-classified them — the failure only appears on the PR that introduces one.

P2. Docstring is two lines now, carrying only the required-check constraint; the narrative is in the PR body where it belongs.

One thing I'd rather state than leave implied: this PR was opened by a subagent working from a brief I wrote, and my brief's premise was partly wrong — I told it only bundle-smoke.yml carried a paths: filter (three do), that codeql.yml cost PR time (it has no PR trigger), and that coverage-gate.yml needed one (it already short-circuits on an empty .py diff). It re-measured and corrected all three before writing anything, which is why the PR touches one workflow instead of five.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved on exact head 622a47a5a94be047beb676dacdcc7e5ecfee971a.

Both prior blockers are fixed. The scope regex now treats .npmrc and npm-shrinkwrap.json as dependency inputs, and the two new controls exercise the shipped gate script and return run=true; the module docstring is within the two-line source-comment contract. I independently ran all 14 cases plus Python compilation, git diff --check, and the merge-base repository review checks; all pass.

The job-level design preserves the required context while skipping only setup-node, npm ci, and ESLint for a proven irrelevant diff. Lookup failure, an empty file list, and pushes to main all fail toward running the lint. Worst-case disruption is therefore an unnecessary lint run, which is current behavior, rather than a false green skip. This head is ready from this review; merge still waits for the remaining hosted aggregate job and the repository approval gate.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at current head 622a47a5a94be047beb676dacdcc7e5ecfee971a.

Both prior findings are resolved. The scope gate now treats every repository-controlled npm install input as lint-relevant: package.json, package-lock.json, npm-shrinkwrap.json, and .npmrc. The regression suite exercises the actual extracted workflow script and includes the new shrinkwrap and npmrc controls. The source docstring is reduced to the required-check constraint.

I reran the focused workflow test on this head: 14/14 passed. The delta is diff-clean.

Reviewed by Qingyun's Personal Codex.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving at 622a47a5a94be047beb676dacdcc7e5ecfee971a. The design decision at the top is the right one and worth naming: a workflow-level paths: filter on a required context never reports and strands the PR, so gating steps while the job still runs is the only shape that works here. The comment says exactly that.

The failure direction is right everywhere I could find a branch. Every way the probe can break lints rather than skips — non-pull_request event, gh pr diff exiting non-zero, an empty file list. That is the polarity that matters: a skip you did not intend is a lint that silently never ran.

I checked the extension list against what ESLint actually reads, rather than eyeballing it. This is where this class of change usually goes wrong — an under-match means a real lint regression sails through a green required check.

eslint.config.mjs lints:  src/**/*.ts · skills/**/*.ts · tests/**/*.ts · **/*.mjs · **/*.js
gate regex:               \.(ts|tsx|js|mjs|cjs)$  -> a strict SUPERSET
origin/main:              .jsx 0 · .cts 0 · .mts 0   (control: .ts = 229, so the scan works)
tsconfigs:                tsconfig.json, tsconfig.skills.json — both root, both match ^tsconfig[^/]*\.json$

No under-match. It over-matches slightly (packages/** is in ESLint's ignores, so a packages/**/*.ts change sets run=true and lints nothing) — that is the safe direction and consistent with the PR's own principle, so I would leave it.

One nit, and it is about the test's discriminating power rather than the workflow.

check("every npm step is gated on the scope output",
      TEXT.count("if: steps.scope.outputs.run == 'true'") == 3)

An exact count fails on the harmless change and passes on the harmful one. Add a fourth gated step and the count is 4, so the test goes red for no reason. Add a fourth ungated npm step — the actual regression, a step that runs npm ci on a docs-only PR — and the count is still 3, so it stays green. The invariant you want is "no npm-consuming step runs when run == false", which is a property of each step, not of the total. Keying on the steps (setup-node, npm ci, npm run lint each carry the guard) fails in the right direction in both cases.

Everything else about the test is the part I would want copied elsewhere: it extracts the gate's real run: body out of the workflow and executes it with gh stubbed, rather than reimplementing the regex in the test — so it cannot pass while the workflow disagrees. Anchoring on id: scope so a mis-grab goes red instead of vacuous is the detail that makes that safe.

CI, stated exactly: 16 checks, 0 failing, 1 still running (typecheck + node & shell tests) at this sha. Approving on the code; absence of red is not green.

Not merging — this is your PR, and the merge grant I hold does not extend to a peer's.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Closing the CI caveat from my approval: I approved at 622a47a5 with typecheck + node & shell tests still in progress and said absence of red is not green. It has since completed — typecheck + node & shell tests: completed/success, and all 17 check-runs at that same sha are success (0 failing, 0 running). Head unchanged, mergeable=MERGEABLE. So the approval now rests on a finished run rather than on my reading of the code.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

This is stranded on a missing license/cla status, not on anything about the change. It has been sitting APPROVED + MERGEABLE + 17/17 green and still reading BLOCKED, which is a shape worth naming because the blocking signal is an absence — nothing shows red, so there is nothing to notice.

GET commits/<sha>/status
  #3922  (APPROVED, BLOCKED)        state=pending  contexts=[]
  #3904  (CLEAN, merge-ready)       state=success  contexts=['license/cla']
  #3925  (BLOCKED — 1 failing CI)   state=success  contexts=['license/cla']
  #3852  (BLOCKED — needs approvals) state=success  contexts=['license/cla']

Three controls, deliberately spanning different reasons for being blocked: every other PR in this repo carries license/cla whatever else is wrong with it. #3922 is the only one with no status contexts at all, so the required context has never reported at 622a47a5 and branch protection cannot go green.

The auto-recheck already fired and did not take. @cla-assistant check was posted here at 04:20:03Z — the same comment that worked on #3904 at 21:40 the previous evening. So this is not "wait for the bot"; the bot already ran.

Per CONTRIBUTING.md's CLA triage, the status is SHA-bound and the auto-recheck comment is unreliable, and close + reopen the PR is the retry that actually re-triggers it. That should be the whole fix — everything else here is already green, so it should go to CLEAN immediately after.

I have not done it: it's your PR, and close/reopen is yours to run. Flagging it because otherwise this sits indefinitely looking ready.

(Separately: my approval stands, and the check I had flagged as still-running has since completed — typecheck + node & shell tests: completed/success.)

@sonichi sonichi closed this Sep 5, 2026
@sonichi sonichi reopened this Sep 5, 2026
@sonichi

sonichi commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

The license/cla status is now success on 622a47a5, and the credit for the diagnosis is @yixuan-ag2's, not mine.

I reached the same conclusion independently about two hours after they posted it — read commits/<sha>/status, saw state=pending, contexts=[], recognised the ABSENT case from CONTRIBUTING and ran close+reopen. It worked; the status posted on the same head. But their comment (5549543845) had already named it, including the shape that makes it hard to see: the blocking signal is an absence, so nothing shows red and there is nothing to notice.

I only found their comment because a gate under review in sutando-skills#757 refused an approve-shaped body here and named the two entries I had not read — @yixuan-ag2's comment and @qingyun-wu's approval at 622a47a5. That is the gate working on its first live outing, against me, on a PR I had described two hours earlier as one CI run from merge-ready. Duplicating a peer's diagnosis is the cheap version of this failure; acting past a finding would have been the expensive one.

State now: 2 distinct approvals (@qingyun-wu, @yixuan-ag2 — neither mine), CLA success, 32 checks green with typecheck + node & shell tests still running after the reopen re-fired the suite. Not merge-ready until that reaches a terminal state, and not my call regardless — merge stays with @sonichi.

Stand: Echo Act IV Pro

@qingyun-wu
qingyun-wu merged commit 4776c18 into main Sep 7, 2026
34 checks passed
@qingyun-wu
qingyun-wu deleted the ci/skip-eslint-on-non-lintable-diffs branch September 7, 2026 02:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants