chore: add architecture lint ratchets and perf regression thresholds
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

ESLint allowlists (only to shrink) for DatabaseSync, synchronous fs in src/main and direct window.goodbuddy subscriptions in components, plus a max-lines ratchet for App.tsx; docs demos and vendored scripts are no longer linted. perf:check (or GB_PERF_CHECK=1 perf:app) fails a non-throttled benchmark that exceeds build/perf-thresholds.json. Records the results in the PERF plan.
This commit is contained in:
mesalogo
2026-10-02 23:11:11 +08:00
parent d859cbfdeb
commit 53b9ecbcae
7 changed files with 485 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
// PERF-17 regression gate for the PERF-11 full-App benchmark report.
// Usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]
// Exit codes: 0 all limits met, 1 a limit, scenario or status failed,
// 2 the report cannot be judged (throttled run, unreadable input).
const { existsSync, readFileSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
const defaultThresholdsPath = join(__dirname, 'perf-thresholds.json')
function metricValue(scenario, path) {
let value = scenario
for (const key of path.split('.')) {
if (value === null || value === undefined) return undefined
value = value[key]
}
return value
}
// Pure: judges a parsed report against parsed thresholds.
// Returns { ok, refused, reason?, rows: [{ scenario, metric, value, limit, pass, note? }] }.
function evaluate(report, thresholds) {
const rows = []
const throttle = Number(report?.environment?.rendererCpuThrottle ?? 1)
const maxThrottle = Number(thresholds.maxRendererCpuThrottle ?? 1)
if (!(throttle <= maxThrottle)) {
return { ok: false, refused: true, reason: `renderer CPU throttle ${throttle}x exceeds ${maxThrottle}x; thresholds apply to non-throttled runs only`, rows }
}
const requiredStatus = thresholds.requiredStatus ?? 'passed'
rows.push({ scenario: '(report)', metric: 'status', value: report?.status ?? null, limit: `= ${requiredStatus}`, pass: report?.status === requiredStatus })
const scenarios = Array.isArray(report?.scenarios) ? report.scenarios : []
for (const [id, limits] of Object.entries(thresholds.scenarios ?? {})) {
const scenario = scenarios.find(entry => entry?.id === id)
if (!scenario) {
const optional = limits.optional === true
rows.push({ scenario: id, metric: '(present)', value: 'missing', limit: optional ? 'optional' : 'required', pass: optional })
continue
}
for (const [metric, bound] of Object.entries(limits)) {
if (metric === 'optional') continue
const raw = metricValue(scenario, metric)
const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : null
let pass = value !== null
const limitParts = []
if (typeof bound.max === 'number') { limitParts.push(`<= ${bound.max}`); pass = pass && value <= bound.max }
if (typeof bound.min === 'number') { limitParts.push(`>= ${bound.min}`); pass = pass && value >= bound.min }
rows.push({ scenario: id, metric, value, limit: limitParts.join(', '), pass, ...(value === null ? { note: 'metric missing' } : {}) })
}
}
return { ok: rows.every(row => row.pass), refused: false, rows }
}
function resolveReportPath(input) {
const path = resolve(input)
return existsSync(path) && statSync(path).isDirectory() ? join(path, 'result.json') : path
}
function formatValue(value) {
if (typeof value !== 'number') return String(value)
return Number.isInteger(value) ? String(value) : value.toFixed(value < 1 ? 3 : 1)
}
function run(argv) {
const [input, thresholdsArg] = argv
if (!input) {
console.error('usage: node build/check-perf-thresholds.cjs <result.json | report directory> [thresholds.json]')
return 2
}
const reportPath = resolveReportPath(input)
const thresholdsPath = resolve(thresholdsArg || defaultThresholdsPath)
let report
let thresholds
try {
report = JSON.parse(readFileSync(reportPath, 'utf8'))
thresholds = JSON.parse(readFileSync(thresholdsPath, 'utf8'))
} catch (error) {
console.error(`cannot read report or thresholds: ${error instanceof Error ? error.message : error}`)
return 2
}
const result = evaluate(report, thresholds)
console.log(`\nperf thresholds: ${reportPath}`)
if (result.refused) {
console.error(`refused: ${result.reason}`)
return 2
}
console.table(result.rows.map(row => ({
scenario: row.scenario,
metric: row.metric,
value: formatValue(row.value),
limit: row.limit,
pass: row.pass ? 'ok' : `FAIL${row.note ? ` (${row.note})` : ''}`
})))
const failed = result.rows.filter(row => !row.pass).length
console.log(failed ? `${failed} of ${result.rows.length} checks failed` : `all ${result.rows.length} checks passed`)
return result.ok ? 0 : 1
}
module.exports = { evaluate, run, defaultThresholdsPath }
if (require.main === module) process.exitCode = run(process.argv.slice(2))
+89
View File
@@ -0,0 +1,89 @@
{
"note": "PERF-17 regression ceilings for the PERF-11 full-App benchmark (npm run perf:app). They apply ONLY to non-throttled runs (environment.rendererCpuThrottle = 1, the default) with the default GB_PERF_* scale, on the reference machine of the 2026-10-02 baseline (Intel Core Ultra X7 358H x16, 32 GB, DPR 1, 120 Hz). Limits are about 2-3x the 2026-10-02 values so that run-to-run noise does not fail; they catch regressions of the size seen before PERF-12 (e.g. ~130 long tasks / 9 s while streaming). Tighten them as PERF-12..PERF-14 land; do not loosen them to make a regression pass. Every listed scenario must be present unless marked optional, and report.status must equal requiredStatus.",
"requiredStatus": "passed",
"maxRendererCpuThrottle": 1,
"scenarios": {
"idle": {
"renderer.longTasks.count": { "max": 2 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"typing-small": {
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 300 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"latency.p95": { "max": 25 },
"latency.max": { "max": 100 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"idle-seeded": {
"renderer.longTasks.count": { "max": 2 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"typing-seeded": {
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 300 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"latency.p95": { "max": 25 },
"latency.max": { "max": 100 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"switch-conversations": {
"renderer.longTasks.count": { "max": 5 },
"renderer.longTasks.totalMs": { "max": 500 },
"renderer.frames.jankRatio": { "max": 0.15 },
"latency.p95": { "max": 350 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"switch-conversations-warm": {
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 300 },
"renderer.frames.jankRatio": { "max": 0.15 },
"latency.p95": { "max": 200 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"switch-conversations-hot": {
"optional": true,
"renderer.longTasks.count": { "max": 3 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.jankRatio": { "max": 0.1 },
"latency.p95": { "max": 100 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"open-long-conversation": {
"renderer.longTasks.count": { "max": 4 },
"renderer.longTasks.totalMs": { "max": 400 },
"latency.p95": { "max": 400 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"scroll-long-conversation": {
"renderer.longTasks.count": { "max": 2 },
"renderer.longTasks.totalMs": { "max": 200 },
"renderer.frames.p95": { "max": 20 },
"renderer.frames.jankRatio": { "max": 0.05 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"stream-seeded": {
"renderer.longTasks.count": { "max": 10 },
"renderer.longTasks.totalMs": { "max": 1500 },
"renderer.frames.p95": { "max": 34 },
"renderer.frames.jankRatio": { "max": 0.15 },
"main.eventLoopDelay.p99": { "max": 100 }
},
"stream-and-type": {
"renderer.longTasks.count": { "max": 10 },
"renderer.longTasks.totalMs": { "max": 1500 },
"renderer.frames.p95": { "max": 34 },
"renderer.frames.jankRatio": { "max": 0.2 },
"latency.p95": { "max": 50 },
"main.eventLoopDelay.p99": { "max": 100 }
}
}
}
+3
View File
@@ -208,6 +208,9 @@ async function main() {
console.log(`\nreport: ${resultPath}`)
if (!keepProfile) rmSync(join(runDirectory, 'profile'), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
process.exitCode = String(report.status).startsWith('passed') ? 0 : 1
// PERF-17: GB_PERF_CHECK=1 gates the run on build/perf-thresholds.json
// (exit 1 on a regression, 2 when the report cannot be judged, e.g. throttled).
if (process.env.GB_PERF_CHECK === '1') process.exitCode = require('./check-perf-thresholds.cjs').run([resultPath])
}
main().catch(error => {