LearnNewsExamplesServices
Frontmatter
id18052
titleThe CI scope classifier that gates every test run has no unit spec
stateClosed
labels
bugaitestingregressionbuild
assigneesneo-opus-grace
createdAtSep 2, 2026, 12:19 AM
updatedAtSep 2, 2026, 9:34 AM
githubUrlhttps://github.com/neomjs/neo/issues/18052
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 2, 2026, 9:34 AM

The CI scope classifier that gates every test run has no unit spec

Closed v13.2.0/archive-v13-2-0-chunk-18 bugaitestingregressionbuild
neo-opus-grace
neo-opus-grace commented on Sep 2, 2026, 12:19 AM

Sub of #17922 · sibling of #17924, #17964, #17968 · covers the fix shipped by #16289

Context

c623b2f63c (#17791 via #17806) deleted 804 unit spec files on the test/playwright/unit/ai/** path prefix. #17922 censused the 30 under ai/buildScripts/ and found 27 whose subject never left this repository. Twenty-one recover verbatim and landed in #17924 / PR #17925. Six were tabled with a named cause each.

Two of those six are already delivered, measured at dev@40edf8abfa:

tabled residual actual state
AC-4, the check-spec-retirement narrowing DONE — f189cc6ffa (#17964, CLOSED) shipped deriveSubjectSuffix, findSurvivingSubjects, unaccountedSurvivors
DataSyncPipeline.spec.mjs RESTORED — f809d86e29 (#17920 / #17927), now at test/playwright/unit/buildScripts/dataSyncPipeline.spec.mjs

So #17922's owed set is 5, not 6. This ticket takes the first of the five.

The Problem

util/WorkflowScopeClassifier.spec.mjs (237 lines) covered the jobs.changes scope step in .github/workflows/test.yml. That step is not a module — it is an inline actions/github-script body — and it runs on every pull request and decides whether unit and components execute at all.

neomjs/neo CI is exactly those two projects. So a classifier that answers false too eagerly does not turn anything red: it produces a green run with nothing behind it. That is the same failure #17922 exists about, one layer above the specs that ticket restores — and the classifier has had zero coverage since 2026-08-27.

What is uncovered right now, all read from the live script at dev@40edf8abfa:

behavior consequence if it silently breaks
isCurrentPullRequestHead stale-head guard a superseded attempt classifies against a diff that is not the head's
fail-toward-running on an undeterminable package.json diff (fetch / parse / missing base ref) the optimization starts skipping suites it cannot reason about
fork-PR head content resolved from pull_request.head.repo every fork PR fails open into full suites forever — this is precisely the fix #16289 shipped, and it has been unguarded since the sweep
requiresUnitForContent (resources/content/release-notes/, .../discussions/, apps/portal/resources/data/, learn/) the corpus gate is skipped on exactly the PRs it exists to catch
isComponentsRelevantPath including .github/workflows/test.yml a scoping regression ships on the one PR guaranteed not to exercise it

Note the third row: #16289 is CLOSED COMPLETED, its fix is live, and the only spec asserting it was deleted five days later by an unrelated commit. The fix is correct today by inspection and by nothing else.

Why this one is a rewrite, not a restoration

#17922 tables this spec as "expects run_integration / run_components admissions the classifier no longer makes post-split". Confirmed mechanically rather than inherited:

$ node -e "…yaml.load('.github/workflows/test.yml').jobs.changes.outputs"
{ run_unit, run_components, skip_reason }

run_integration and run_parity are gone — both suites left with the extraction. Seven of the nine deleted arms assert them, so a verbatim restore reds on seven arms for a reason that has nothing to do with the classifier being wrong. That is a legitimate topology change in the subject, and dropping those keys is re-aiming, not trimming — which is why AC-3 below requires it to be stated in the file rather than done quietly.

The harness itself — reading the committed script string out of the YAML and evaluating it with AsyncFunction against mocked github / context / core — reconciles cleanly and is what makes the subject testable at all.

The Fix

  1. Restore to test/playwright/unit/buildScripts/util/, beside the surviving sibling tree — not the old unit/ai/ path, whose prefix is what classified it as extracted substrate and would re-arm the same deletion at the next boundary change.
  2. Re-aim the seven arms onto the outputs the job actually declares; name the dropped keys and the reason in the file.
  3. Add arms for the half the deleted spec never asserted: the stale-head guard and the run_unit docs/content classification.
  4. Pin the declared output set, so a suite added back reds here first instead of being silently unasserted.

Contract Ledger Matrix

Target Surface Source of Authority Behavior Fallback Docs Evidence
test/playwright/unit/buildScripts/util/WorkflowScopeClassifier.spec.mjs #17922 AC-1; sibling precedent check-chore-sync.spec.mjs evaluates the committed scope script against mocked Actions globals none — a classifier gating all of CI without a spec is the defect spec header each arm red against a seeded mutation of test.yml
jobs.changes declared outputs .github/workflows/test.yml exactly run_unit, run_components, skip_reason n/a spec header first arm asserts the key set

Acceptance Criteria

  • WorkflowScopeClassifier.spec.mjs exists under test/playwright/unit/buildScripts/util/ and passes against the live test.yml.
  • Every arm is shown red against a seeded mutation of the workflow it reads — a restored spec that cannot fail is not restored coverage. Mutations must cover, at minimum: fail-closed on an undeterminable diff, fork-blind head resolution, a dropped learn/ admission, a neutered stale-head guard, and a dropped self-classification.
  • The file names the assertions it dropped and why, so the difference from the deleted original is auditable rather than invisible.
  • The declared-output set is asserted directly, not implied by the other arms.
  • The unit project's collected spec-file count rises by exactly 1, measured by count.
  • #17922's body is corrected so its two already-delivered residual rows no longer read as owed.

Out of Scope

  • The other four residuals — check-theme-surfaces, RebuildContentIndexesAndSeo, PublishReleaseNoteOrphan, WorkflowConcurrency. Each needs its own rewrite-vs-retire judgement; WorkflowConcurrency is the natural next one (same harness, same subject file) and is deliberately left unclaimed.
  • Changing what the classifier does. Restoration is not the moment to fix the subject. If an arm reveals a defect, it gets filed, not patched here.
  • Making spec-retirement-lint.yml blocking — #17171 owns required-context binding for the whole *-lint.yml family.

Avoided Traps

  • Restoring verbatim to get a fast green. Seven arms would red on outputs that correctly no longer exist, and "fixing" that by deleting assertions without saying so reproduces #17922 in miniature.
  • Restoring to the old ai/ path. That prefix is the deletion trigger.
  • Treating a green run as evidence. The whole finding is that green survived the loss of 804 specs; the honest instruments here are the spec-file count and the mutation ledger.
  • Asserting run_integration because the original did. The original was right when it was written; the topology moved under it.

Related

  • #17922 — parent; the 27-subject census
  • #17924 / PR #17925 — the 21 verbatim restorations
  • #17964 — the surviving-subject guard narrowing (delivers #17922 AC-4)
  • #16289 — the fork-PR content-resolution fix this spec covers
  • #17791 / c623b2f63c — the sweep that deleted it

Handoff Retrieval Hints

  • Retrieval Hint: "the CI scope classifier that gates unit and components has no unit spec"
  • Retrieval Hint: jobs.changes outputs are run_unit/run_components/skip_reason — run_integration and run_parity left with the extraction