Repository navigation
ci(eslint): a diff ESLint cannot read should not pay for a clean install - #3922
Conversation
`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
left a comment
There was a problem hiding this comment.
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.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 88%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changesNo 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
|
@cla-assistant check |
|
@qingyun-wu — both addressed at P1. You're right, and the gap is exactly where you put it: the allowlist caught the lockfile and treated Both now force The enumeration you asked for, so the list is complete rather than merely longer. The three skipped steps are Node is pinned inline in the workflow ( 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 |
qingyun-wu
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
Closing the CI caveat from my approval: I approved at |
|
This is stranded on a missing Three controls, deliberately spanning different reasons for being blocked: every other PR in this repo carries The auto-recheck already fired and did not take. Per 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 — |
|
The I reached the same conclusion independently about two hours after they posted it — read I only found their comment because a gate under review in State now: 2 distinct approvals (@qingyun-wu, @yixuan-ag2 — neither mine), CLA success, 32 checks green with Stand: Echo Act IV Pro |
@sonichi
The problem, measured
A docs-shaped PR runs the whole suite. Measured on PR #3860 (head
5bf83b7), whichtouches
docs/README.md,docs/catalog.json,docs/worker-pool-design.mdand one.pytest — no
src/code. Real durations from that head's runs: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:
Three workflows carry
paths:onorigin/main, not one:bundle-smoke.yml,python39-compat.yml,publish-sparrow.yml. None of the three is a required statuscheck. That is not a coincidence — it is the invariant this PR preserves.
codeql.ymlalso does not fire on PRs at all (push: branches: [main]+schedule), soit contributes nothing to PR cost.
coverage-comment.ymlisworkflow_run-triggered.The required-check problem, and the approach I chose
mainis not protected by branch protection (.../branches/main/protection→ 404) but byruleset 19110427, which lists 15 required contexts:
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 justproduces 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.ymlonly. ESLint's flat config lintssrc/**/*.ts,skills/**/*.ts,tests/**/*.tsand**/*.{js,mjs}, with no type-aware preset (eslint.config.mjs:"non-type-checked recommended set only"). A
.mdordocs/*.jsonchange cannot alter itsoutput. 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 runspython3 skills/release/scripts/docs_audit.py, which validates exactlydocs/README.md,docs/catalog.jsonand repo-local Markdown links — the files docs(pool): worker-pool design v1 — step 1 of staging #3604 (no router process) #3860 touched. And thesuites read docs:
grep -rlE "['\"](\.\./)*docs/|/docs/" tests/ | wc -l→ 23 testfiles read repo
docs/, and 57 read a root Markdown file. Skipping tests on a docsdiff would reduce coverage on work that IS relevant. Its 13m30s is dominated by
Run shell standalone tests(11m03s), andtests/review-checks.test.sh/tests/sutando-migrate-rules.test.share among the docs readers.coverage-gate.yml— already short-circuits, for free.scripts/coverage-gate.shexits 0 on
git diff --name-only "$BASE"...HEAD -- '*.py'being empty. Its 6m38s ondocs(pool): worker-pool design v1 — step 1 of staging #3604 (no router process) #3860 is because a
.pytest did change. On a pure docs diff the job ischeckout (4s) + pip (9s) + early exit. Nothing to win; a filter here would also starve
coverage-comment.ymlof its artifact.lint-agents-md-sync.yml(refuses AGENTS.md stale vs CLAUDE.md) andlint-src-map.yml(refuses docs/src-map.md stale vs src/) — these exist to catch adocs 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 morecomplexity 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:
So on #3860 the eslint job would have reported success without
npm ci: 44s → ~10s perpush, 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 fullpath — the change is exercised, not just asserted.
Test, and proof it fails when broken
tests/eslint-workflow-scope-gate.test.pyextracts the gate's realrun:body from theworkflow (anchored on
id: scope) and executes it withghstubbed, so it exercises theshipped 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:
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_requestevent, 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 thenew test refuses that. Pushes to
mainare never gated, somainstays fully linted.Local gates
Not verified
in-file comment, not re-measured here.
docs(pool): worker-pool design v1 — step 1 of staging #3604 (no router process) #3860; the post-merge number will land on a real docs-only PR.
Single concern: no other workflow, script or test is touched.
Stand: Echo Act IV Pro