close
Skip to content

refactor(i18n): run the unit-literal scan as a gate, not a vitest test - #2562

Merged
iamwhatever merged 1 commit into
kirodotdev:mainfrom
k33bz:poc/extract-unit-literals
Aug 10, 2026
Merged

refactor(i18n): run the unit-literal scan as a gate, not a vitest test#2562
iamwhatever merged 1 commit into
kirodotdev:mainfrom
k33bz:poc/extract-unit-literals

Conversation

@k33bz

@k33bz k33bz commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

src/i18n/unitLiterals.test.ts runs a whole-repo TypeScript AST scan (every non-test
file under website/src) inside vitest's default 15s per-test budget. On a contended,
many-core runner it crosses that line and fails the Frontend Tests job, as reported in
#2501 (and others). The failure is contention, not a code change: a branch that only
touches tests or locale catalogs (both excluded from the scan) can still be the one
that goes red, with nothing in the output to say so.

Why it matters

A gate that fails on timing rather than on a real violation costs a full re-run of a
~14min job (Coverage Gate consumes its artifact, so one timeout takes two checks
down), and it erodes the ratchet's authority: people learn to re-run reds instead of
reading them. It gets worse as CI and developer machines gain cores.

What changed (motivation to approach to change)

Symptom: the scan times out under contention.

Root cause: a repo-scale scan, whose cost tracks repo size and worker count and is
multiplied by process-wide v8 coverage, is living under a per-test deadline.
Raising the timeout only moves the cliff (the scan goes 2.0s standalone to 28.6s at
12 workers; full table in #2501), and throttling workers costs the whole suite +82%
wall clock. So the scan moves out of vitest into a standalone gate, which is option 3
from #2501, while the matcher's unit tests stay in vitest.

  • scripts/lib/unit-literals.mjs (new): the matcher, extracted verbatim so the gate
    and the tests share one definition and cannot drift.
  • scripts/check-unit-literals.mjs (new): the gate. Three checks, strongest first:
    [added-lines] (zero tolerance), [vs-base] (zero tolerance), [ceiling] (info).
  • scripts/lib/i18n-gate-table.mjs: one script row plus three check rows. No CI
    workflow change; the i18n:check aggregator already runs the table.
  • src/i18n/unitLiterals.test.ts: trimmed 552 to 75 lines; the four matcher
    assertions stay, importing the extracted module.
  • src/test/i18nGateTable.test.ts: fixture and assertions for the new rows.

Enforcement is unchanged where it matters: the two diff-scoped checks stay zero
tolerance, so a new un-migrated literal still fails. The whole-repo ceiling is
enforce: 'info', which the gate table's contract requires for a non-hard-zero
whole-repo count, so one branch's inherited debt cannot fail an unrelated branch's build.
This is a refactor: matcher and diff logic are carried over unchanged, and equivalence is
verified rather than assumed (the gate and the tests import the same module, and the
gate's --json mode emits the raw finding set for direct comparison).

Tests

  • src/i18n/unitLiterals.test.ts: the four matcher assertions, now importing
    scripts/lib/unit-literals.mjs. They lock in that the matcher finds files to scan,
    detects the number+unit shapes that shipped, exempts CSS values, and exempts values
    routed through the format seam.
  • src/test/i18nGateTable.test.ts: a HEALTHY fixture for the new units script; a
    guard that throws if a registered script has no fixture; no-base NOT RUN assertions
    for the two diff-scoped rows plus a PASS for the ceiling; the script-count check; and
    unit-ceiling in the info list.

Manual verification

N/A, unit coverage is sufficient. For reference, the full suite is green (885 files,
12160 pass, 0 fail, 0 lint errors) and the gate runs standalone in 2.0s over 1026 files.

Related Issues

Fixes #2501.

Related flake context:

Checklist

  • Single commit with a Conventional Commits title (refactor: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable): N/A, no user-facing docs affected
  • No secrets, credentials, or internal references in the diff

The scan parses every in-scope file with the TypeScript compiler, so its cost
scales with the size of the repo rather than with what a branch changed. Inside
vitest that cost is multiplied by v8 coverage instrumentation and by every
sibling worker. Measured on one 6-core box, same commit, only --maxWorkers
varying:

  standalone gate ....  2.0 s
   2 workers .........  11.2 s   5.6x
   4 workers .........  13.7 s   6.8x   <- GitHub CI shape, 15 s budget
   6 workers .........  16.6 s   8.2x
   8 workers .........  20.3 s  10.1x
  12 workers .........  28.6 s  14.2x

The suite's 15 s per-test budget, sized for tests that `await import(...)`, is
already marginal at the CI worker count and fails outright on a developer
machine. Raising it only moves the number, because the cost tracks worker count.
Throttling workers is worse: 12 -> 2 cuts the scan 60% but costs the whole suite
82% more wall clock (512 s -> 929 s). Even at 2 workers the scan pays a 5.6x tax
it cannot escape, because v8 coverage is process-wide. There is no worker count
at which this belongs in the suite.

So it moves out, following the pattern this repo already uses for repo-scale
gates (check-source-strings.mjs, check-i18n-strings.mjs, docs-lint.sh):

- scripts/lib/unit-literals.mjs    the matcher, shared by the gate and the tests
- scripts/check-unit-literals.mjs  the gate: ceiling + two diff-scoped checks
- one line in the i18n gate table, which CI already runs; no workflow change
- unitLiterals.test.ts keeps the four matcher tests (552 -> 75 lines, 17 ms)

This is a refactor. The matcher and the diff-scoped logic are carried over
unchanged, so the gate reports the same findings with the same attribution and
the same limitations. Equivalence is verified rather than assumed: the old and
new matchers agree file-by-file across every in-scope file, identical finding
sets, nothing only-in-old and nothing only-in-new.

Two behaviour notes, both called out in the issue rather than buried:

The ceiling becomes `enforce: 'info'`. The gate table requires a whole-repo count
that is not a hard zero to be informational, because one branch's inherited debt
must not fail another branch's build. The two diff-scoped checks still stop any
new site at zero tolerance.

The diff-scoped markers print only when a base ref resolved. Printing them at
zero with no base would claim a check that never ran, and would match the table's
`find` regex and suppress its own NOT RUN branch.
@k33bz
k33bz requested a review from a team August 10, 2026 14:13
@k33bz
k33bz requested a review from a team as a code owner August 10, 2026 14:13
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention fork Pull request from a fork (external contributor) readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

UX Review (Fable 5, fork) — ✅ PASS

Advisory UX-level review of 8362fe0621ead86660e5a87154344eb197376e4d via the fork AI-review pipeline — updated in place on each push; does not block merge.

This PR touches only CI tooling (a Node gate script, the gate-table registry, and tests) — no dashboard UI, user-facing strings, or rendered surfaces change. The only humans who "experience" it are contributors reading gate output, and those messages are checked below. Everything holds up; final review:

UX-Verdict: PASS

No product UI is touched; the developer-facing gate output states what failed, why it matters, and the exact fix — self-sufficient on a cold read.

Suggestions

  • check-unit-literals.mjs prints OK: 74 un-migrated... even when [added-lines]/[vs-base] are about to print FAIL below it; a contributor scanning top-down sees "OK" first on a failing run — prefix that line with a neutral marker (e.g. [ceiling]) so "OK" never leads a red log.

[UX-REVIEWED] 8362fe0

@github-actions

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 8362fe0621ead86660e5a87154344eb197376e4d via the fork AI-review pipeline; updated in place on each push.

Review details

This is a mechanical refactor: the number+unit literal matcher is extracted from unitLiterals.test.ts into a shared scripts/lib/unit-literals.mjs, and the repo-scale scan moves out of vitest into a standalone gate script check-unit-literals.mjs registered in the i18n gate table.

I verified the key semantic points:

  • The matcher logic (unitLiteralHits, inCssContext, inScope, walk, the UNIT/CSS_UNIT regexes, SEAM/ROUND_TRIP) is carried over verbatim; both the test and the gate now import the single copy, so they can't drift.
  • Path arithmetic in the new script (HEREWEBSITESRC/REPO, PREFIX + rel) matches the map keys produced by parseAddedLines and the git cwd.
  • The three gate-table find regexes match the exact output lines the script prints ([added-lines] N number+unit…, [vs-base] N touched file(s) gained number+unit…, OK: N un-migrated number+unit literal(s) across M file(s), baseline K.), and the no-base scenario correctly stays silent on the diff markers so the table reports NOT RUN.
  • The diff-scoped checks (enforce: 'zero', exit 1 on introduced/grew) preserve the real regression protection; only the whole-repo ceiling becomes info, consistent with the pre-existing rule that a non-hard-zero repo-scale check must be informational (asserted by i18nGateTable.test.ts).
  • No AUTOSDE frontend rule (security, icons, a11y, tokens) is engaged: the .mjs files aren't matched by any src/**/*.ts(x) pattern, and the .ts/.test.ts edits are deletions/simplifications with no innerHTML, HTML string building, or icon/emoji changes.

No findings.

[OPUS-REVIEWED] 8362fe0

@github-actions

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — ✅ PASS

Advisory design-level review of 8362fe0621ead86660e5a87154344eb197376e4d via the fork AI-review pipeline — updated in place on each push; does not block merge.

Design-Verdict: PASS

Right shape: repo-scale scan moves to the gate table where every peer already lives; enforcement changes are explicit and conform to the documented ratchet rule.

Suggestions

  • computeScope()/readBase in check-unit-literals.mjs re-implements diffScope() semantics from check-i18n-strings.mjs (merge-base fallback, untracked-as-new, working-tree RHS) while importing only the hunk parser; the same drift argument that justified extracting lib/unit-literals.mjs applies — consolidate the diff-scope machinery into scripts/lib/ here or as an immediate follow-up.

[DESIGN-REVIEWED] 8362fe0

@github-actions

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed 8362fe0621ead86660e5a87154344eb197376e4d via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 8362fe0

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@iamwhatever
iamwhatever merged commit 45df9f9 into kirodotdev:main Aug 10, 2026
52 checks passed
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 10, 2026
kyleseaman pushed a commit that referenced this pull request Aug 10, 2026
The repo-wide scan parses every `.ts`/`.tsx` file under `src` -- ~1k files and
~12MB of TSX -- to find sites in a few dozen of them, and the parse is the
overwhelming majority of its runtime. That cost is what made this scan too
expensive to sit in the vitest suite at all: it timed out at 15650ms against the
15s per-test budget on main's Frontend Tests job (run 31359945669), which #2562
resolved by moving the scan out to a standalone gate. This removes the cost
itself, so the gate that inherited it is ~2x cheaper and no longer scales with
files it can never flag.

A finding needs a literal chunk whose text starts with an optional space then a
unit symbol, and in raw source such a chunk is always preceded by `}` (a template
continuation, or JSX text after an expression) or by a quote directly after `+`.
Reject a file on that raw-text test and it never gets parsed; 911 of 1026 files
now don't. The unit vocabulary is read out of `UNIT` itself so the fast path
cannot become narrower than the matcher, and the files where raw and cooked text
can legitimately disagree -- an escape, or a comment between `+` and its operand
-- are admitted unconditionally rather than reasoned about.

Three smaller costs went with it: parent pointers are no longer requested from
`createSourceFile` (the visitor passes down the ancestor chain it already knows),
the CSS-context checks now run after the cheap unit test instead of before it,
and the directory walk classifies entries from the `readdir` it already makes
instead of a `stat` per entry.

Measured on one box, interleaved A/B over 5 rounds, same corpus:

  scan phase (warm, in-process) .... 1439ms -> 434ms
  gate end-to-end (median) ......... 4313ms -> 2376ms

The matcher is unchanged and still decides every finding. Verified equivalent
against the previous implementation over all 1031 in-scope files: identical
per-file hit lists, 52 findings before and after, and zero findings in any file
the fast path skips. A new test pins the fast path to the matcher with a case per
clause that is a real finding admitted by that clause alone, so deleting a clause
fails loudly instead of going quietly blind.
kyleseaman pushed a commit that referenced this pull request Aug 10, 2026
The repo-wide scan parses every `.ts`/`.tsx` file under `src` -- ~1k files and
~12MB of TSX -- to find sites in a few dozen of them, and the parse is the
overwhelming majority of its runtime. That cost is what made this scan too
expensive to sit in the vitest suite at all: it timed out at 15650ms against the
15s per-test budget on main's Frontend Tests job (run 31359945669), which #2562
resolved by moving the scan out to a standalone gate. This removes the cost
itself, so the gate that inherited it is ~2x cheaper and no longer scales with
files it can never flag.

A finding needs a literal chunk whose text starts with an optional space then a
unit symbol, and in raw source such a chunk is always preceded by `}` (a template
continuation, or JSX text after an expression) or by a quote directly after `+`.
Reject a file on that raw-text test and it never gets parsed; 911 of 1026 files
now don't. The unit vocabulary is read out of `UNIT` itself so the fast path
cannot become narrower than the matcher, and the files where raw and cooked text
can legitimately disagree -- an escape, or a comment between `+` and its operand
-- are admitted unconditionally rather than reasoned about.

Three smaller costs went with it: parent pointers are no longer requested from
`createSourceFile` (the visitor passes down the ancestor chain it already knows),
the CSS-context checks now run after the cheap unit test instead of before it,
and the directory walk classifies entries from the `readdir` it already makes
instead of a `stat` per entry.

Measured on one box, interleaved A/B over 5 rounds, same corpus:

  scan phase (warm, in-process) .... 1439ms -> 434ms
  gate end-to-end (median) ......... 4313ms -> 2376ms

The matcher is unchanged and still decides every finding. Verified equivalent
against the previous implementation over all 1031 in-scope files: identical
per-file hit lists, 52 findings before and after, and zero findings in any file
the fast path skips. A new test pins the fast path to the matcher with a case per
clause that is a real finding admitted by that clause alone, so deleting a clause
fails loudly instead of going quietly blind.
iamwhatever pushed a commit that referenced this pull request Aug 10, 2026
…2592)

The repo-wide scan parses every `.ts`/`.tsx` file under `src` -- ~1k files and
~12MB of TSX -- to find sites in a few dozen of them, and the parse is the
overwhelming majority of its runtime. That cost is what made this scan too
expensive to sit in the vitest suite at all: it timed out at 15650ms against the
15s per-test budget on main's Frontend Tests job (run 31359945669), which #2562
resolved by moving the scan out to a standalone gate. This removes the cost
itself, so the gate that inherited it is ~2x cheaper and no longer scales with
files it can never flag.

A finding needs a literal chunk whose text starts with an optional space then a
unit symbol, and in raw source such a chunk is always preceded by `}` (a template
continuation, or JSX text after an expression) or by a quote directly after `+`.
Reject a file on that raw-text test and it never gets parsed; 911 of 1026 files
now don't. The unit vocabulary is read out of `UNIT` itself so the fast path
cannot become narrower than the matcher, and the files where raw and cooked text
can legitimately disagree -- an escape, or a comment between `+` and its operand
-- are admitted unconditionally rather than reasoned about.

Three smaller costs went with it: parent pointers are no longer requested from
`createSourceFile` (the visitor passes down the ancestor chain it already knows),
the CSS-context checks now run after the cheap unit test instead of before it,
and the directory walk classifies entries from the `readdir` it already makes
instead of a `stat` per entry.

Measured on one box, interleaved A/B over 5 rounds, same corpus:

  scan phase (warm, in-process) .... 1439ms -> 434ms
  gate end-to-end (median) ......... 4313ms -> 2376ms

The matcher is unchanged and still decides every finding. Verified equivalent
against the previous implementation over all 1031 in-scope files: identical
per-file hit lists, 52 findings before and after, and zero findings in any file
the fast path skips. A new test pins the fast path to the matcher with a case per
clause that is a real finding admitted by that clause alone, so deleting a clause
fails loudly instead of going quietly blind.

Co-authored-by: Kyle Seaman <kseam@dev-dsk-kseam-1b-55230d27.us-east-1.amazon.com>
@k33bz
k33bz deleted the poc/extract-unit-literals branch August 14, 2026 21:37
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
kirodotdev#2562)

The scan parses every in-scope file with the TypeScript compiler, so its cost
scales with the size of the repo rather than with what a branch changed. Inside
vitest that cost is multiplied by v8 coverage instrumentation and by every
sibling worker. Measured on one 6-core box, same commit, only --maxWorkers
varying:

  standalone gate ....  2.0 s
   2 workers .........  11.2 s   5.6x
   4 workers .........  13.7 s   6.8x   <- GitHub CI shape, 15 s budget
   6 workers .........  16.6 s   8.2x
   8 workers .........  20.3 s  10.1x
  12 workers .........  28.6 s  14.2x

The suite's 15 s per-test budget, sized for tests that `await import(...)`, is
already marginal at the CI worker count and fails outright on a developer
machine. Raising it only moves the number, because the cost tracks worker count.
Throttling workers is worse: 12 -> 2 cuts the scan 60% but costs the whole suite
82% more wall clock (512 s -> 929 s). Even at 2 workers the scan pays a 5.6x tax
it cannot escape, because v8 coverage is process-wide. There is no worker count
at which this belongs in the suite.

So it moves out, following the pattern this repo already uses for repo-scale
gates (check-source-strings.mjs, check-i18n-strings.mjs, docs-lint.sh):

- scripts/lib/unit-literals.mjs    the matcher, shared by the gate and the tests
- scripts/check-unit-literals.mjs  the gate: ceiling + two diff-scoped checks
- one line in the i18n gate table, which CI already runs; no workflow change
- unitLiterals.test.ts keeps the four matcher tests (552 -> 75 lines, 17 ms)

This is a refactor. The matcher and the diff-scoped logic are carried over
unchanged, so the gate reports the same findings with the same attribution and
the same limitations. Equivalence is verified rather than assumed: the old and
new matchers agree file-by-file across every in-scope file, identical finding
sets, nothing only-in-old and nothing only-in-new.

Two behaviour notes, both called out in the issue rather than buried:

The ceiling becomes `enforce: 'info'`. The gate table requires a whole-repo count
that is not a hard zero to be informational, because one branch's inherited debt
must not fail another branch's build. The two diff-scoped checks still stop any
new site at zero tolerance.

The diff-scoped markers print only when a base ref resolved. Printing them at
zero with no base would claim a check that never ran, and would match the table's
`find` regex and suppress its own NOT RUN branch.
encomjp pushed a commit to encomjp/kirocrew-customapi that referenced this pull request Aug 22, 2026
…irodotdev#2592)

The repo-wide scan parses every `.ts`/`.tsx` file under `src` -- ~1k files and
~12MB of TSX -- to find sites in a few dozen of them, and the parse is the
overwhelming majority of its runtime. That cost is what made this scan too
expensive to sit in the vitest suite at all: it timed out at 15650ms against the
15s per-test budget on main's Frontend Tests job (run 31359945669), which kirodotdev#2562
resolved by moving the scan out to a standalone gate. This removes the cost
itself, so the gate that inherited it is ~2x cheaper and no longer scales with
files it can never flag.

A finding needs a literal chunk whose text starts with an optional space then a
unit symbol, and in raw source such a chunk is always preceded by `}` (a template
continuation, or JSX text after an expression) or by a quote directly after `+`.
Reject a file on that raw-text test and it never gets parsed; 911 of 1026 files
now don't. The unit vocabulary is read out of `UNIT` itself so the fast path
cannot become narrower than the matcher, and the files where raw and cooked text
can legitimately disagree -- an escape, or a comment between `+` and its operand
-- are admitted unconditionally rather than reasoned about.

Three smaller costs went with it: parent pointers are no longer requested from
`createSourceFile` (the visitor passes down the ancestor chain it already knows),
the CSS-context checks now run after the cheap unit test instead of before it,
and the directory walk classifies entries from the `readdir` it already makes
instead of a `stat` per entry.

Measured on one box, interleaved A/B over 5 rounds, same corpus:

  scan phase (warm, in-process) .... 1439ms -> 434ms
  gate end-to-end (median) ......... 4313ms -> 2376ms

The matcher is unchanged and still decides every finding. Verified equivalent
against the previous implementation over all 1031 in-scope files: identical
per-file hit lists, 52 findings before and after, and zero findings in any file
the fast path skips. A new test pins the fast path to the matcher with a case per
clause that is a real finding admitted by that clause alone, so deleting a clause
fails loudly instead of going quietly blind.

Co-authored-by: Kyle Seaman <kseam@dev-dsk-kseam-1b-55230d27.us-east-1.amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend Tests flakes: the whole-repo unitLiterals AST scan runs under vitest default 15s timeout

2 participants