merge: integrate origin/main (406 commits) into live branch
Merge upstream's paired-room stabilization, dedicated message-runtime-loop, outbound delivery refactor (ipc-outbound-delivery / deliverFormattedCanonicalMessage), discord module split, dashboard rework, and migrations 015-018 into our branch. Conflict policy: prefer upstream for the heavily-refactored paired-room / message-runtime / db cluster (it supersedes our earlier loop band-aids), keep only orthogonal unique fixes: - codex bundled-JS launcher fix (codex-warmup.ts) - slot-aligned Claude+Codex usage primer (usage-primer.ts) re-wired into index.ts - MemoryHigh=3G cgroup bound, discord perms diagnostic, prompt docs - progress null-race + silent-failure publish guards (message-turn-controller.ts) Dropped (superseded by upstream or unwired): reviewer STEP_DONE/TASK_DONE loop band-aids, router markdown-escape override, register tribunal-default+git-init, restart-context rewindToSeq (unwired). Verified: typecheck clean, build clean, vitest shows zero new regressions vs the origin/main baseline (only pre-existing env-config failures remain).
This commit is contained in:
132
scripts/check-code-quality.ts
Normal file
132
scripts/check-code-quality.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { relative, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
analyzeCodeQuality,
|
||||
evaluateCodeQuality,
|
||||
type CodeQualityBudget,
|
||||
type CodeQualityFinding,
|
||||
type CodeQualityMetrics,
|
||||
} from './quality/code-quality.js';
|
||||
|
||||
interface QualityBudgetConfig {
|
||||
defaults: CodeQualityBudget;
|
||||
overrides?: Record<string, CodeQualityBudget>;
|
||||
testDefaults?: CodeQualityBudget;
|
||||
}
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const CONFIG_PATH = resolve(ROOT, 'quality/code-quality-budgets.json');
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.css']);
|
||||
const IGNORED_PARTS = new Set([
|
||||
'dist',
|
||||
'node_modules',
|
||||
'coverage',
|
||||
'store',
|
||||
'data',
|
||||
]);
|
||||
|
||||
const config = JSON.parse(
|
||||
readFileSync(CONFIG_PATH, 'utf8'),
|
||||
) as QualityBudgetConfig;
|
||||
const reportOnly = process.argv.includes('--report');
|
||||
const metrics = collectMetrics();
|
||||
const findings = metrics.flatMap((item) =>
|
||||
evaluateCodeQuality(item, budgetForFile(item.filePath, config)),
|
||||
);
|
||||
|
||||
if (reportOnly) {
|
||||
for (const item of metrics
|
||||
.filter((metric) => config.overrides?.[metric.filePath])
|
||||
.sort((a, b) => b.lineCount - a.lineCount)) {
|
||||
console.log(
|
||||
[
|
||||
item.filePath,
|
||||
`lines=${item.lineCount}`,
|
||||
`fnLines=${item.maxFunctionLines}`,
|
||||
`complexity=${item.maxComplexity}`,
|
||||
`nesting=${item.maxNesting}`,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (findings.length > 0) {
|
||||
console.error(formatFindings(findings));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`code quality OK (${metrics.length} files, ${Object.keys(config.overrides ?? {}).length} ratcheted hotspots)`,
|
||||
);
|
||||
|
||||
function collectMetrics(): CodeQualityMetrics[] {
|
||||
return trackedFiles()
|
||||
.filter((filePath) => shouldAnalyze(filePath))
|
||||
.map((filePath) =>
|
||||
analyzeCodeQuality(
|
||||
filePath,
|
||||
readFileSync(resolve(ROOT, filePath), 'utf8'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function trackedFiles(): string[] {
|
||||
return execFileSync('git', ['ls-files'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function shouldAnalyze(filePath: string): boolean {
|
||||
if (filePath.endsWith('.d.ts')) return false;
|
||||
const parts = filePath.split('/');
|
||||
if (parts.some((part) => IGNORED_PARTS.has(part))) return false;
|
||||
return SOURCE_EXTENSIONS.has(extensionFor(filePath));
|
||||
}
|
||||
|
||||
function extensionFor(filePath: string): string {
|
||||
const match = /\.[^.]+$/.exec(filePath);
|
||||
return match?.[0] ?? '';
|
||||
}
|
||||
|
||||
function budgetForFile(
|
||||
filePath: string,
|
||||
budgetConfig: QualityBudgetConfig,
|
||||
): CodeQualityBudget {
|
||||
const base = isTestFile(filePath)
|
||||
? (budgetConfig.testDefaults ?? budgetConfig.defaults)
|
||||
: budgetConfig.defaults;
|
||||
return {
|
||||
...base,
|
||||
...budgetConfig.overrides?.[filePath],
|
||||
};
|
||||
}
|
||||
|
||||
function isTestFile(filePath: string): boolean {
|
||||
return (
|
||||
filePath.includes('/test/') ||
|
||||
filePath.endsWith('.test.ts') ||
|
||||
filePath.endsWith('.test.tsx') ||
|
||||
filePath.endsWith('.spec.ts') ||
|
||||
filePath.endsWith('.spec.tsx')
|
||||
);
|
||||
}
|
||||
|
||||
function formatFindings(findings: CodeQualityFinding[]): string {
|
||||
const lines = findings.map((finding) => {
|
||||
const label = finding.owner
|
||||
? `${finding.filePath} (${finding.owner})`
|
||||
: finding.filePath;
|
||||
return `- ${label}: ${finding.metric} ${finding.actual} > ${finding.budget}`;
|
||||
});
|
||||
return [
|
||||
'code quality budget failed:',
|
||||
...lines,
|
||||
'',
|
||||
`Update the code to reduce the metric, or adjust ${relative(ROOT, CONFIG_PATH)} only when intentionally ratcheting a new baseline.`,
|
||||
].join('\n');
|
||||
}
|
||||
138
scripts/dash-inspect.ts
Normal file
138
scripts/dash-inspect.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Dashboard inspector via Playwright.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/dash-inspect.ts shot # full-page screenshot
|
||||
* bun scripts/dash-inspect.ts shot rooms # screenshot of #/rooms
|
||||
* bun scripts/dash-inspect.ts measure '<sel>' # bounding box + computed style
|
||||
* bun scripts/dash-inspect.ts click '<sel>' # click and screenshot after
|
||||
* bun scripts/dash-inspect.ts eval '<expr>' # evaluate JS in page
|
||||
* bun scripts/dash-inspect.ts dom '<sel>' # outerHTML of selector
|
||||
* bun scripts/dash-inspect.ts route <hash> # navigate to #/<hash>
|
||||
*
|
||||
* Output: writes to /tmp/dash-shot.png + prints data to stdout.
|
||||
*/
|
||||
|
||||
import { chromium, type BrowserContext, type Page } from 'playwright';
|
||||
|
||||
const BASE = process.env.DASH_URL ?? 'http://100.101.210.95:5174';
|
||||
const SHOT = '/tmp/dash-shot.png';
|
||||
|
||||
async function withPage<T>(
|
||||
fn: (page: Page, ctx: BrowserContext) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
deviceScaleFactor: 1,
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await fn(page, ctx);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function gotoRoute(page: Page, route?: string) {
|
||||
const url = route ? `${BASE}/#/${route.replace(/^#?\/?/, '')}` : BASE;
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 20000 });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
async function shot(page: Page) {
|
||||
await page.screenshot({ path: SHOT, fullPage: false });
|
||||
console.log(`screenshot saved: ${SHOT}`);
|
||||
}
|
||||
|
||||
async function measure(page: Page, sel: string) {
|
||||
const all = await page.$$(sel);
|
||||
if (all.length === 0) {
|
||||
console.log(`NOT FOUND: ${sel}`);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < Math.min(all.length, 6); i++) {
|
||||
const el = all[i];
|
||||
const box = await el.boundingBox();
|
||||
const style = await el.evaluate((node: object) => {
|
||||
const cs = (
|
||||
globalThis as unknown as {
|
||||
getComputedStyle: (e: object) => Record<string, string>;
|
||||
}
|
||||
).getComputedStyle(node);
|
||||
return {
|
||||
display: cs.display,
|
||||
position: cs.position,
|
||||
margin: cs.margin,
|
||||
padding: cs.padding,
|
||||
width: cs.width,
|
||||
height: cs.height,
|
||||
boxSizing: cs.boxSizing,
|
||||
gridTemplateColumns: cs.gridTemplateColumns,
|
||||
gridTemplateRows: cs.gridTemplateRows,
|
||||
};
|
||||
});
|
||||
console.log(
|
||||
`[${i}] ${sel}\n rect: ${box ? `x=${Math.round(box.x)} y=${Math.round(box.y)} w=${Math.round(box.width)} h=${Math.round(box.height)}` : 'none'}\n ${JSON.stringify(style)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function evalExpr(page: Page, expr: string) {
|
||||
const result = await page.evaluate(expr);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
async function dom(page: Page, sel: string) {
|
||||
const el = await page.$(sel);
|
||||
if (!el) {
|
||||
console.log(`NOT FOUND: ${sel}`);
|
||||
return;
|
||||
}
|
||||
const html = await el.evaluate(
|
||||
(node: object) => (node as unknown as { outerHTML: string }).outerHTML,
|
||||
);
|
||||
console.log(
|
||||
html.length > 4000 ? `${html.slice(0, 4000)}\n…(truncated)` : html,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickAndShot(page: Page, sel: string) {
|
||||
await page.click(sel);
|
||||
await page.waitForTimeout(500);
|
||||
await shot(page);
|
||||
}
|
||||
|
||||
const [, , cmd = 'shot', ...rest] = process.argv;
|
||||
|
||||
await withPage(async (page) => {
|
||||
// Default route from rest[0] when shot/measure/etc; otherwise look for explicit `route` arg.
|
||||
const routeArg =
|
||||
cmd === 'shot' && rest[0] && !rest[0].startsWith('.') ? rest[0] : 'rooms';
|
||||
await gotoRoute(page, routeArg);
|
||||
|
||||
switch (cmd) {
|
||||
case 'shot':
|
||||
await shot(page);
|
||||
break;
|
||||
case 'measure':
|
||||
await measure(page, rest[0]);
|
||||
break;
|
||||
case 'click':
|
||||
await clickAndShot(page, rest[0]);
|
||||
break;
|
||||
case 'eval':
|
||||
await evalExpr(page, rest[0]);
|
||||
break;
|
||||
case 'dom':
|
||||
await dom(page, rest[0]);
|
||||
break;
|
||||
case 'route':
|
||||
await shot(page);
|
||||
break;
|
||||
default:
|
||||
console.error(`unknown command: ${cmd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
1033
scripts/dashboard-ux.ts
Normal file
1033
scripts/dashboard-ux.ts
Normal file
File diff suppressed because it is too large
Load Diff
64
scripts/quality/code-quality.test.ts
Normal file
64
scripts/quality/code-quality.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { analyzeCodeQuality, evaluateCodeQuality } from './code-quality.js';
|
||||
|
||||
describe('code quality evaluator', () => {
|
||||
it('measures line count and function metrics from TypeScript source', () => {
|
||||
const metrics = analyzeCodeQuality(
|
||||
'sample.ts',
|
||||
[
|
||||
'export function choose(value: number) {',
|
||||
' if (value > 10) {',
|
||||
' return value;',
|
||||
' }',
|
||||
' return value > 0 ? value : 0;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(metrics.lineCount).toBe(6);
|
||||
expect(metrics.nonEmptyLineCount).toBe(6);
|
||||
expect(metrics.maxFunctionLines).toBe(6);
|
||||
expect(metrics.maxComplexity).toBeGreaterThanOrEqual(3);
|
||||
expect(metrics.maxNesting).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not let nested functions inflate the parent function budget', () => {
|
||||
const metrics = analyzeCodeQuality(
|
||||
'nested.ts',
|
||||
[
|
||||
'export function outer() {',
|
||||
' const inner = () => {',
|
||||
' if (true) return 1;',
|
||||
' return 0;',
|
||||
' };',
|
||||
' return inner();',
|
||||
'}',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
expect(metrics.maxComplexity).toBe(2);
|
||||
});
|
||||
|
||||
it('returns budget findings for metrics over the configured limit', () => {
|
||||
const metrics = analyzeCodeQuality(
|
||||
'oversized.ts',
|
||||
['export function f() {', ' return 1;', '}'].join('\n'),
|
||||
);
|
||||
|
||||
expect(
|
||||
evaluateCodeQuality(metrics, {
|
||||
maxComplexity: 1,
|
||||
maxFunctionLines: 2,
|
||||
maxLines: 2,
|
||||
maxNesting: 0,
|
||||
owner: 'test',
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ metric: 'lineCount' }),
|
||||
expect.objectContaining({ metric: 'maxFunctionLines' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
202
scripts/quality/code-quality.ts
Normal file
202
scripts/quality/code-quality.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import ts from 'typescript';
|
||||
|
||||
export interface CodeQualityBudget {
|
||||
maxComplexity?: number;
|
||||
maxFunctionLines?: number;
|
||||
maxLines?: number;
|
||||
maxNesting?: number;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
export interface CodeQualityMetrics {
|
||||
filePath: string;
|
||||
lineCount: number;
|
||||
maxComplexity: number;
|
||||
maxFunctionLines: number;
|
||||
maxNesting: number;
|
||||
nonEmptyLineCount: number;
|
||||
}
|
||||
|
||||
export interface CodeQualityFinding {
|
||||
actual: number;
|
||||
budget: number;
|
||||
filePath: string;
|
||||
metric: keyof Pick<
|
||||
CodeQualityMetrics,
|
||||
'lineCount' | 'maxComplexity' | 'maxFunctionLines' | 'maxNesting'
|
||||
>;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
interface FunctionMetrics {
|
||||
complexity: number;
|
||||
lines: number;
|
||||
nesting: number;
|
||||
}
|
||||
|
||||
export function analyzeCodeQuality(
|
||||
filePath: string,
|
||||
sourceText: string,
|
||||
): CodeQualityMetrics {
|
||||
const lines = sourceText.split(/\r?\n/);
|
||||
const textMetrics = {
|
||||
filePath,
|
||||
lineCount: lines.length,
|
||||
nonEmptyLineCount: lines.filter((line) => line.trim().length > 0).length,
|
||||
};
|
||||
if (!/\.(?:c|m)?[jt]sx?$/.test(filePath)) {
|
||||
return {
|
||||
...textMetrics,
|
||||
maxComplexity: 0,
|
||||
maxFunctionLines: 0,
|
||||
maxNesting: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
scriptKindForPath(filePath),
|
||||
);
|
||||
const functions: FunctionMetrics[] = [];
|
||||
|
||||
const visit = (node: ts.Node) => {
|
||||
if (isFunctionLike(node)) {
|
||||
functions.push(analyzeFunction(sourceFile, node));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
ts.forEachChild(sourceFile, visit);
|
||||
|
||||
return {
|
||||
...textMetrics,
|
||||
maxComplexity: maxOf(functions.map((fn) => fn.complexity)),
|
||||
maxFunctionLines: maxOf(functions.map((fn) => fn.lines)),
|
||||
maxNesting: maxOf(functions.map((fn) => fn.nesting)),
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateCodeQuality(
|
||||
metrics: CodeQualityMetrics,
|
||||
budget: CodeQualityBudget,
|
||||
): CodeQualityFinding[] {
|
||||
const findings: CodeQualityFinding[] = [];
|
||||
addFinding(findings, metrics, budget, 'lineCount', budget.maxLines);
|
||||
addFinding(
|
||||
findings,
|
||||
metrics,
|
||||
budget,
|
||||
'maxFunctionLines',
|
||||
budget.maxFunctionLines,
|
||||
);
|
||||
addFinding(findings, metrics, budget, 'maxComplexity', budget.maxComplexity);
|
||||
addFinding(findings, metrics, budget, 'maxNesting', budget.maxNesting);
|
||||
return findings;
|
||||
}
|
||||
|
||||
function addFinding(
|
||||
findings: CodeQualityFinding[],
|
||||
metrics: CodeQualityMetrics,
|
||||
budget: CodeQualityBudget,
|
||||
metric: CodeQualityFinding['metric'],
|
||||
limit: number | undefined,
|
||||
) {
|
||||
if (limit === undefined) return;
|
||||
const actual = metrics[metric];
|
||||
if (actual <= limit) return;
|
||||
findings.push({
|
||||
actual,
|
||||
budget: limit,
|
||||
filePath: metrics.filePath,
|
||||
metric,
|
||||
owner: budget.owner,
|
||||
});
|
||||
}
|
||||
|
||||
function analyzeFunction(
|
||||
sourceFile: ts.SourceFile,
|
||||
node: ts.SignatureDeclaration,
|
||||
): FunctionMetrics {
|
||||
const startLine = sourceFile.getLineAndCharacterOfPosition(
|
||||
node.getStart(sourceFile),
|
||||
).line;
|
||||
const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line;
|
||||
let complexity = 1;
|
||||
let maxNesting = 0;
|
||||
|
||||
const walk = (current: ts.Node, nesting: number) => {
|
||||
if (current !== node && isFunctionLike(current)) return;
|
||||
|
||||
if (isDecisionNode(current)) {
|
||||
complexity += 1;
|
||||
const nextNesting = nesting + 1;
|
||||
maxNesting = Math.max(maxNesting, nextNesting);
|
||||
ts.forEachChild(current, (child) => walk(child, nextNesting));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isBinaryExpression(current) &&
|
||||
isLogicalOperator(current.operatorToken.kind)
|
||||
) {
|
||||
complexity += 1;
|
||||
}
|
||||
|
||||
ts.forEachChild(current, (child) => walk(child, nesting));
|
||||
};
|
||||
|
||||
walk(node, 0);
|
||||
|
||||
return {
|
||||
complexity,
|
||||
lines: endLine - startLine + 1,
|
||||
nesting: maxNesting,
|
||||
};
|
||||
}
|
||||
|
||||
function isDecisionNode(node: ts.Node): boolean {
|
||||
return (
|
||||
ts.isIfStatement(node) ||
|
||||
ts.isForStatement(node) ||
|
||||
ts.isForInStatement(node) ||
|
||||
ts.isForOfStatement(node) ||
|
||||
ts.isWhileStatement(node) ||
|
||||
ts.isDoStatement(node) ||
|
||||
ts.isCaseClause(node) ||
|
||||
ts.isCatchClause(node) ||
|
||||
ts.isConditionalExpression(node)
|
||||
);
|
||||
}
|
||||
|
||||
function isFunctionLike(node: ts.Node): node is ts.SignatureDeclaration {
|
||||
return (
|
||||
ts.isFunctionDeclaration(node) ||
|
||||
ts.isFunctionExpression(node) ||
|
||||
ts.isArrowFunction(node) ||
|
||||
ts.isMethodDeclaration(node) ||
|
||||
ts.isConstructorDeclaration(node) ||
|
||||
ts.isGetAccessorDeclaration(node) ||
|
||||
ts.isSetAccessorDeclaration(node)
|
||||
);
|
||||
}
|
||||
|
||||
function isLogicalOperator(kind: ts.SyntaxKind): boolean {
|
||||
return (
|
||||
kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
||||
kind === ts.SyntaxKind.BarBarToken ||
|
||||
kind === ts.SyntaxKind.QuestionQuestionToken
|
||||
);
|
||||
}
|
||||
|
||||
function maxOf(values: number[]): number {
|
||||
return values.length === 0 ? 0 : Math.max(...values);
|
||||
}
|
||||
|
||||
function scriptKindForPath(filePath: string): ts.ScriptKind {
|
||||
if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX;
|
||||
if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX;
|
||||
if (filePath.endsWith('.js')) return ts.ScriptKind.JS;
|
||||
return ts.ScriptKind.TS;
|
||||
}
|
||||
Reference in New Issue
Block a user