perf(lint): keep JS plugins out of default oxlint and adopt ast-grep (#33430)

* perf(lint): keep JS plugins out of default oxlint and adopt ast-grep

Any jsPlugin makes Oxlint hand every file's AST to Node, so the seven custom
plugins cost the default `oxlint` run several times its native time.

- Move app-store-performance, quadratic-buffer-concat, renderer-scrollbar-style
  and ime-enter-guard (scope- or dataflow-aware) to config/oxlint-js-plugins.json,
  run by `pnpm lint:js-plugins`, CI, lint-staged and the changed-lines gate.
- Port sort-comparator-performance, mobile-pairing and native-chat-design-tokens
  to ast-grep rules with rule tests, and delete the plugins.
- Add @ast-grep/cli, config/sgconfig.yml, `pnpm lint:ast-grep` / `test:ast-grep`,
  a CI step, and an ast-grep pass in check-changed-code-quality.
- Add flaky-test rules (wall-clock reads, real-time waits, polling loops,
  unquarantined skips, unexplained timeout bumps), baselined by a count ratchet.

* Enforce flaky-test ratchet on changed files; refresh baseline

* docs(agents): keep custom-rule details out of AGENTS.md

* Resolve ast-grep from the repo so gate scans of other roots work
This commit is contained in:
Neil
2026-10-25 00:23:38 -07:00
committed by GitHub
parent a30d4f4f28
commit 5447a26fd1
50 changed files with 2878 additions and 473 deletions
@@ -14,7 +14,6 @@ on:
- 'config/oxlint-plugins/quadratic-buffer-concat.mjs'
- 'config/scripts/*-plugin.test.mjs'
- '!config/scripts/renderer-scrollbar-style-plugin.test.mjs'
- '!config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs'
# Keep in sync with the contract list in config/vitest.performance.config.ts;
# without these a rename lands green and only breaks the next nightly.
- 'src/main/sqlite/sync-database.test.ts'
+20
View File
@@ -276,6 +276,24 @@ jobs:
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
node node_modules/oxlint/bin/oxlint --format github
# JS plugins make Oxlint hand every AST to Node, so they run apart from the native root lint.
- name: Enforce JS-plugin lint rules
if: '!cancelled()'
id: js-plugin-lint
background: true
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
node node_modules/oxlint/bin/oxlint --config config/oxlint-js-plugins.json --format github src config tests mobile native .github
- name: Enforce ast-grep rules
if: needs.code_paths.outputs.static_analysis == 'true'
run: |
node_modules/.bin/ast-grep test --config config/sgconfig.yml --skip-snapshot-tests
node_modules/.bin/ast-grep scan --config config/sgconfig.yml --min-severity warning --format github src config tests mobile
# Why the full tree: it takes ~2s, and the pre-commit hook is skippable.
- name: Check formatting
if: needs.code_paths.outputs.static_analysis == 'true'
@@ -533,6 +551,8 @@ jobs:
- wait: main-sync-spawn-ratchet
- wait: js-plugin-lint
- wait: unit-plan
- uses: actions/upload-artifact@v7
+1 -53
View File
@@ -1,48 +1,13 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
"jsPlugins": [
{
"name": "sort-comparator-performance",
"specifier": "./config/oxlint-plugins/sort-comparator-performance.mjs"
},
{
"name": "mobile-pairing",
"specifier": "./config/oxlint-plugins/mobile-pairing-qrcode-import.mjs"
},
{
"name": "app-store-performance",
"specifier": "./config/oxlint-plugins/app-store-performance.mjs"
},
{
"name": "quadratic-buffer-concat",
"specifier": "./config/oxlint-plugins/quadratic-buffer-concat.mjs"
},
{
"name": "renderer-scrollbar-style",
"specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs"
},
{
"name": "native-chat-design-tokens",
"specifier": "./config/oxlint-plugins/native-chat-design-tokens.mjs"
},
{
"name": "ime-enter-guard",
"specifier": "./config/oxlint-plugins/ime-enter-guard.mjs"
}
],
"categories": {
"correctness": "error"
},
"rules": {
"sort-comparator-performance/no-repeated-collator": "warn",
"app-store-performance/require-selector": "error",
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
"no-fallthrough": "error",
"eslint/no-useless-call": "error",
"oxc/no-accumulating-spread": "error",
"quadratic-buffer-concat/no-loop-carried-concat": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
@@ -117,7 +82,6 @@
]
}
],
"mobile-pairing/no-eager-qrcode-import": "error",
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
@@ -183,8 +147,6 @@
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error",
"ime-enter-guard/require-ime-guard": "error",
"no-restricted-properties": [
"error",
{
@@ -194,14 +156,6 @@
]
}
},
{
"files": ["src/renderer/src/components/native-chat/**/*.{ts,tsx}"],
"rules": {
"native-chat-design-tokens/no-arbitrary-font-size": "error",
"native-chat-design-tokens/no-softened-foreground": "error",
"native-chat-design-tokens/no-weakened-focus-ring": "error"
}
},
{
"files": ["src/shared/**/*.ts"],
"rules": {
@@ -217,13 +171,7 @@
{
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
"rules": {
"react/globals": "off",
"quadratic-buffer-concat/no-loop-carried-concat": "off",
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off",
"ime-enter-guard/require-ime-guard": "off",
"native-chat-design-tokens/no-arbitrary-font-size": "off",
"native-chat-design-tokens/no-softened-foreground": "off",
"native-chat-design-tokens/no-weakened-focus-ring": "off"
"react/globals": "off"
}
},
{
@@ -0,0 +1,83 @@
id: test-no-wall-clock
valid:
- "const createdAt = Date.parse('2026-01-01T00:00:00Z')"
- "const fixed = new Date('2026-01-01T00:00:00Z')"
- 'const dir = join(tmpdir(), `run-${Date.now()}`)'
- |
vi.useFakeTimers()
const now = Date.now()
invalid:
- const started = Date.now()
- 'const record = { closedAt: new Date() }'
---
id: test-no-real-time-wait
valid:
- await new Promise((resolve) => setTimeout(resolve, 0))
- await new Promise((resolve) => setImmediate(resolve))
- "await new Promise((resolve, reject) => { setTimeout(() => reject(new Error('timeout')), 5_000); emitter.once('ready', resolve) })"
- await vi.advanceTimersByTimeAsync(1_000)
- await expect.poll(() => read()).toBe(true)
invalid:
- await new Promise((resolve) => setTimeout(resolve, 50))
- await new Promise<void>((r) => setTimeout(r, 50))
- await new Promise((resolve) => setTimeout(() => resolve(), DELAY_MS))
- 'await new Promise((resolve) => { setTimeout(resolve, 20) })'
- await page.waitForTimeout(500)
- await sleep(100)
- await setTimeout(100)
- 'while (!ready()) { await sleep(0); await sleep(25) }'
---
id: test-no-polling-loop
valid:
- 'for (const item of items) { await sleep(10) }'
- 'while (queue.length > 0) { await drain(queue.shift()) }'
- 'await expect.poll(() => read(), { timeout: 5_000 }).toBe(true)'
invalid:
- 'while (!ready()) { await new Promise((resolve) => setTimeout(resolve, 20)) }'
- 'do { value = read(); await sleep(50) } while (value === undefined)'
- 'while (!ready()) { await sleep(0); await sleep(25) }'
- 'for (let attempt = 0; attempt < 20; attempt++) { if (done()) break; await page.waitForTimeout(100) }'
---
id: test-skip-needs-quarantine
valid:
- |
// quarantine: #1234 times out on Windows runners
it.skip('restores the session', async () => {})
- |
// quarantine: #1234 flakes under load
// Re-enable once the PTY fixture is deterministic.
describe.skip('pty fixture', () => {})
- test.skip(process.platform === 'win32', 'POSIX shell only')
- it.skipIf(isWindows)('uses POSIX paths', () => {})
invalid:
- it.skip('restores the session', async () => {})
- test.fixme('drag reorders tabs', async () => {})
- test.describe.skip('layout', () => {})
- it.only('focuses', () => {})
- |
// TODO: re-enable
it.skip('restores the session', async () => {})
- |
// quarantine: flaky
it.skip('restores the session', async () => {})
---
id: test-timeout-needs-reason
valid:
- "it('reads', async () => {})"
- |
// Spawns a real shell, which is slow on Windows runners.
it('spawns', async () => {}, 20_000)
- |
it('spawns', async () => {},
// Spawns a real shell.
20_000)
- |
// Builds the packaged app first.
test.setTimeout(300_000)
- 'await expect(locator).toBeVisible({ timeout: 10_000 })'
invalid:
- "it('spawns', async () => {}, 20_000)"
- "it('spawns', { timeout: 20_000 }, async () => {})"
- "describe('suite', { timeout: 60_000 }, () => {})"
- test.setTimeout(300_000)
- test.slow()
@@ -0,0 +1,32 @@
id: native-chat-no-arbitrary-font-size-tsx
valid:
- 'export const X = () => <p className="text-sm text-xs text-2xs text-3xs" />'
- 'export const X = () => <pre className="font-mono text-chat-code" />'
- 'export const X = () => <p className="text-[var(--git-decoration-added)]" />'
- 'export const X = () => <p className="min-h-[26px] max-w-[80%]" />'
invalid:
- 'export const X = () => <p className="truncate text-[13px]" />'
- 'export const X = () => <p className="text-[0.8125rem]" />'
- 'export const X = () => <p className="sm:text-[11px]" />'
- "export const X = () => <p className={cn(a && 'text-[12px]')} />"
- 'export const X = () => <p className={`flex ${a} text-[12px]`} />'
---
id: native-chat-no-arbitrary-font-size
valid:
- "const ROW = 'flex text-[var(--size)]'"
invalid:
- "const ROW = 'flex text-[10px]'"
---
id: native-chat-no-softened-foreground-tsx
valid:
- 'export const X = () => <p className="text-chat-foreground text-foreground" />'
- 'export const X = () => <p className="text-destructive/80" />'
invalid:
- 'export const X = () => <p className="text-foreground/85" />'
- 'export const X = () => <p className="hover:!text-foreground/70" />'
---
id: native-chat-no-weakened-focus-ring-tsx
valid:
- 'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring" />'
invalid:
- 'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring/70" />'
@@ -0,0 +1,11 @@
id: no-eager-qrcode-import
valid:
- "import type QRCode from 'qrcode'"
- "import { type QRCodeToStringOptions } from 'qrcode'"
- "const QRCode = await import('qrcode')"
- "import QRCode from 'qrcode-generator'"
invalid:
- "import QRCode from 'qrcode'"
- "import * as QRCode from 'qrcode'"
- "import { toString, type QRCodeToStringOptions } from 'qrcode'"
- "import 'qrcode'"
@@ -0,0 +1,18 @@
id: no-repeated-collator
valid:
- |
const collator = new Intl.Collator(locale, options)
rows.sort((a, b) => collator.compare(a.name, b.name) || a.id.localeCompare(b.id))
- rows.toSorted(collator.compare)
- "const equal = a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0"
- rows.map((a) => new Intl.Collator(a.locale))
- |
rows.sort((a, b) => {
function deferred() { return new Intl.Collator(locale) }
return a - b
})
invalid:
- "rows.sort((a, b) => a.name.localeCompare(b.name, locale, { sensitivity: 'base' }))"
- "rows.toSorted(function (a, b) { return new Intl.Collator('sv').compare(a, b) })"
- "rows['sort']((a, b) => Intl.Collator('en', { numeric: true }).compare(a, b))"
- "rows.sort((a, b) => a['localeCompare'](b, undefined, options))"
@@ -0,0 +1,139 @@
# Ratcheted by config/scripts/check-flaky-test-pattern-ratchet.mjs; info severity keeps
# `pnpm lint:ast-grep` quiet about the grandfathered baseline.
# The `utils` block matches test-no-real-time-wait.yml; keep them in sync.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: test-no-polling-loop
language: typescript
severity: info
message: A hand-rolled polling loop guesses at timing. Use expect.poll, vi.waitFor, or a locator assertion, which report what never became true.
note: 'For a deliberate loop, add `// ast-grep-ignore: test-no-polling-loop -- <reason>` above it.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
utils:
resolves-promise-executor:
inside:
stopBy: end
kind: new_expression
all:
- has: { field: constructor, regex: '^Promise$' }
- has:
field: arguments
has:
nthChild: 1
any:
- has: { field: parameter, pattern: $R }
- has:
{
field: parameters,
has: { nthChild: 1, has: { field: pattern, pattern: $R } }
}
nonzero-delay:
has: { field: arguments, has: { nthChild: 2, not: { regex: '^0$' } } }
# Inline checks rather than `constraints`: those run after matching, so a rejected candidate
# would end a `has` search instead of letting it try the next node.
real-time-sleep:
any:
- kind: call_expression
has: { field: function, has: { field: property, regex: '^waitForTimeout$' } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^setTimeout$' }
- has: { field: arguments, has: { nthChild: 1, kind: number } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^(sleep|delay|wait|pause)$' }
- has: { field: arguments, has: { nthChild: 1, not: { regex: '^0$' } } }
# Callback forms first: `any` keeps the first match, and `setTimeout($R, …)` would otherwise
# bind $R to the callback and fail the executor check.
- all:
- pattern: setTimeout(() => $R($$$), $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout(() => { $R($$$) }, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout($R, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
rule:
any: [{ kind: while_statement }, { kind: do_statement }, { kind: for_statement }]
has:
field: body
stopBy: end
matches: real-time-sleep
---
id: test-no-polling-loop-tsx
language: tsx
severity: info
message: A hand-rolled polling loop guesses at timing. Use expect.poll, vi.waitFor, or a locator assertion, which report what never became true.
note: 'For a deliberate loop, add `// ast-grep-ignore: test-no-polling-loop -- <reason>` above it.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
utils:
resolves-promise-executor:
inside:
stopBy: end
kind: new_expression
all:
- has: { field: constructor, regex: '^Promise$' }
- has:
field: arguments
has:
nthChild: 1
any:
- has: { field: parameter, pattern: $R }
- has:
{
field: parameters,
has: { nthChild: 1, has: { field: pattern, pattern: $R } }
}
nonzero-delay:
has: { field: arguments, has: { nthChild: 2, not: { regex: '^0$' } } }
# Inline checks rather than `constraints`: those run after matching, so a rejected candidate
# would end a `has` search instead of letting it try the next node.
real-time-sleep:
any:
- kind: call_expression
has: { field: function, has: { field: property, regex: '^waitForTimeout$' } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^setTimeout$' }
- has: { field: arguments, has: { nthChild: 1, kind: number } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^(sleep|delay|wait|pause)$' }
- has: { field: arguments, has: { nthChild: 1, not: { regex: '^0$' } } }
# Callback forms first: `any` keeps the first match, and `setTimeout($R, …)` would otherwise
# bind $R to the callback and fail the executor check.
- all:
- pattern: setTimeout(() => $R($$$), $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout(() => { $R($$$) }, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout($R, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
rule:
any: [{ kind: while_statement }, { kind: do_statement }, { kind: for_statement }]
has:
field: body
stopBy: end
matches: real-time-sleep
@@ -0,0 +1,130 @@
# Ratcheted by config/scripts/check-flaky-test-pattern-ratchet.mjs; info severity keeps
# `pnpm lint:ast-grep` quiet about the grandfathered baseline.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: test-no-real-time-wait
language: typescript
severity: info
message: A real-time wait makes the test slow and flaky. Await the condition instead (expect.poll, vi.waitFor, a locator assertion) or drive fake timers.
note: 'A zero-delay yield is allowed. For a deliberate wait, add `// ast-grep-ignore: test-no-real-time-wait -- <reason>` above it.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
utils:
resolves-promise-executor:
inside:
stopBy: end
kind: new_expression
all:
- has: { field: constructor, regex: '^Promise$' }
- has:
field: arguments
has:
nthChild: 1
any:
- has: { field: parameter, pattern: $R }
- has:
{
field: parameters,
has: { nthChild: 1, has: { field: pattern, pattern: $R } }
}
nonzero-delay:
has: { field: arguments, has: { nthChild: 2, not: { regex: '^0$' } } }
# Inline checks rather than `constraints`: those run after matching, so a rejected candidate
# would end a `has` search instead of letting it try the next node.
real-time-sleep:
any:
- kind: call_expression
has: { field: function, has: { field: property, regex: '^waitForTimeout$' } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^setTimeout$' }
- has: { field: arguments, has: { nthChild: 1, kind: number } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^(sleep|delay|wait|pause)$' }
- has: { field: arguments, has: { nthChild: 1, not: { regex: '^0$' } } }
# Callback forms first: `any` keeps the first match, and `setTimeout($R, …)` would otherwise
# bind $R to the callback and fail the executor check.
- all:
- pattern: setTimeout(() => $R($$$), $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout(() => { $R($$$) }, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout($R, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
rule:
matches: real-time-sleep
---
id: test-no-real-time-wait-tsx
language: tsx
severity: info
message: A real-time wait makes the test slow and flaky. Await the condition instead (expect.poll, vi.waitFor, a locator assertion) or drive fake timers.
note: 'A zero-delay yield is allowed. For a deliberate wait, add `// ast-grep-ignore: test-no-real-time-wait -- <reason>` above it.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
utils:
resolves-promise-executor:
inside:
stopBy: end
kind: new_expression
all:
- has: { field: constructor, regex: '^Promise$' }
- has:
field: arguments
has:
nthChild: 1
any:
- has: { field: parameter, pattern: $R }
- has:
{
field: parameters,
has: { nthChild: 1, has: { field: pattern, pattern: $R } }
}
nonzero-delay:
has: { field: arguments, has: { nthChild: 2, not: { regex: '^0$' } } }
# Inline checks rather than `constraints`: those run after matching, so a rejected candidate
# would end a `has` search instead of letting it try the next node.
real-time-sleep:
any:
- kind: call_expression
has: { field: function, has: { field: property, regex: '^waitForTimeout$' } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^setTimeout$' }
- has: { field: arguments, has: { nthChild: 1, kind: number } }
- kind: await_expression
has:
kind: call_expression
all:
- has: { field: function, regex: '^(sleep|delay|wait|pause)$' }
- has: { field: arguments, has: { nthChild: 1, not: { regex: '^0$' } } }
# Callback forms first: `any` keeps the first match, and `setTimeout($R, …)` would otherwise
# bind $R to the callback and fail the executor check.
- all:
- pattern: setTimeout(() => $R($$$), $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout(() => { $R($$$) }, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
- all:
- pattern: setTimeout($R, $MS)
- matches: nonzero-delay
- matches: resolves-promise-executor
rule:
matches: real-time-sleep
@@ -0,0 +1,55 @@
# Ratcheted by config/scripts/check-flaky-test-pattern-ratchet.mjs; info severity keeps
# `pnpm lint:ast-grep` quiet about the grandfathered baseline.
# A file that installs fake timers or stubs Date is trusted as a whole, to keep false positives low.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: test-no-wall-clock
language: typescript
severity: info
message: A unit test reading the wall clock can pass or fail with the time of day or machine speed. Use fixed timestamps, or vi.useFakeTimers() with vi.setSystemTime().
note: 'If the real clock is the point of the test, add `// ast-grep-ignore: test-no-wall-clock -- <reason>` above it.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
rule:
any:
- pattern: Date.now()
- pattern: new Date()
not:
any:
# `${Date.now()}` builds a unique name, not a timing assumption.
- inside: { kind: template_substitution }
- inside:
stopBy: end
kind: program
has:
stopBy: end
any:
- pattern: vi.useFakeTimers($$$)
- pattern: vi.setSystemTime($$$)
- pattern: vi.spyOn(Date, $$$)
---
id: test-no-wall-clock-tsx
language: tsx
severity: info
message: A unit test reading the wall clock can pass or fail with the time of day or machine speed. Use fixed timestamps, or vi.useFakeTimers() with vi.setSystemTime().
note: 'If the real clock is the point of the test, add `// ast-grep-ignore: test-no-wall-clock -- <reason>` above it.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
rule:
any:
- pattern: Date.now()
- pattern: new Date()
not:
any:
# `${Date.now()}` builds a unique name, not a timing assumption.
- inside: { kind: template_substitution }
- inside:
stopBy: end
kind: program
has:
stopBy: end
any:
- pattern: vi.useFakeTimers($$$)
- pattern: vi.setSystemTime($$$)
- pattern: vi.spyOn(Date, $$$)
@@ -0,0 +1,62 @@
# Ratcheted by config/scripts/check-flaky-test-pattern-ratchet.mjs; info severity keeps
# `pnpm lint:ast-grep` quiet about the grandfathered baseline.
# Conditional skips (`skipIf`, Playwright's `test.skip(condition, reason)`) are platform or
# environment guards, not quarantines, so only unconditional forms are checked.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: test-skip-needs-quarantine
language: typescript
severity: info
message: 'A skipped, focused, or fixme test needs `// quarantine: #<issue> <reason>` directly above it, so it has an owner and a way back.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
rule:
any:
- pattern: $T.skip($$$ARGS)
- pattern: $T.only($$$ARGS)
- pattern: $T.fixme($$$ARGS)
has:
field: arguments
any:
- not: { has: { nthChild: 1 } }
- has: { nthChild: 1, any: [{ kind: string }, { kind: template_string }] }
not:
inside:
kind: expression_statement
follows:
stopBy: { not: { kind: comment } }
kind: comment
regex: '^//\s*quarantine: #\d+\s+\S'
constraints:
T:
regex: '^(it|test|describe|suite|test\.describe(\.(serial|parallel))?)$'
---
id: test-skip-needs-quarantine-tsx
language: tsx
severity: info
message: 'A skipped, focused, or fixme test needs `// quarantine: #<issue> <reason>` directly above it, so it has an owner and a way back.'
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
rule:
any:
- pattern: $T.skip($$$ARGS)
- pattern: $T.only($$$ARGS)
- pattern: $T.fixme($$$ARGS)
has:
field: arguments
any:
- not: { has: { nthChild: 1 } }
- has: { nthChild: 1, any: [{ kind: string }, { kind: template_string }] }
not:
inside:
kind: expression_statement
follows:
stopBy: { not: { kind: comment } }
kind: comment
regex: '^//\s*quarantine: #\d+\s+\S'
constraints:
T:
regex: '^(it|test|describe|suite|test\.describe(\.(serial|parallel))?)$'
@@ -0,0 +1,86 @@
# Ratcheted by config/scripts/check-flaky-test-pattern-ratchet.mjs; info severity keeps
# `pnpm lint:ast-grep` quiet about the grandfathered baseline.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: test-timeout-needs-reason
language: typescript
severity: info
message: A per-test timeout bump hides slow or hanging tests. Put a comment directly above it saying why this test needs more time.
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
utils:
test-call:
has:
field: function
regex: '^(it|test|describe|suite)(\.(concurrent|sequential|serial))?$'
rule:
any:
# it('title', fn, 20_000)
- all:
- kind: call_expression
- matches: test-call
- has: { field: arguments, has: { nthChild: 3, not: { kind: comment } } }
# it('title', { timeout: 20_000 }, fn)
- all:
- kind: call_expression
- matches: test-call
- has:
field: arguments
has:
kind: object
has: { kind: pair, has: { field: key, regex: '^timeout$' } }
- pattern: test.setTimeout($$$)
- pattern: test.slow($$$)
- pattern: testInfo.setTimeout($$$)
not:
any:
# The reason may sit inside the call, beside the timeout argument.
- has: { field: arguments, has: { kind: comment } }
- inside:
kind: expression_statement
follows:
stopBy: { not: { kind: comment } }
kind: comment
---
id: test-timeout-needs-reason-tsx
language: tsx
severity: info
message: A per-test timeout bump hides slow or hanging tests. Put a comment directly above it saying why this test needs more time.
files:
- '**/*.test.ts'
- '**/*.test.tsx'
- '**/tests/e2e/**'
utils:
test-call:
has:
field: function
regex: '^(it|test|describe|suite)(\.(concurrent|sequential|serial))?$'
rule:
any:
# it('title', fn, 20_000)
- all:
- kind: call_expression
- matches: test-call
- has: { field: arguments, has: { nthChild: 3, not: { kind: comment } } }
# it('title', { timeout: 20_000 }, fn)
- all:
- kind: call_expression
- matches: test-call
- has:
field: arguments
has:
kind: object
has: { kind: pair, has: { field: key, regex: '^timeout$' } }
- pattern: test.setTimeout($$$)
- pattern: test.slow($$$)
- pattern: testInfo.setTimeout($$$)
not:
any:
# The reason may sit inside the call, beside the timeout argument.
- has: { field: arguments, has: { kind: comment } }
- inside:
kind: expression_statement
follows:
stopBy: { not: { kind: comment } }
kind: comment
@@ -0,0 +1,29 @@
# Native-chat class strings stay on the chat design tokens, including variant-prefixed classes.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: native-chat-no-arbitrary-font-size
language: typescript
severity: error
message: 'Use a chat type step (text-sm, text-xs, text-2xs, text-3xs, text-chat-code), not an arbitrary size. See docs/styleguide/native-chat.md.'
files:
- '**/src/renderer/src/components/native-chat/**'
ignores:
- '**/*.test.*'
- '**/*.spec.*'
- '**/*-benchmark.*'
rule:
kind: string_fragment
regex: '(?:^|\s)!?(?:(?:\[[^\]\s]*\]|[^\s:\[\]])+:)*!?text-\[(?:length:)?[\d.]+(?:px|rem|em)\]!?(?:\s|$)'
---
id: native-chat-no-arbitrary-font-size-tsx
language: tsx
severity: error
message: 'Use a chat type step (text-sm, text-xs, text-2xs, text-3xs, text-chat-code), not an arbitrary size. See docs/styleguide/native-chat.md.'
files:
- '**/src/renderer/src/components/native-chat/**'
ignores:
- '**/*.test.*'
- '**/*.spec.*'
- '**/*-benchmark.*'
rule:
kind: string_fragment
regex: '(?:^|\s)!?(?:(?:\[[^\]\s]*\]|[^\s:\[\]])+:)*!?text-\[(?:length:)?[\d.]+(?:px|rem|em)\]!?(?:\s|$)'
@@ -0,0 +1,29 @@
# Native-chat class strings stay on the chat design tokens, including variant-prefixed classes.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: native-chat-no-softened-foreground
language: typescript
severity: error
message: "Don't soften text-foreground by hand: use text-chat-foreground on the chat canvas or text-muted-foreground on a lifted surface. See docs/styleguide/native-chat.md."
files:
- '**/src/renderer/src/components/native-chat/**'
ignores:
- '**/*.test.*'
- '**/*.spec.*'
- '**/*-benchmark.*'
rule:
kind: string_fragment
regex: '(?:^|\s)!?(?:(?:\[[^\]\s]*\]|[^\s:\[\]])+:)*!?text-foreground/\d+!?(?:\s|$)'
---
id: native-chat-no-softened-foreground-tsx
language: tsx
severity: error
message: "Don't soften text-foreground by hand: use text-chat-foreground on the chat canvas or text-muted-foreground on a lifted surface. See docs/styleguide/native-chat.md."
files:
- '**/src/renderer/src/components/native-chat/**'
ignores:
- '**/*.test.*'
- '**/*.spec.*'
- '**/*-benchmark.*'
rule:
kind: string_fragment
regex: '(?:^|\s)!?(?:(?:\[[^\]\s]*\]|[^\s:\[\]])+:)*!?text-foreground/\d+!?(?:\s|$)'
@@ -0,0 +1,29 @@
# Native-chat class strings stay on the chat design tokens, including variant-prefixed classes.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: native-chat-no-weakened-focus-ring
language: typescript
severity: error
message: 'A focus ring at reduced opacity fails 3:1 contrast on the chat canvas: use ring-ring. See docs/styleguide/native-chat.md.'
files:
- '**/src/renderer/src/components/native-chat/**'
ignores:
- '**/*.test.*'
- '**/*.spec.*'
- '**/*-benchmark.*'
rule:
kind: string_fragment
regex: '(?:^|\s)!?(?:(?:\[[^\]\s]*\]|[^\s:\[\]])+:)*!?ring-ring/\d+!?(?:\s|$)'
---
id: native-chat-no-weakened-focus-ring-tsx
language: tsx
severity: error
message: 'A focus ring at reduced opacity fails 3:1 contrast on the chat canvas: use ring-ring. See docs/styleguide/native-chat.md.'
files:
- '**/src/renderer/src/components/native-chat/**'
ignores:
- '**/*.test.*'
- '**/*.spec.*'
- '**/*-benchmark.*'
rule:
kind: string_fragment
regex: '(?:^|\s)!?(?:(?:\[[^\]\s]*\]|[^\s:\[\]])+:)*!?ring-ring/\d+!?(?:\s|$)'
@@ -0,0 +1,33 @@
# Startup must not parse the qrcode bundle; only mobile pairing reaches it.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: no-eager-qrcode-import
language: typescript
severity: error
message: qrcode is only reachable from mobile pairing. Use `import type` plus `await import('qrcode')` so startup does not parse its bundle.
rule:
kind: import_statement
has: { field: source, regex: '^.qrcode.$' }
not:
any:
- regex: '^import\s+type\b'
# `import { type A, type B } from 'qrcode'` erases too.
- all:
- has: { kind: import_clause, has: { kind: named_imports } }
- not: { has: { kind: import_clause, has: { kind: identifier } } }
- not: { has: { stopBy: end, kind: import_specifier, not: { regex: '^type\s' } } }
---
id: no-eager-qrcode-import-tsx
language: tsx
severity: error
message: qrcode is only reachable from mobile pairing. Use `import type` plus `await import('qrcode')` so startup does not parse its bundle.
rule:
kind: import_statement
has: { field: source, regex: '^.qrcode.$' }
not:
any:
- regex: '^import\s+type\b'
# `import { type A, type B } from 'qrcode'` erases too.
- all:
- has: { kind: import_clause, has: { kind: named_imports } }
- not: { has: { kind: import_clause, has: { kind: identifier } } }
- not: { has: { stopBy: end, kind: import_specifier, not: { regex: '^type\s' } } }
@@ -0,0 +1,61 @@
# Resolving collation options inside a sort comparator repeats setup for every comparison.
# The TSX document below mirrors the TypeScript one; keep them in sync.
id: no-repeated-collator
language: typescript
severity: error
message: Create one Intl.Collator before sorting and reuse its compare method; resolving collation options inside the comparator repeats setup for every comparison. Preserve the locale, options, and tie-breaker.
rule:
any:
- pattern: $S.localeCompare($A, $L, $O)
- pattern: $S['localeCompare']($A, $L, $O)
- pattern: new Intl.Collator($$$ARGS)
- pattern: Intl.Collator($$$ARGS)
# The nearest enclosing function must be the comparator itself.
inside:
stopBy:
any:
- kind: arrow_function
- kind: function_expression
- kind: function_declaration
- kind: method_definition
any: [{ kind: arrow_function }, { kind: function_expression }]
nthChild: 1
inside:
kind: arguments
inside:
kind: call_expression
has:
field: function
any:
- { kind: member_expression, has: { field: property, regex: '^(sort|toSorted)$' } }
- { kind: subscript_expression, has: { field: index, regex: '^.(sort|toSorted).$' } }
---
id: no-repeated-collator-tsx
language: tsx
severity: error
message: Create one Intl.Collator before sorting and reuse its compare method; resolving collation options inside the comparator repeats setup for every comparison. Preserve the locale, options, and tie-breaker.
rule:
any:
- pattern: $S.localeCompare($A, $L, $O)
- pattern: $S['localeCompare']($A, $L, $O)
- pattern: new Intl.Collator($$$ARGS)
- pattern: Intl.Collator($$$ARGS)
# The nearest enclosing function must be the comparator itself.
inside:
stopBy:
any:
- kind: arrow_function
- kind: function_expression
- kind: function_declaration
- kind: method_definition
any: [{ kind: arrow_function }, { kind: function_expression }]
nthChild: 1
inside:
kind: arguments
inside:
kind: call_expression
has:
field: function
any:
- { kind: member_expression, has: { field: property, regex: '^(sort|toSorted)$' } }
- { kind: subscript_expression, has: { field: index, regex: '^.(sort|toSorted).$' } }
File diff suppressed because it is too large Load Diff
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": [],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"jsPlugins": [
{
"name": "app-store-performance",
"specifier": "./oxlint-plugins/app-store-performance.mjs"
},
{
"name": "quadratic-buffer-concat",
"specifier": "./oxlint-plugins/quadratic-buffer-concat.mjs"
},
{
"name": "renderer-scrollbar-style",
"specifier": "./oxlint-plugins/renderer-scrollbar-style.mjs"
},
{
"name": "ime-enter-guard",
"specifier": "./oxlint-plugins/ime-enter-guard.mjs"
}
],
"rules": {
"app-store-performance/require-selector": "error",
"app-store-performance/no-identity-selector": "error",
"app-store-performance/no-fresh-selector-result": "error",
"quadratic-buffer-concat/no-loop-carried-concat": "error"
},
"overrides": [
{
"files": ["**/src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error",
"ime-enter-guard/require-ime-guard": "error"
}
},
{
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
"rules": {
"quadratic-buffer-concat/no-loop-carried-concat": "off",
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off",
"ime-enter-guard/require-ime-guard": "off"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
}
+1 -6
View File
@@ -18,10 +18,6 @@
{
"name": "quadratic-buffer-concat",
"specifier": "../config/oxlint-plugins/quadratic-buffer-concat.mjs"
},
{
"name": "sort-comparator-performance",
"specifier": "../config/oxlint-plugins/sort-comparator-performance.mjs"
}
],
"rules": {
@@ -29,8 +25,7 @@
"app-store-performance/no-identity-selector": "warn",
"app-store-performance/no-fresh-selector-result": "warn",
"app-store-performance/no-nested-fresh-under-shallow": "warn",
"quadratic-buffer-concat/no-loop-carried-concat": "warn",
"sort-comparator-performance/no-repeated-collator": "warn"
"quadratic-buffer-concat/no-loop-carried-concat": "warn"
},
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "**/*.test.*", "**/*.spec.*"]
}
@@ -1,29 +0,0 @@
const MESSAGE =
"qrcode is only reachable from mobile pairing. Use `import type` plus `await import('qrcode')` so startup does not parse its bundle."
function hasRuntimeImport(node) {
if (node.importKind === 'type') {
return false
}
return (
node.specifiers.length === 0 ||
node.specifiers.some((specifier) => specifier.importKind !== 'type')
)
}
export default {
meta: { name: 'mobile-pairing' },
rules: {
'no-eager-qrcode-import': {
create(context) {
return {
ImportDeclaration(node) {
if (node.source?.value === 'qrcode' && hasRuntimeImport(node)) {
context.report({ node, message: MESSAGE })
}
}
}
}
}
}
}
@@ -1,49 +0,0 @@
import { plainClassName } from './renderer-scrollbar-style.mjs'
const CHECKS = {
'no-arbitrary-font-size': {
pattern: /^text-\[(?:length:)?[\d.]+(?:px|rem|em)\]$/,
message:
'Use a chat type step (text-sm, text-xs, text-2xs, text-3xs, text-chat-code), not an arbitrary size. See docs/styleguide/native-chat.md.'
},
'no-softened-foreground': {
pattern: /^text-foreground\/\d+$/,
message:
"Don't soften text-foreground by hand: use text-chat-foreground on the chat canvas or text-muted-foreground on a lifted surface. See docs/styleguide/native-chat.md."
},
'no-weakened-focus-ring': {
pattern: /^ring-ring\/\d+$/,
message:
'A focus ring at reduced opacity fails 3:1 contrast on the chat canvas: use ring-ring. See docs/styleguide/native-chat.md.'
}
}
function literalTexts(node) {
if (node.type === 'Literal') {
return typeof node.value === 'string' ? [node.value] : []
}
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
}
function createRule({ pattern, message }) {
return {
create(context) {
const check = (node) => {
const offending = literalTexts(node)
.flatMap((text) => text.split(/\s+/))
.find((token) => pattern.test(plainClassName(token)))
if (offending) {
context.report({ node, message: `${offending}: ${message}` })
}
}
return { Literal: check, TemplateLiteral: check }
}
}
}
export default {
meta: { name: 'native-chat-design-tokens' },
rules: Object.fromEntries(
Object.entries(CHECKS).map(([name, check]) => [name, createRule(check)])
)
}
@@ -1,60 +0,0 @@
const FUNCTION_TYPES = new Set([
'ArrowFunctionExpression',
'FunctionExpression',
'FunctionDeclaration'
])
function propertyName(node) {
if (node?.type !== 'MemberExpression') {
return null
}
if (!node.computed && node.property.type === 'Identifier') {
return node.property.name
}
return node.property.type === 'Literal' ? node.property.value : null
}
function isInlineSortComparator(node) {
for (let parent = node.parent; parent; parent = parent.parent) {
if (!FUNCTION_TYPES.has(parent.type)) {
continue
}
const call = parent.parent
return (
call?.type === 'CallExpression' &&
call.arguments[0] === parent &&
['sort', 'toSorted'].includes(propertyName(call.callee))
)
}
return false
}
function isCollatorConstruction(node) {
return (
node.callee?.object?.type === 'Identifier' &&
node.callee.object.name === 'Intl' &&
propertyName(node.callee) === 'Collator'
)
}
function createRule(context) {
function inspect(node) {
const optionedComparison =
node.type === 'CallExpression' &&
propertyName(node.callee) === 'localeCompare' &&
node.arguments.length >= 3
if ((optionedComparison || isCollatorConstruction(node)) && isInlineSortComparator(node)) {
context.report({
node,
message:
'Create one Intl.Collator before sorting and reuse its compare method; resolving collation options inside the comparator repeats setup for every comparison. Preserve the locale, options, and tie-breaker.'
})
}
}
return { CallExpression: inspect, NewExpression: inspect }
}
export default {
meta: { name: 'sort-comparator-performance' },
rules: { 'no-repeated-collator': { create: createRule } }
}
+4 -4
View File
@@ -1,18 +1,18 @@
# Performance regression checks
`pnpm --silent audit:perf > performance-audit.json` scans production `src/` with
the existing app-store and buffer-concatenation rules plus the sort-comparator
rule. Warnings are advisory in this full inventory; tool/parser failures fail.
the existing app-store and buffer-concatenation rules. Warnings are advisory in this full inventory; tool/parser failures fail.
New warning findings on changed lines fail `pnpm check:code-quality:changed`.
Tests, generated files, `mobile/` and `cloud/` are outside this source audit.
The sort rule detects optioned `localeCompare` and `Intl.Collator` construction
The ast-grep rule `config/ast-grep/rules/no-repeated-collator.yml` (`pnpm lint:ast-grep`,
an error everywhere) detects optioned `localeCompare` and `Intl.Collator` construction
inside inline `sort`/`toSorted` callbacks. Construct one collator outside the
callback, preserving locale, options and tie-breakers. If the locale changes at
runtime, reconstruct at the next sort or key the cache by locale. Bare comparisons
and standalone equality checks are allowed. There is no autofix or interprocedural
analysis: named comparators, aliases, custom methods and deferred callbacks need
manual review. A warning identifies repeated setup, not proof of visible lag.
manual review. A finding identifies repeated setup, not proof of visible lag.
`pnpm test:perf:contracts` runs the explicit selection in
`vitest.performance.config.ts`: SQLite statement reuse and schema parity, relay
+50
View File
@@ -0,0 +1,50 @@
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import process from 'node:process'
export const AST_GREP_CONFIG = 'config/sgconfig.yml'
export const AST_GREP_SCAN_PATHS = ['src', 'config', 'tests', 'mobile']
// Why the platform binary: the package's `ast-grep` bin is either a JS launcher or (after its
// postinstall) the native binary, and on Windows a .cmd shim Node refuses to spawn without a shell.
// Resolved from this script, not the scanned root, so scans of temp or nested roots still find it.
export function resolveAstGrepBinary() {
const binary = createRequire(import.meta.url)('@ast-grep/cli/postinstall.js').resolveBinaryPath()
if (!binary) {
throw new Error('No @ast-grep/cli binary for this platform; run pnpm install.')
}
return binary
}
/** Runs `ast-grep scan` and returns its JSON matches; exit status 1 only means error findings. */
export function scanAstGrep(root, { filter, minSeverity, paths = AST_GREP_SCAN_PATHS } = {}) {
const args = ['scan', '--config', AST_GREP_CONFIG, '--json=stream', '--color', 'never']
if (filter) {
args.push('--filter', filter)
}
if (minSeverity) {
args.push('--min-severity', minSeverity)
}
const result = spawnSync(resolveAstGrepBinary(), [...args, '--', ...paths], {
cwd: root,
encoding: 'utf8',
maxBuffer: 256 * 1024 * 1024,
windowsHide: true
})
if (result.error) {
throw result.error
}
if (result.status !== 0 && result.status !== 1) {
process.stderr.write(result.stderr)
throw new Error(`ast-grep scan failed with exit code ${result.status}.`)
}
return result.stdout
.split('\n')
.filter((line) => line.trim())
.map((line) => JSON.parse(line))
}
/** Per-language twins share one rule id with a `-tsx` suffix; report them as one rule. */
export function baseRuleId(ruleId) {
return ruleId.replace(/-tsx$/, '')
}
+48 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { runProcessSync } from '@orca/process-host'
import { scanAstGrep } from './ast-grep-scan.mjs'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
@@ -16,6 +17,14 @@ const ANTI_SLOP_DISABLE_PATTERN =
/\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*\banti-slop\//
const REACT_DOCTOR_DISABLE_PATTERN =
/^\s*\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s+react-doctor\/[\w-]+(?:\s*,\s*react-doctor\/[\w-]+)*\s*(?:--(?:(?!\*\/).)*)?(?:\*\/)?\s*$/
const JS_PLUGINS_CONFIG = 'config/oxlint-js-plugins.json'
// The root scan no longer loads these plugins, so their suppressions belong to the JS plugins scan.
const JS_PLUGIN_NAMES = JSON.parse(
readFileSync(new URL(`../../${JS_PLUGINS_CONFIG}`, import.meta.url), 'utf8')
).jsPlugins.map((plugin) => plugin.name)
const JS_PLUGIN_DISABLE_PATTERN = new RegExp(
String.raw`\/[/*]\s*(?:oxlint|eslint)-disable(?:-next-line|-line)?\s[^\n]*\b(?:${JS_PLUGIN_NAMES.join('|')})\/`
)
const EXPLICIT_DISABLE_RULE_PATTERN = /(?:-disable(?:-next-line|-line)?\s+|^)[\w-]+(?:\/[\w-]+)?/
export const OXLINT_SCANS = [
{
@@ -24,6 +33,12 @@ export const OXLINT_SCANS = [
label: 'code quality',
args: ['--report-unused-disable-directives-severity', 'warn']
},
{
// Why separate: any JS plugin makes Oxlint hand every file's AST to Node, which costs the
// default `oxlint` run several times its native time, so these rules run only in gates.
label: 'JS plugins',
args: ['--config', JS_PLUGINS_CONFIG]
},
{
label: 'casting code quality',
args: ['--config', 'config/oxlint-code-quality-casting.json']
@@ -483,7 +498,10 @@ export function isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, scanLab
}
return (diagnostic.labels ?? []).some((label) =>
diagnosticHighlightedLines(root, diagnostic.filename, label.span).some(
(line) => ANTI_SLOP_DISABLE_PATTERN.test(line) || REACT_DOCTOR_DISABLE_PATTERN.test(line)
(line) =>
ANTI_SLOP_DISABLE_PATTERN.test(line) ||
REACT_DOCTOR_DISABLE_PATTERN.test(line) ||
JS_PLUGIN_DISABLE_PATTERN.test(line)
)
)
}
@@ -541,6 +559,24 @@ function runOxlintScan(root, scan, files) {
return parseOxlintOutput(result.stdout, scan.label).diagnostics ?? []
}
/** ast-grep matches as Oxlint-shaped diagnostics; `info` flaky-test rules are owned by their ratchet. */
export function runAstGrepScan(root, files) {
return scanAstGrep(root, { minSeverity: 'warning', paths: files }).map((match) => ({
filename: match.file,
code: `ast-grep(${match.ruleId})`,
message: match.message,
labels: [
{
span: {
line: match.range.start.line + 1,
offset: match.range.byteOffset.start,
length: match.range.byteOffset.end - match.range.byteOffset.start
}
}
]
}))
}
export function main(
root = process.cwd(),
requestedBase = process.argv.slice(2).find((argument) => argument !== '--')
@@ -575,6 +611,17 @@ export function main(
)
}
const astGrepFindings = runAstGrepScan(root, files).filter((diagnostic) =>
diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks, movedCodeMatcher)
)
for (const diagnostic of astGrepFindings) {
printDiagnostic(diagnostic, root)
}
failures += astGrepFindings.length
console.log(
`ast-grep: ${astGrepFindings.length} new finding(s) across ${files.length} changed file(s).`
)
const missingSafety = findCastingDirectivesMissingSafety(root, rangesByFile)
for (const diagnostic of missingSafety) {
printDiagnostic(diagnostic, root)
@@ -282,6 +282,9 @@ describe('unloaded plugin directive unused warning', () => {
withFixture(directive('anti-slop/no-module-mocking'), (diagnostic) => {
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(true)
})
withFixture(directive('ime-enter-guard/require-ime-guard'), (diagnostic) => {
expect(isUnloadedPluginDirectiveUnusedWarning(diagnostic, root, 'code quality')).toBe(true)
})
})
it('still reports an unused directive for a rule the root scan does load', () => {
@@ -0,0 +1,103 @@
import { execFileSync } from 'node:child_process'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { baseRuleId, scanAstGrep } from './ast-grep-scan.mjs'
import { resolvePullRequestDiffBase } from './git-pull-request-diff-base.mjs'
import { listTrackedFiles, runCountRatchet } from './ratchet-baseline.mjs'
// Ratchet on the flaky-test ast-grep rules in config/ast-grep/rules/flaky-tests: wall-clock reads,
// real-time waits, polling loops, unquarantined skips, and unexplained timeout bumps. Each row
// counts one rule in one file, so fixing one pattern cannot pay for adding another.
const BASELINE_PATH = 'config/flaky-test-pattern-baseline.txt'
const RULE_FILTER = '^test-'
const HEADER = [
'# Flaky-test pattern findings per rule and file (config/ast-grep/rules/flaky-tests).',
'# This is a RATCHET: counts may only go DOWN. Run `pnpm lint:ast-grep:flaky-tests` to list them.',
'# Prune after a fix: pnpm check:flaky-test-pattern-ratchet --prune'
]
export function countFindings(matches, isTracked = () => true) {
const counts = new Map()
for (const match of matches) {
const file = match.file.split('\\').join('/')
if (!isTracked(file)) {
continue
}
const key = `${baseRuleId(match.ruleId)}:${file}`
counts.set(key, (counts.get(key) ?? 0) + 1)
}
return counts
}
export function collectFlakyTestPatternCounts(root = process.cwd()) {
// Tracked files only, so untracked scratch never changes the baseline.
const tracked = new Set(listTrackedFiles(root, []))
return countFindings(scanAstGrep(root, { filter: RULE_FILTER }), (file) => tracked.has(file))
}
function git(root, args) {
return execFileSync('git', args, {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
}
// Why: these patterns are common, so a whole-tree count breaks every PR as soon as any merge adds
// one. Enforce only the files this change touches; unchanged files keep their grandfathered counts.
export function listChangedFiles(root = process.cwd()) {
if (process.env.ORCA_FLAKY_TEST_RATCHET_ALL === '1') {
return null
}
let base
try {
const mergeBase = git(root, ['merge-base', 'HEAD', 'origin/main']).trim()
base = resolvePullRequestDiffBase(root, mergeBase)
} catch {
try {
base = resolvePullRequestDiffBase(root, undefined)
} catch {
return null
}
}
if (!base) {
return null
}
const changed = git(root, ['diff', '--name-only', '--no-renames', base])
.split('\n')
.filter(Boolean)
return new Set(changed)
}
export function changedFileScope(changed) {
if (!changed) {
return undefined
}
return (key) => changed.has(key.slice(key.indexOf(':') + 1))
}
export function main(root = process.cwd(), arg = undefined) {
return runCountRatchet(
{
name: 'flaky-test-pattern',
baselinePath: BASELINE_PATH,
header: HEADER,
collect: collectFlakyTestPatternCounts,
scope: (dir) => changedFileScope(listChangedFiles(dir)),
unit: 'flaky-test finding(s)',
fix: [
'Wait on the condition (expect.poll, vi.waitFor, locator assertions) instead of sleeping or',
'polling, use fake timers or fixed timestamps instead of the wall clock, put',
'`// quarantine: #<issue> <reason>` above a skip, and a reason comment above a timeout bump.',
'A deliberate exception takes `// ast-grep-ignore: <rule-id> -- <reason>` above the line.'
]
},
root,
arg
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(main(process.cwd(), process.argv[2]))
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { changedFileScope, countFindings } from './check-flaky-test-pattern-ratchet.mjs'
describe('countFindings', () => {
it('counts each rule per file, folding TSX twins and Windows separators', () => {
const counts = countFindings(
[
{ ruleId: 'test-no-wall-clock', file: 'src/a.test.ts' },
{ ruleId: 'test-no-wall-clock', file: 'src\\a.test.ts' },
{ ruleId: 'test-no-wall-clock-tsx', file: 'src/b.test.tsx' },
{ ruleId: 'test-no-real-time-wait', file: 'src/a.test.ts' },
{ ruleId: 'test-no-real-time-wait', file: 'scratch.test.ts' }
],
(file) => file !== 'scratch.test.ts'
)
expect([...counts]).toEqual([
['test-no-wall-clock:src/a.test.ts', 2],
['test-no-wall-clock:src/b.test.tsx', 1],
['test-no-real-time-wait:src/a.test.ts', 1]
])
})
})
describe('changedFileScope', () => {
it('limits enforcement to findings in changed files', () => {
const inScope = changedFileScope(new Set(['src/a.test.ts']))
expect(inScope('test-no-wall-clock:src/a.test.ts')).toBe(true)
expect(inScope('test-no-wall-clock:src/b.test.ts')).toBe(false)
expect(changedFileScope(null)).toBeUndefined()
})
})
-3
View File
@@ -144,7 +144,6 @@
"config/scripts/managed-data-account-runtime.test.ts": 925,
"config/scripts/merge-orcad-prebuilds.test.mjs": 66,
"config/scripts/mobile-entry-static-closure-work-budget.test.mjs": 90,
"config/scripts/mobile-pairing-qrcode-import-plugin.test.mjs": 367,
"config/scripts/mobile-process-host-tooling.test.mjs": 751,
"config/scripts/mobile-release-check-scope.test.mjs": 775,
"config/scripts/mobile-test-change-scope.test.mjs": 130,
@@ -205,7 +204,6 @@
"config/scripts/moved-code-normalization-budget.test.mjs": 94,
"config/scripts/msbuild-file-tracking.test.mjs": 33,
"config/scripts/native-chat-compaction-pagination.test.mjs": 456,
"config/scripts/native-chat-design-tokens-plugin.test.mjs": 1531,
"config/scripts/node-old-space-limit.test.mjs": 41,
"config/scripts/node-server-change-scope.test.mjs": 2683,
"config/scripts/node-server-concurrency.test.mjs": 173,
@@ -304,7 +302,6 @@
"config/scripts/shared-electron-dist-cache.test.ts": 97,
"config/scripts/skill-description-length.test.mjs": 64,
"config/scripts/skill-recipe-shell.test.mjs": 1238,
"config/scripts/sort-comparator-performance-plugin.test.mjs": 249,
"config/scripts/space-sharing-copy.test.ts": 121,
"config/scripts/ssh-browser-e2e-routing.test.mjs": 118,
"config/scripts/stable-release-tag-selection-budget.test.mjs": 118,
@@ -129,7 +129,7 @@ for (const count of [36, 50, 250]) {
const issues = makeJiraIssues(count)
const before = () =>
[...issues]
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator.
// ast-grep-ignore: no-repeated-collator -- Baseline measures per-comparison setup against a reused collator.
.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
.map((issue) => issue.key)
const after = () => sortJiraIssues(issues, 'key', 'asc').map((issue) => issue.key)
@@ -143,7 +143,7 @@ for (const count of [36, 50, 250]) {
for (const count of [10, 50, 250]) {
const values = makeBaseSensitivityValues(count)
const before = () =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Baseline measures per-comparison setup against a reused collator.
// ast-grep-ignore: no-repeated-collator -- Baseline measures per-comparison setup against a reused collator.
[...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
const after = () => [...values].sort(compareBaseSensitivityLocaleText)
assertSameOrder(before, after, `base ${count}`)
@@ -1,48 +0,0 @@
import { spawnSync } from 'node:child_process'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/mobile-pairing-qrcode-import.mjs')
const oxlint = resolveOxlintInvocation()
function lintSource(source) {
const directory = mkdtempSync(path.join(tmpdir(), 'orca-qrcode-import-lint-'))
const sourcePath = path.join(directory, 'sample.ts')
const configPath = path.join(directory, 'oxlint.json')
writeFileSync(sourcePath, source)
writeFileSync(
configPath,
JSON.stringify({
categories: { correctness: 'off' },
jsPlugins: [{ name: 'mobile-pairing', specifier: pluginPath }],
rules: { 'mobile-pairing/no-eager-qrcode-import': 'error' }
})
)
const result = spawnSync(
oxlint.command,
[...oxlint.prefixArgs, '--config', configPath, '--format', 'json', sourcePath],
{ encoding: 'utf8', windowsHide: true }
)
if (result.error) {
throw result.error
}
return JSON.parse(result.stdout).diagnostics
}
describe('mobile pairing qrcode import rule', () => {
it('rejects eager runtime imports', () => {
const diagnostics = lintSource("import QRCode from 'qrcode'\nvoid QRCode")
expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
'mobile-pairing(no-eager-qrcode-import)'
])
})
it('allows type-only and lazy imports', () => {
expect(lintSource("import type QRCode from 'qrcode'\nlet qr: typeof QRCode")).toEqual([])
expect(lintSource("const QRCode = await import('qrcode')\nvoid QRCode")).toEqual([])
})
})
+6 -1
View File
@@ -86,13 +86,18 @@ describe('moved-code matcher and changed-line gate', () => {
if (command === 'git') {
return { status: 0, stdout: args[0] === 'show' ? baseline.join('\n') : '' }
}
if (args[0] === 'scan') {
return { status: 0, stdout: '', stderr: '' }
}
return { status: 1, stdout: JSON.stringify({ diagnostics }), stderr: '' }
})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
try {
expect(quality.main(root, 'baseline')).toBe(1)
const scans = processCalls.spawnSync.mock.calls.filter(([command]) => command !== 'git')
const scans = processCalls.spawnSync.mock.calls.filter(
([command, args]) => command !== 'git' && args[0] !== 'scan'
)
expect(scans).toHaveLength(quality.OXLINT_SCANS.length)
const annotations = error.mock.calls
.map(([message]) => message)
@@ -1,99 +0,0 @@
import path from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { runOxlintPluginOnSources } from './oxlint-plugin-test-runner.mjs'
const pluginPath = path.resolve('config/oxlint-plugins/native-chat-design-tokens.mjs')
function lintSources(sources) {
return runOxlintPluginOnSources({
pluginName: 'native-chat-design-tokens',
pluginPath,
sources,
rules: {
'native-chat-design-tokens/no-arbitrary-font-size': 'warn',
'native-chat-design-tokens/no-softened-foreground': 'warn',
'native-chat-design-tokens/no-weakened-focus-ring': 'warn'
}
})
}
const violations = [
[
'arbitrary px size',
'native-chat-design-tokens(no-arbitrary-font-size)',
'export const X = () => <p className="truncate text-[13px]" />'
],
[
'arbitrary rem size',
'native-chat-design-tokens(no-arbitrary-font-size)',
'export const X = () => <p className="text-[0.8125rem]" />'
],
[
'arbitrary size behind a variant',
'native-chat-design-tokens(no-arbitrary-font-size)',
'export const X = () => <p className="sm:text-[11px]" />'
],
[
'arbitrary size in a class constant',
'native-chat-design-tokens(no-arbitrary-font-size)',
"const ROW = 'flex text-[10px]'"
],
[
'arbitrary size in a composer',
'native-chat-design-tokens(no-arbitrary-font-size)',
"export const X = () => <p className={cn(a && 'text-[12px]')} />"
],
[
'softened foreground',
'native-chat-design-tokens(no-softened-foreground)',
'export const X = () => <p className="text-foreground/85" />'
],
[
'weakened focus ring',
'native-chat-design-tokens(no-weakened-focus-ring)',
'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring/70" />'
]
]
const accepted = [
['named steps', 'export const X = () => <p className="text-sm text-xs text-2xs text-3xs" />'],
['code step', 'export const X = () => <pre className="font-mono text-chat-code" />'],
[
'arbitrary colour',
'export const X = () => <p className="text-[var(--git-decoration-added)]" />'
],
[
'arbitrary non-font length',
'export const X = () => <p className="min-h-[26px] max-w-[80%]" />'
],
[
'chat and surface text colours',
'export const X = () => <p className="text-chat-foreground text-foreground" />'
],
['softened state colour', 'export const X = () => <p className="text-destructive/80" />'],
[
'full focus ring',
'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring" />'
]
]
const diagnosticsBySource = new Map()
beforeAll(() => {
const sources = [
...violations.map(([, , source]) => source),
...accepted.map(([, source]) => source)
]
lintSources(sources).forEach((diagnostics, index) => {
diagnosticsBySource.set(sources[index], diagnostics)
})
})
describe('native-chat-design-tokens oxlint plugin', () => {
it.each(violations)('reports %s', (_name, rule, source) => {
expect(diagnosticsBySource.get(source).map((diagnostic) => diagnostic.code)).toEqual([rule])
})
it.each(accepted)('accepts %s', (_name, source) => {
expect(diagnosticsBySource.get(source)).toEqual([])
})
})
+1
View File
@@ -418,6 +418,7 @@ const DESKTOP_IRRELEVANT_PREFIXES = [
]
const STATIC_ANALYSIS_AUDIT_SCRIPTS = [
'lint:js-plugins',
'audit:code-quality:native',
'audit:code-quality:type-aware',
'audit:anti-slop'
@@ -234,6 +234,7 @@ it('pins every foreground and background step to its selected phase', () => {
(step) => !step.background && /outputs\.(static_analysis|typecheck)/.test(step.if ?? '')
)
expect(foreground.map((step) => [step.name ?? step.run ?? step.uses, step.if])).toEqual([
['Enforce ast-grep rules', staticPhase],
['Check formatting', staticPhase],
['Reject low-evidence patterns', staticPhase],
['Enforce type-aware code-quality baseline', staticPhase],
@@ -270,6 +271,7 @@ it('pins every foreground and background step to its selected phase', () => {
['process-host-imports', `\${{ ${staticPhase} }}`],
['main-sync-spawn-ratchet', `\${{ ${staticPhase} }}`],
['root-lint', `\${{ ${staticPhase} }}`],
['js-plugin-lint', `\${{ ${staticPhase} }}`],
['native-code-quality', `\${{ ${staticPhase} }}`],
['changed-code-quality', `\${{ ${staticPhase} }}`],
['typecheck', `\${{ ${typePhase} }}`],
+3 -1
View File
@@ -188,7 +188,9 @@ export function runCountRatchet(ratchet, root, arg) {
const baseline = parseCountBaseline(
fs.existsSync(baselineFile) ? fs.readFileSync(baselineFile, 'utf8') : ''
)
const { grown, shrunk } = diffCounts(current, baseline)
const inScope = ratchet.scope?.(root)
const keep = (counts) => (inScope ? new Map([...counts].filter(([key]) => inScope(key))) : counts)
const { grown, shrunk } = diffCounts(keep(current), keep(baseline))
for (const { file, now, allowed } of grown) {
console.error(`${file}: ${now} ${ratchet.unit}, baseline allows ${allowed}.`)
}
@@ -1,70 +0,0 @@
import path from 'node:path'
import { beforeAll, describe, expect, it } from 'vitest'
import { runOxlintPluginOnSources } from './oxlint-plugin-test-runner.mjs'
function lintSources(sources) {
return runOxlintPluginOnSources({
pluginName: 'sort-comparator-performance',
pluginPath: path.resolve('config/oxlint-plugins/sort-comparator-performance.mjs'),
rules: { 'sort-comparator-performance/no-repeated-collator': 'warn' },
sources
})
}
const sources = {
repeatedCollation: `
rows.sort((a, b) => a.name.localeCompare(b.name, locale, { sensitivity: 'base' }))
rows.toSorted(function (a, b) { return new Intl.Collator('sv').compare(a, b) })
rows['sort']((a, b) => Intl.Collator('en', { numeric: true }).compare(a, b))
rows.sort((a, b) => a['localeCompare'](b, undefined, options))
`,
acceptedComparisons: `
const collator = new Intl.Collator(locale, options)
rows.sort((a, b) => collator.compare(a.name, b.name) || a.id.localeCompare(b.id))
rows.toSorted(collator.compare)
const equal = a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0
rows.map(a => new Intl.Collator(a.locale))
rows.sort((a, b) => {
function deferred() { return new Intl.Collator(locale) }
return a - b
})
`
}
const diagnosticsBySource = new Map()
beforeAll(() => {
const fixtures = Object.values(sources)
lintSources(fixtures).forEach((diagnostics, index) => {
diagnosticsBySource.set(fixtures[index], diagnostics)
})
})
describe('sort comparator performance', () => {
it('reports repeated collation setup in inline sort and toSorted callbacks', () => {
const findings = diagnosticsBySource.get(sources.repeatedCollation)
expect(findings).toHaveLength(4)
expect(
findings.every(
(finding) => finding.code === 'sort-comparator-performance(no-repeated-collator)'
)
).toBe(true)
const reportedSources = findings.map(({ labels }) => {
const { offset, length } = labels[0].span
return Buffer.from(sources.repeatedCollation)
.subarray(offset, offset + length)
.toString('utf8')
})
expect(reportedSources.sort()).toEqual(
[
"a.name.localeCompare(b.name, locale, { sensitivity: 'base' })",
"new Intl.Collator('sv')",
"Intl.Collator('en', { numeric: true })",
"a['localeCompare'](b, undefined, options)"
].sort()
)
})
it('allows one collator per sort, bare comparisons, and unrelated callbacks', () => {
expect(diagnosticsBySource.get(sources.acceptedComparisons)).toEqual([])
})
})
+9
View File
@@ -0,0 +1,9 @@
# Run from the repository root: `pnpm lint:ast-grep` and `pnpm test:ast-grep`.
# Rule ids are per language, so each rule file holds a TypeScript and a TSX document.
ruleDirs:
- ast-grep/rules
testConfigs:
- testDir: ast-grep/rule-tests
# The TypeScript grammar parses plain JavaScript; TSX would misread `<T>(x) => x` generics.
languageGlobs:
typescript: ['*.js', '*.mjs', '*.cjs']
-1
View File
@@ -24,7 +24,6 @@ const contracts = [
'src/renderer/src/store/store-identity-churn-probe.test.ts',
'config/scripts/app-store-performance-plugin.test.mjs',
'config/scripts/quadratic-buffer-concat-plugin.test.mjs',
'config/scripts/sort-comparator-performance-plugin.test.mjs',
'src/main/claude-usage/scanner-incremental-append.test.ts',
'src/main/claude-usage/store-checkpoint-persistence.test.ts'
]
+1 -1
View File
@@ -1,6 +1,6 @@
# Native chat typography and color
Read before changing anything under `src/renderer/src/components/native-chat/`. Core rules: [`docs/STYLEGUIDE.md`](../STYLEGUIDE.md). `pnpm lint` enforces the type scale, softened-foreground, and focus-ring rules below (`config/oxlint-plugins/native-chat-design-tokens.mjs`).
Read before changing anything under `src/renderer/src/components/native-chat/`. Core rules: [`docs/STYLEGUIDE.md`](../STYLEGUIDE.md). `pnpm lint` enforces the type scale, softened-foreground, and focus-ring rules below (`config/ast-grep/rules/native-chat-*.yml`).
## Chat type scale
+9 -1
View File
@@ -16,7 +16,7 @@
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"test:perf:benchmarks": "vitest bench --run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
"lint": "oxlint && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:e2e-typecheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:workspace-record-read-ratchet && pnpm run check:host-list-render-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:test-file-runners && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"lint": "oxlint && pnpm run lint:js-plugins && pnpm run lint:ast-grep && pnpm run test:ast-grep && pnpm run audit:anti-slop && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:e2e-typecheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:focus-read-ratchet && pnpm run check:layout-writer-ratchets && pnpm run check:path-literal-ratchet && pnpm run check:legacy-relay-fence-ratchet && pnpm run check:unproven-pty-retirement-ratchet && pnpm run check:id-only-store-write-ratchet && pnpm run check:repo-host-read-ratchet && pnpm run check:workspace-record-read-ratchet && pnpm run check:host-list-render-ratchet && pnpm run check:flaky-test-pattern-ratchet && pnpm run check:readme-local-links && pnpm run check:node-runtime-pin && pnpm run check:test-file-runners && pnpm run check:mobile-shared-dependency-versions && pnpm run verify:rpc-params-catalog && pnpm run verify:acp-protocol && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalogs && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage && pnpm run check:process-host-imports && pnpm run check:main-sync-spawn-ratchet && pnpm run check:compat-boundary-imports",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
@@ -24,6 +24,10 @@
"audit:dead-code": "node config/scripts/run-audit-tool.mjs knip --config config/knip.json",
"check:changed": "node config/scripts/check-changed.mjs",
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
"lint:js-plugins": "oxlint --config config/oxlint-js-plugins.json src config tests mobile native .github",
"lint:ast-grep": "ast-grep scan --config config/sgconfig.yml --min-severity warning src config tests mobile",
"lint:ast-grep:flaky-tests": "ast-grep scan --config config/sgconfig.yml --filter \"^test-\" src config tests mobile",
"test:ast-grep": "ast-grep test --config config/sgconfig.yml --skip-snapshot-tests",
"check:dead-classes": "oxlint --config config/oxlint-dead-classes.json src/renderer",
"lint:design-system": "oxlint --config config/oxlint-design-system.json src/renderer",
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
@@ -44,6 +48,7 @@
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:ts-nocheck-ratchet": "node config/scripts/check-ts-nocheck-ratchet.mjs",
"check:host-list-render-ratchet": "node config/scripts/check-host-list-render-ratchet.mjs",
"check:flaky-test-pattern-ratchet": "node config/scripts/check-flaky-test-pattern-ratchet.mjs",
"check:path-literal-ratchet": "node config/scripts/check-path-literal-ratchet.mjs",
"check:legacy-relay-fence-ratchet": "node config/scripts/check-legacy-relay-fence-ratchet.mjs",
"check:unproven-pty-retirement-ratchet": "node config/scripts/check-unproven-pty-retirement-ratchet.mjs",
@@ -234,6 +239,7 @@
},
"devDependencies": {
"@actions/expressions": "0.3.61",
"@ast-grep/cli": "0.51.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@electron-toolkit/tsconfig": "^2.0.0",
@@ -364,6 +370,8 @@
"lint-staged": {
"{*.{ts,tsx,js,jsx,mjs,mts,cts},!(cloud)/**/*.{ts,tsx,js,jsx,mjs,mts,cts}}": [
"oxlint",
"oxlint --config config/oxlint-js-plugins.json",
"ast-grep scan --config config/sgconfig.yml --min-severity warning",
"oxlint --config config/oxlint-react-doctor.json",
"oxfmt --write --no-error-on-unmatched-pattern"
],
+90 -35
View File
@@ -279,6 +279,9 @@ importers:
'@actions/expressions':
specifier: 0.3.61
version: 0.3.61
'@ast-grep/cli':
specifier: 0.51.0
version: 0.51.0
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
@@ -725,6 +728,55 @@ packages:
zod:
optional: true
'@ast-grep/cli-darwin-arm64@0.51.0':
resolution: {integrity: sha512-LYh8du1Qd1xFMi15Lh6xnWgHBZGbdCVyQVHOsaqEbICXvd8oAdEIex8ywTt2PwODYAMt6TD7p94S2/kt5dJ1tQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@ast-grep/cli-darwin-x64@0.51.0':
resolution: {integrity: sha512-RF3FsO3XsGt+qLqpqbHiKKhly9/Pd08mVnvxx86Qli3DokvIFRAR8KlsTDnql4hsNgAbacp4vyDJsf/pBhh8iw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@ast-grep/cli-linux-arm64-gnu@0.51.0':
resolution: {integrity: sha512-aYT07uAVSA3nfoofdVXJuL44DGt1lIOj/GGSNy9mrIiD3TeBv5wxNNXHjMw96bkACdOp8pFMwiuCLY8pMtCT7Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@ast-grep/cli-linux-x64-gnu@0.51.0':
resolution: {integrity: sha512-ljsdvSrmnmQZaB/lrtLuOP3k3AYCb+TNpPCMi4OQuF1vRBFwMdsES0xrL9iUpJ8ioyIy1h5qTM1T4z5OeoGIvw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@ast-grep/cli-win32-arm64-msvc@0.51.0':
resolution: {integrity: sha512-G0eb9EmwWvsaJSZI929PCcJhBBeFiB7oAndml5weI1iDjDVn7f5u8jb4wEsovA9W5wy70MjYfH7/H59ogQr8Nw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@ast-grep/cli-win32-ia32-msvc@0.51.0':
resolution: {integrity: sha512-68oC9L73Nq7Wi34aEqcUSIhIYaaM4PpVrrlcbobn4cAKSlUEMIC/9aKsKo6pknQYwSNr9dyIFX92zXIoTRNbcg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@ast-grep/cli-win32-x64-msvc@0.51.0':
resolution: {integrity: sha512-lYsKBG0Wn/vk8Zbeo60l+Lms317mL1xpW6Dvv68k3F57vqlMHkWR2tuNcyWC3OVJXbas8dFMoe1R+6wkofIC+g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@ast-grep/cli@0.51.0':
resolution: {integrity: sha512-sIZEt4L4iQJRM2QDKQnbltQxddIsxzt9nmguw3/X7eP3cFwBopsZtFRvaEVht/3/6C1xe3Opn/pbsxSFVJDvpA==}
engines: {node: '>= 12.0.0'}
hasBin: true
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -4712,10 +4764,6 @@ packages:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
content-type@2.0.0:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
content-type@2.1.0:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
@@ -6886,10 +6934,6 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
qs@6.16.1:
resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==}
engines: {node: '>=0.6'}
@@ -7501,16 +7545,9 @@ packages:
resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==}
engines: {node: ^20.0.0 || >=22.0.0}
tldts-core@7.4.16:
resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==}
tldts-core@7.4.18:
resolution: {integrity: sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==}
tldts@7.4.16:
resolution: {integrity: sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==}
hasBin: true
tldts@7.4.18:
resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==}
hasBin: true
@@ -8076,6 +8113,39 @@ snapshots:
optionalDependencies:
zod: 4.6.7
'@ast-grep/cli-darwin-arm64@0.51.0':
optional: true
'@ast-grep/cli-darwin-x64@0.51.0':
optional: true
'@ast-grep/cli-linux-arm64-gnu@0.51.0':
optional: true
'@ast-grep/cli-linux-x64-gnu@0.51.0':
optional: true
'@ast-grep/cli-win32-arm64-msvc@0.51.0':
optional: true
'@ast-grep/cli-win32-ia32-msvc@0.51.0':
optional: true
'@ast-grep/cli-win32-x64-msvc@0.51.0':
optional: true
'@ast-grep/cli@0.51.0':
dependencies:
detect-libc: 2.1.2
optionalDependencies:
'@ast-grep/cli-darwin-arm64': 0.51.0
'@ast-grep/cli-darwin-x64': 0.51.0
'@ast-grep/cli-linux-arm64-gnu': 0.51.0
'@ast-grep/cli-linux-x64-gnu': 0.51.0
'@ast-grep/cli-win32-arm64-msvc': 0.51.0
'@ast-grep/cli-win32-ia32-msvc': 0.51.0
'@ast-grep/cli-win32-x64-msvc': 0.51.0
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -11416,8 +11486,6 @@ snapshots:
content-type@1.0.5: {}
content-type@2.0.0: {}
content-type@2.1.0: {}
convert-source-map@2.0.0: {}
@@ -12089,7 +12157,7 @@ snapshots:
accepts: 2.0.0
body-parser: 2.4.0(supports-color@7.2.0)
content-disposition: 2.0.1
content-type: 2.0.0
content-type: 2.1.0
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3(supports-color@7.2.0)
@@ -12106,7 +12174,7 @@ snapshots:
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.8
qs: 6.16.0
qs: 6.16.1
range-parser: 1.2.1
router: 2.2.0(supports-color@7.2.0)
send: 1.2.1(supports-color@7.2.0)
@@ -14098,11 +14166,6 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
qs@6.16.1:
dependencies:
es-define-property: 1.0.1
@@ -14179,7 +14242,7 @@ snapshots:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.2
iconv-lite: 0.7.3
unpipe: 1.0.0
react-colorful@5.7.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(@types/react@19.2.18)(react@19.2.8))(react@19.2.8):
@@ -14834,16 +14897,8 @@ snapshots:
tinypool@2.2.0: {}
tldts-core@7.4.16:
optional: true
tldts-core@7.4.18: {}
tldts@7.4.16:
dependencies:
tldts-core: 7.4.16
optional: true
tldts@7.4.18:
dependencies:
tldts-core: 7.4.18
@@ -14858,7 +14913,7 @@ snapshots:
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.16
tldts: 7.4.18
optional: true
trim-lines@3.0.1: {}
@@ -14909,7 +14964,7 @@ snapshots:
type-is@2.1.0:
dependencies:
content-type: 2.0.0
content-type: 2.1.0
media-typer: 1.1.0
mime-types: 3.0.2
+2
View File
@@ -59,6 +59,8 @@ supportedArchitectures:
- current
allowBuilds:
# Its postinstall only hardlinks the platform binary over the JS launcher.
'@ast-grep/cli': true
'@parcel/watcher': true
'@swc/core': false
'@vscode/windows-process-tree': false
@@ -127,7 +127,7 @@ describe('discovered skill ordering', () => {
it('sorts discovery sources like the per-call label comparator', () => {
const sources = Array.from({ length: 400 }, (_, index) => discoverySource(index))
const expected = [...sources].sort((a, b) =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Parity oracle.
// ast-grep-ignore: no-repeated-collator -- Parity oracle.
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
const NativeCollator = Intl.Collator
+1 -1
View File
@@ -179,7 +179,7 @@ it('reuses one source collator while preserving locale, lexical numbers, and sta
)
const roots = labels.map((label, index) => ({ ...homeRoot, id: String(index), label }))
const expected = [...roots].sort((a, b) =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })
)
const NativeCollator = Intl.Collator
+2 -2
View File
@@ -50,7 +50,7 @@ describe('getWarpThemeDirectories', () => {
)
readdirSyncMock.mockReturnValue(names.map(directoryEntry))
const expected = [...names].sort((a, b) =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
const NativeCollator = Intl.Collator
@@ -76,7 +76,7 @@ describe('getWarpThemeDirectories', () => {
const warpNames = ['.warp-zebra', '.warp-Ångström', '.warp-éclair']
readdirSyncMock.mockReturnValue([...noise, ...warpNames.map(directoryEntry)])
const expected = [...warpNames].sort((a, b) =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
const NativeCollator = Intl.Collator
@@ -13,9 +13,9 @@ describe('manual theme ordering', () => {
)
const expected = [...paths].sort(
(a, b) =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
path.basename(a).localeCompare(path.basename(b), undefined, { sensitivity: 'base' }) ||
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
const NativeCollator = Intl.Collator
@@ -12,7 +12,7 @@ it('reuses one collator per directory while preserving capped scan order', async
await mkdir(path.join(directory, 'nested'))
await writeFile(path.join(directory, 'nested', 'theme.yaml'), '')
const expected = (await readdir(directory))
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
.map((name) => (name === 'nested' ? path.join(name, 'theme.yaml') : name))
const NativeCollator = Intl.Collator
@@ -148,7 +148,7 @@ describe('skillDeleteResultLines', () => {
it('sorts deletion roots once with unchanged case, accent, and number ordering', () => {
const labels = ['éclair', 'Eclair', 'item2', 'item10', 'Ångström', 'zebra', 'İstanbul']
const expected = [...labels].sort((a, b) =>
// oxlint-disable-next-line sort-comparator-performance/no-repeated-collator -- Preserve the old comparator as the parity oracle.
// ast-grep-ignore: no-repeated-collator -- Preserve the old comparator as the parity oracle.
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
const NativeCollator = Intl.Collator