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
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:
@@ -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))
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user