mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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$/, '')
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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} }}`],
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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']
|
||||
@@ -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,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
@@ -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"
|
||||
],
|
||||
|
||||
Generated
+90
-35
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user