linear-grab-bridge 0.24.1 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/linear-grab-bridge.mjs +697 -6
- package/package.json +11 -3
- package/slop-scan.global.js +3 -0
package/linear-grab-bridge.mjs
CHANGED
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
* Binds 127.0.0.1 only — never exposed to the network.
|
|
14
14
|
*/
|
|
15
15
|
import { createServer } from 'node:http';
|
|
16
|
-
import { spawn, execFile } from 'node:child_process';
|
|
16
|
+
import { spawn, execFile, execFileSync } from 'node:child_process';
|
|
17
17
|
import { randomUUID } from 'node:crypto';
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
|
-
import {
|
|
20
|
-
|
|
19
|
+
import {
|
|
20
|
+
appendFileSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
statSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
import { homedir, platform, tmpdir } from 'node:os';
|
|
21
30
|
import { join } from 'node:path';
|
|
22
31
|
|
|
23
32
|
const argv = process.argv.slice(2);
|
|
@@ -28,7 +37,16 @@ const flag = (name, fallback) => {
|
|
|
28
37
|
const PORT = Number(flag('--port', '4577'));
|
|
29
38
|
const DIR = flag('--dir', process.cwd());
|
|
30
39
|
const CLAUDE_BIN = flag('--claude', 'claude');
|
|
31
|
-
const VERSION = '0.
|
|
40
|
+
const VERSION = '0.25.1';
|
|
41
|
+
|
|
42
|
+
// ---- audit subcommand dispatch ---------------------------------------------
|
|
43
|
+
// `npx linear-grab-bridge audit` is a headless design gate — it sweeps a
|
|
44
|
+
// running dev app route-by-route in Chromium (driven over raw CDP), runs the
|
|
45
|
+
// slop-scan design-contract scan, writes a report, and exits nonzero when new
|
|
46
|
+
// violations exceed the baseline. It must NEVER start the HTTP server below.
|
|
47
|
+
// Guarded here, dispatched at the very bottom (after every const/fn is
|
|
48
|
+
// initialized) so audit mode short-circuits the entire server module body.
|
|
49
|
+
const AUDIT_MODE = argv[0] === 'audit';
|
|
32
50
|
|
|
33
51
|
/** Best-effort command runner (git/gh introspection). Never throws. */
|
|
34
52
|
function run(cmd, args, cwd = DIR) {
|
|
@@ -456,7 +474,7 @@ function sendMessage(task, text) {
|
|
|
456
474
|
|
|
457
475
|
// ---- HTTP ------------------------------------------------------------------
|
|
458
476
|
|
|
459
|
-
createServer(async (req, res) => {
|
|
477
|
+
const server = createServer(async (req, res) => {
|
|
460
478
|
try {
|
|
461
479
|
if (req.method === 'OPTIONS') {
|
|
462
480
|
res.writeHead(204, CORS);
|
|
@@ -915,7 +933,9 @@ createServer(async (req, res) => {
|
|
|
915
933
|
} catch (err) {
|
|
916
934
|
json(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
917
935
|
}
|
|
918
|
-
})
|
|
936
|
+
});
|
|
937
|
+
if (!AUDIT_MODE)
|
|
938
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
919
939
|
const B = '\x1b[1m';
|
|
920
940
|
const D = '\x1b[2m';
|
|
921
941
|
const C = '\x1b[36m';
|
|
@@ -942,3 +962,674 @@ createServer(async (req, res) => {
|
|
|
942
962
|
console.log(`${D}──────────────────────────────────────────────────${R}`);
|
|
943
963
|
loadHistory();
|
|
944
964
|
});
|
|
965
|
+
|
|
966
|
+
// ============================================================================
|
|
967
|
+
// audit — headless, CI-able design gate (raw CDP, zero deps)
|
|
968
|
+
// ============================================================================
|
|
969
|
+
|
|
970
|
+
// Colored console helpers, matching the bridge banner style.
|
|
971
|
+
const A = {
|
|
972
|
+
B: '\x1b[1m',
|
|
973
|
+
D: '\x1b[2m',
|
|
974
|
+
C: '\x1b[36m',
|
|
975
|
+
G: '\x1b[32m',
|
|
976
|
+
Y: '\x1b[33m',
|
|
977
|
+
Rd: '\x1b[31m',
|
|
978
|
+
R: '\x1b[0m',
|
|
979
|
+
};
|
|
980
|
+
const alog = (s) => console.log(s);
|
|
981
|
+
const die = (msg) => {
|
|
982
|
+
console.error(`${A.Rd}audit: ${msg}${A.R}`);
|
|
983
|
+
process.exit(2);
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
/** Locate a Chromium-family browser. --chrome wins; else probe known paths. */
|
|
987
|
+
function findBrowser() {
|
|
988
|
+
const explicit = flag('--chrome', null);
|
|
989
|
+
if (explicit) {
|
|
990
|
+
if (!existsSync(explicit)) die(`--chrome path does not exist: ${explicit}`);
|
|
991
|
+
return explicit;
|
|
992
|
+
}
|
|
993
|
+
const probed = [];
|
|
994
|
+
const os = platform();
|
|
995
|
+
if (os === 'darwin') {
|
|
996
|
+
const apps = [
|
|
997
|
+
'Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
998
|
+
'Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
|
999
|
+
'Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
1000
|
+
'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
1001
|
+
'Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
1002
|
+
'Chromium.app/Contents/MacOS/Chromium',
|
|
1003
|
+
];
|
|
1004
|
+
for (const app of apps) {
|
|
1005
|
+
for (const root of ['/Applications', join(homedir(), 'Applications')]) {
|
|
1006
|
+
const p = join(root, app);
|
|
1007
|
+
probed.push(p);
|
|
1008
|
+
if (existsSync(p)) return p;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
} else if (os === 'linux') {
|
|
1012
|
+
for (const name of ['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge']) {
|
|
1013
|
+
probed.push(name);
|
|
1014
|
+
const out = whichSync(name);
|
|
1015
|
+
if (out) return out;
|
|
1016
|
+
}
|
|
1017
|
+
} else if (os === 'win32') {
|
|
1018
|
+
const pf = process.env['PROGRAMFILES'] ?? 'C:\\Program Files';
|
|
1019
|
+
const pf86 = process.env['PROGRAMFILES(X86)'] ?? 'C:\\Program Files (x86)';
|
|
1020
|
+
const cands = [
|
|
1021
|
+
join(pf, 'Google/Chrome/Application/chrome.exe'),
|
|
1022
|
+
join(pf86, 'Google/Chrome/Application/chrome.exe'),
|
|
1023
|
+
join(pf, 'Microsoft/Edge/Application/msedge.exe'),
|
|
1024
|
+
join(pf86, 'Microsoft/Edge/Application/msedge.exe'),
|
|
1025
|
+
];
|
|
1026
|
+
for (const p of cands) {
|
|
1027
|
+
probed.push(p);
|
|
1028
|
+
if (existsSync(p)) return p;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
die(
|
|
1032
|
+
`no Chrome/Edge/Brave/Chromium found. Probed:\n ${probed.join('\n ')}\nPass --chrome <path> to point at a browser binary.`,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Resolve a binary via `which` (linux). Synchronous, best-effort. */
|
|
1037
|
+
function whichSync(name) {
|
|
1038
|
+
try {
|
|
1039
|
+
return execFileSync('which', [name], { encoding: 'utf8' }).trim() || null;
|
|
1040
|
+
} catch {
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** Poll for the CDP DevToolsActivePort file; return ws:// endpoint. */
|
|
1046
|
+
async function waitForEndpoint(tmp) {
|
|
1047
|
+
const portFile = join(tmp, 'DevToolsActivePort');
|
|
1048
|
+
for (let i = 0; i < 150; i++) {
|
|
1049
|
+
if (existsSync(portFile)) {
|
|
1050
|
+
const [port, path] = readFileSync(portFile, 'utf8').split('\n');
|
|
1051
|
+
if (port && path) return `ws://127.0.0.1:${port.trim()}${path.trim()}`;
|
|
1052
|
+
}
|
|
1053
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
1054
|
+
}
|
|
1055
|
+
die('browser did not open a debugging port within 15s (DevToolsActivePort never appeared)');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/** Minimal promise-based CDP client over one WebSocket. */
|
|
1059
|
+
function makeCdp(ws) {
|
|
1060
|
+
let nextId = 1;
|
|
1061
|
+
const pending = new Map();
|
|
1062
|
+
// event listeners keyed by sessionId ('' = browser) → Map<method, Set<fn>>
|
|
1063
|
+
const listeners = new Map();
|
|
1064
|
+
ws.addEventListener('message', (ev) => {
|
|
1065
|
+
let msg;
|
|
1066
|
+
try {
|
|
1067
|
+
msg = JSON.parse(ev.data);
|
|
1068
|
+
} catch {
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (msg.id != null && pending.has(msg.id)) {
|
|
1072
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
1073
|
+
pending.delete(msg.id);
|
|
1074
|
+
if (msg.error) reject(new Error(msg.error.message ?? JSON.stringify(msg.error)));
|
|
1075
|
+
else resolve(msg.result);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
if (msg.method) {
|
|
1079
|
+
const sid = msg.sessionId ?? '';
|
|
1080
|
+
const byMethod = listeners.get(sid);
|
|
1081
|
+
const set = byMethod?.get(msg.method);
|
|
1082
|
+
if (set) for (const fn of [...set]) fn(msg.params ?? {});
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
const send = (method, params = {}, sessionId) =>
|
|
1086
|
+
new Promise((resolve, reject) => {
|
|
1087
|
+
const id = nextId++;
|
|
1088
|
+
pending.set(id, { resolve, reject });
|
|
1089
|
+
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
1090
|
+
});
|
|
1091
|
+
const on = (method, sessionId, fn) => {
|
|
1092
|
+
const sid = sessionId ?? '';
|
|
1093
|
+
if (!listeners.has(sid)) listeners.set(sid, new Map());
|
|
1094
|
+
const byMethod = listeners.get(sid);
|
|
1095
|
+
if (!byMethod.has(method)) byMethod.set(method, new Set());
|
|
1096
|
+
byMethod.get(method).add(fn);
|
|
1097
|
+
return () => byMethod.get(method)?.delete(fn);
|
|
1098
|
+
};
|
|
1099
|
+
/** Resolve on the next matching event, or reject after ms. */
|
|
1100
|
+
const once = (method, sessionId, ms) =>
|
|
1101
|
+
new Promise((resolve, reject) => {
|
|
1102
|
+
const off = on(method, sessionId, (p) => {
|
|
1103
|
+
off();
|
|
1104
|
+
clearTimeout(t);
|
|
1105
|
+
resolve(p);
|
|
1106
|
+
});
|
|
1107
|
+
const t = setTimeout(() => {
|
|
1108
|
+
off();
|
|
1109
|
+
reject(new Error(`timeout waiting for ${method}`));
|
|
1110
|
+
}, ms);
|
|
1111
|
+
});
|
|
1112
|
+
return { send, on, once };
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/** Read the slop-scan bundle once: local file next to the bridge, else CDN. */
|
|
1116
|
+
async function loadBundleSource() {
|
|
1117
|
+
const local = new URL('./slop-scan.global.js', import.meta.url);
|
|
1118
|
+
try {
|
|
1119
|
+
return readFileSync(local, 'utf8');
|
|
1120
|
+
} catch {
|
|
1121
|
+
/* fall through to CDN */
|
|
1122
|
+
}
|
|
1123
|
+
try {
|
|
1124
|
+
const res = await fetch('https://cdn.jsdelivr.net/npm/linear-grab@latest/dist/slop-scan.global.js');
|
|
1125
|
+
if (!res.ok) throw new Error(`CDN ${res.status}`);
|
|
1126
|
+
return await res.text();
|
|
1127
|
+
} catch (e) {
|
|
1128
|
+
die(
|
|
1129
|
+
`could not load the slop-scan bundle. Looked for ${local.pathname} and the jsDelivr CDN fallback (${e instanceof Error ? e.message : e}).`,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** The audit itself. Returns the process exit code. */
|
|
1135
|
+
async function runAudit() {
|
|
1136
|
+
if (typeof WebSocket === 'undefined') {
|
|
1137
|
+
die(`audit needs Node 22+ (built-in WebSocket); you have ${process.version}`);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const url = flag('--url', 'http://localhost:3000').replace(/\/+$/, '');
|
|
1141
|
+
const themeFlag = flag('--theme', 'both');
|
|
1142
|
+
const themes = themeFlag === 'both' ? ['light', 'dark'] : [themeFlag];
|
|
1143
|
+
const [vw, vh] = flag('--viewport', '1440x900')
|
|
1144
|
+
.split('x')
|
|
1145
|
+
.map((n) => Number(n) || 0);
|
|
1146
|
+
const settleMs = Number(flag('--wait', '1500'));
|
|
1147
|
+
const pageTimeout = Number(flag('--timeout', '30000'));
|
|
1148
|
+
const failOn = flag('--fail-on', 'error'); // error | warn | none
|
|
1149
|
+
const updateBaseline = argv.includes('--update-baseline');
|
|
1150
|
+
const auditDir = join(DIR, '.lineargrab');
|
|
1151
|
+
const outPath = flag('--out', join(auditDir, 'slop-report.md'));
|
|
1152
|
+
const baselinePath = join(auditDir, 'slop-baseline.json');
|
|
1153
|
+
const ndjsonPath = join(auditDir, 'scan.ndjson');
|
|
1154
|
+
|
|
1155
|
+
// Routes: --routes, else auditRoutes in <DIR>/.lineargrab.json, else ['/'].
|
|
1156
|
+
let routes = flag('--routes', null)
|
|
1157
|
+
?.split(',')
|
|
1158
|
+
.map((r) => r.trim())
|
|
1159
|
+
.filter(Boolean);
|
|
1160
|
+
if (!routes || !routes.length) {
|
|
1161
|
+
try {
|
|
1162
|
+
const cfg = JSON.parse(readFileSync(join(DIR, '.lineargrab.json'), 'utf8'));
|
|
1163
|
+
if (Array.isArray(cfg.auditRoutes) && cfg.auditRoutes.length) routes = cfg.auditRoutes;
|
|
1164
|
+
} catch {
|
|
1165
|
+
/* no config */
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
if (!routes || !routes.length) routes = ['/'];
|
|
1169
|
+
|
|
1170
|
+
const BUNDLE_SOURCE = await loadBundleSource();
|
|
1171
|
+
const bin = findBrowser();
|
|
1172
|
+
|
|
1173
|
+
// Auth: headless Chromium has no session — auth-gated routes would grade
|
|
1174
|
+
// the login page. `audit --login` opens a VISIBLE browser on a persistent
|
|
1175
|
+
// per-repo profile; sign in once, press Enter, and every later audit
|
|
1176
|
+
// reuses that profile (auto-detected). --fresh forces a clean throwaway.
|
|
1177
|
+
const defaultProfile = join(
|
|
1178
|
+
HISTORY_DIR,
|
|
1179
|
+
`audit-profile-${createHash('sha1').update(DIR).digest('hex').slice(0, 8)}`,
|
|
1180
|
+
);
|
|
1181
|
+
const explicitProfile = flag('--profile', null);
|
|
1182
|
+
const loginMode = argv.includes('--login');
|
|
1183
|
+
const fresh = argv.includes('--fresh');
|
|
1184
|
+
const persistentProfile =
|
|
1185
|
+
explicitProfile ?? (!fresh && (loginMode || existsSync(defaultProfile)) ? defaultProfile : null);
|
|
1186
|
+
|
|
1187
|
+
if (loginMode) {
|
|
1188
|
+
const profile = persistentProfile ?? defaultProfile;
|
|
1189
|
+
mkdirSync(profile, { recursive: true });
|
|
1190
|
+
alog('');
|
|
1191
|
+
alog(`${A.B}◆ linear-grab audit --login${A.R}`);
|
|
1192
|
+
alog(` A browser window is opening on ${A.C}${url}${A.R}.`);
|
|
1193
|
+
alog(` Sign in there, then come back and press ${A.B}Enter${A.R} to save the session.`);
|
|
1194
|
+
alog(` ${A.D}profile: ${profile}${A.R}`);
|
|
1195
|
+
const loginChild = spawn(
|
|
1196
|
+
bin,
|
|
1197
|
+
[`--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', url],
|
|
1198
|
+
{ stdio: 'ignore', detached: false },
|
|
1199
|
+
);
|
|
1200
|
+
await new Promise((resolve) => process.stdin.once('data', resolve));
|
|
1201
|
+
try {
|
|
1202
|
+
loginChild.kill('SIGTERM');
|
|
1203
|
+
} catch {
|
|
1204
|
+
/* already closed by the user */
|
|
1205
|
+
}
|
|
1206
|
+
alog(`${A.G}✓${A.R} session saved — future ${A.C}audit${A.R} runs use it automatically.`);
|
|
1207
|
+
return 0;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
alog('');
|
|
1211
|
+
alog(`${A.B}◆ linear-grab audit${A.R} ${A.D}v${VERSION}${A.R}`);
|
|
1212
|
+
alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
|
|
1213
|
+
alog(` url ${A.C}${url}${A.R}`);
|
|
1214
|
+
alog(` routes ${A.C}${routes.length}${A.R} ${A.D}${routes.join(', ')}${A.R}`);
|
|
1215
|
+
alog(` themes ${A.C}${themes.join(', ')}${A.R}`);
|
|
1216
|
+
alog(` browser ${A.C}${bin}${A.R}`);
|
|
1217
|
+
if (persistentProfile) alog(` profile ${A.C}${persistentProfile}${A.R} ${A.D}(signed-in session)${A.R}`);
|
|
1218
|
+
alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
|
|
1219
|
+
|
|
1220
|
+
const tmp = persistentProfile ?? mkdtempSync(join(tmpdir(), 'lg-audit-'));
|
|
1221
|
+
let child = null;
|
|
1222
|
+
let ws = null;
|
|
1223
|
+
const cleanup = () => {
|
|
1224
|
+
try {
|
|
1225
|
+
if (child && !child.killed) {
|
|
1226
|
+
child.kill('SIGTERM');
|
|
1227
|
+
const c = child;
|
|
1228
|
+
setTimeout(() => {
|
|
1229
|
+
try {
|
|
1230
|
+
c.kill('SIGKILL');
|
|
1231
|
+
} catch {
|
|
1232
|
+
/* gone */
|
|
1233
|
+
}
|
|
1234
|
+
}, 2000).unref?.();
|
|
1235
|
+
}
|
|
1236
|
+
} catch {
|
|
1237
|
+
/* ignore */
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
// Ephemeral profiles only — a signed-in persistent profile is the
|
|
1241
|
+
// user's saved session and must survive the run.
|
|
1242
|
+
if (!persistentProfile) rmSync(tmp, { recursive: true, force: true });
|
|
1243
|
+
} catch {
|
|
1244
|
+
/* ignore */
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
const onSigint = () => {
|
|
1248
|
+
cleanup();
|
|
1249
|
+
process.exit(130);
|
|
1250
|
+
};
|
|
1251
|
+
process.on('SIGINT', onSigint);
|
|
1252
|
+
|
|
1253
|
+
/** @type {Array<any>} */
|
|
1254
|
+
const allFindings = [];
|
|
1255
|
+
const failedRoutes = [];
|
|
1256
|
+
|
|
1257
|
+
try {
|
|
1258
|
+
// A reused profile keeps the previous run's DevToolsActivePort — remove
|
|
1259
|
+
// it so waitForEndpoint can't connect to a dead port.
|
|
1260
|
+
try {
|
|
1261
|
+
rmSync(join(tmp, 'DevToolsActivePort'), { force: true });
|
|
1262
|
+
} catch {
|
|
1263
|
+
/* fresh profile */
|
|
1264
|
+
}
|
|
1265
|
+
child = spawn(
|
|
1266
|
+
bin,
|
|
1267
|
+
[
|
|
1268
|
+
'--headless=new',
|
|
1269
|
+
'--remote-debugging-port=0',
|
|
1270
|
+
`--user-data-dir=${tmp}`,
|
|
1271
|
+
'--no-first-run',
|
|
1272
|
+
'--no-default-browser-check',
|
|
1273
|
+
'--disable-extensions',
|
|
1274
|
+
'--hide-scrollbars',
|
|
1275
|
+
'about:blank',
|
|
1276
|
+
],
|
|
1277
|
+
{ stdio: 'ignore' },
|
|
1278
|
+
);
|
|
1279
|
+
child.on('error', (e) => die(`failed to launch browser: ${e.message}`));
|
|
1280
|
+
|
|
1281
|
+
const endpoint = await waitForEndpoint(tmp);
|
|
1282
|
+
ws = new WebSocket(endpoint);
|
|
1283
|
+
await new Promise((resolve, reject) => {
|
|
1284
|
+
ws.addEventListener('open', resolve, { once: true });
|
|
1285
|
+
ws.addEventListener('error', () => reject(new Error('WebSocket error')), { once: true });
|
|
1286
|
+
});
|
|
1287
|
+
const cdp = makeCdp(ws);
|
|
1288
|
+
|
|
1289
|
+
// One reusable page target for the whole sweep.
|
|
1290
|
+
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
1291
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
|
|
1292
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
1293
|
+
await cdp.send('Runtime.enable', {}, sessionId);
|
|
1294
|
+
await cdp.send(
|
|
1295
|
+
'Emulation.setDeviceMetricsOverride',
|
|
1296
|
+
{ width: vw, height: vh, deviceScaleFactor: 1, mobile: false },
|
|
1297
|
+
sessionId,
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
for (const route of routes) {
|
|
1301
|
+
const target = url + route;
|
|
1302
|
+
// Collect per-theme findings so we can dedupe across the two themes.
|
|
1303
|
+
/** @type {Map<string, any>} */
|
|
1304
|
+
const routeMap = new Map();
|
|
1305
|
+
|
|
1306
|
+
for (const theme of themes) {
|
|
1307
|
+
const started = Date.now();
|
|
1308
|
+
await cdp.send(
|
|
1309
|
+
'Emulation.setEmulatedMedia',
|
|
1310
|
+
{ features: [{ name: 'prefers-color-scheme', value: theme }] },
|
|
1311
|
+
sessionId,
|
|
1312
|
+
);
|
|
1313
|
+
|
|
1314
|
+
const loaded = cdp.once('Page.loadEventFired', sessionId, pageTimeout).then(
|
|
1315
|
+
() => true,
|
|
1316
|
+
() => false,
|
|
1317
|
+
);
|
|
1318
|
+
await cdp.send('Page.navigate', { url: target }, sessionId);
|
|
1319
|
+
if (!(await loaded)) {
|
|
1320
|
+
failedRoutes.push({ route, theme, reason: `load timeout (>${pageTimeout}ms)` });
|
|
1321
|
+
alog(` ${A.Rd}✗${A.R} ${padRoute(route)} ${padTheme(theme)} ${A.D}load timeout${A.R}`);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
await new Promise((r) => setTimeout(r, settleMs));
|
|
1325
|
+
|
|
1326
|
+
// Inject the scan bundle.
|
|
1327
|
+
const inj = await cdp.send(
|
|
1328
|
+
'Runtime.evaluate',
|
|
1329
|
+
{ expression: BUNDLE_SOURCE, returnByValue: false },
|
|
1330
|
+
sessionId,
|
|
1331
|
+
);
|
|
1332
|
+
if (inj.exceptionDetails) {
|
|
1333
|
+
const reason = inj.exceptionDetails.exception?.description ?? 'inject failed';
|
|
1334
|
+
failedRoutes.push({ route, theme, reason });
|
|
1335
|
+
alog(` ${A.Rd}✗${A.R} ${padRoute(route)} ${padTheme(theme)} ${A.D}${reason.slice(0, 60)}${A.R}`);
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Lazy content mounts on scroll (marketing sections, consent banners,
|
|
1340
|
+
// virtualized lists) — sweep to the bottom and back so the DOM we
|
|
1341
|
+
// grade is the DOM users actually see, not a timing accident.
|
|
1342
|
+
await cdp.send(
|
|
1343
|
+
'Runtime.evaluate',
|
|
1344
|
+
{
|
|
1345
|
+
expression: `(async () => {
|
|
1346
|
+
const d = document.scrollingElement || document.documentElement;
|
|
1347
|
+
const step = Math.max(400, innerHeight * 0.8);
|
|
1348
|
+
for (let y = 0; y < d.scrollHeight && y < 20000; y += step) {
|
|
1349
|
+
scrollTo(0, y);
|
|
1350
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
1351
|
+
}
|
|
1352
|
+
scrollTo(0, 0);
|
|
1353
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1354
|
+
})()`,
|
|
1355
|
+
awaitPromise: true,
|
|
1356
|
+
returnByValue: false,
|
|
1357
|
+
},
|
|
1358
|
+
sessionId,
|
|
1359
|
+
);
|
|
1360
|
+
|
|
1361
|
+
// Run it — until two consecutive scans agree. Dynamic pages settle
|
|
1362
|
+
// over a second or two; a single-shot scan makes the baseline flaky
|
|
1363
|
+
// ("9 new findings" on an unchanged page = the gate crying wolf).
|
|
1364
|
+
let findings = null;
|
|
1365
|
+
let scanFailed = null;
|
|
1366
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1367
|
+
const evalRes = await cdp.send(
|
|
1368
|
+
'Runtime.evaluate',
|
|
1369
|
+
{
|
|
1370
|
+
// Attributed run resolves Component @ file:line via the page's
|
|
1371
|
+
// own react-grab (loaded by linear-grab's script tag); falls
|
|
1372
|
+
// back to the sync scan on older bundles.
|
|
1373
|
+
expression:
|
|
1374
|
+
'__SLOP_SCAN__.runAttributed ? __SLOP_SCAN__.runAttributed() : __SLOP_SCAN__.run()',
|
|
1375
|
+
awaitPromise: true,
|
|
1376
|
+
returnByValue: true,
|
|
1377
|
+
},
|
|
1378
|
+
sessionId,
|
|
1379
|
+
);
|
|
1380
|
+
if (evalRes.exceptionDetails) {
|
|
1381
|
+
scanFailed = evalRes.exceptionDetails.exception?.description ?? 'scan threw';
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
const next = Array.isArray(evalRes.result?.value) ? evalRes.result.value : [];
|
|
1385
|
+
if (findings && next.length === findings.length) {
|
|
1386
|
+
findings = next;
|
|
1387
|
+
break;
|
|
1388
|
+
}
|
|
1389
|
+
findings = next;
|
|
1390
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1391
|
+
}
|
|
1392
|
+
if (scanFailed) {
|
|
1393
|
+
failedRoutes.push({ route, theme, reason: scanFailed });
|
|
1394
|
+
alog(` ${A.Rd}✗${A.R} ${padRoute(route)} ${padTheme(theme)} ${A.D}${scanFailed.slice(0, 60)}${A.R}`);
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
findings ??= [];
|
|
1398
|
+
|
|
1399
|
+
// Dedupe across themes within this route.
|
|
1400
|
+
for (const f of findings) {
|
|
1401
|
+
const key = `${f.ruleId}|${f.selector}|${f.evidence}`;
|
|
1402
|
+
const existing = routeMap.get(key);
|
|
1403
|
+
if (existing) {
|
|
1404
|
+
if (!existing.themes.includes(theme)) existing.themes.push(theme);
|
|
1405
|
+
} else {
|
|
1406
|
+
routeMap.set(key, { ...f, route, themes: [theme] });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
const errs = findings.filter((f) => f.severity === 'error').length;
|
|
1411
|
+
const warns = findings.filter((f) => f.severity === 'warn').length;
|
|
1412
|
+
alog(
|
|
1413
|
+
` ${A.G}✓${A.R} ${padRoute(route)} ${padTheme(theme)} ` +
|
|
1414
|
+
`${String(errs).padStart(4)} ${A.D}errors${A.R} ${String(warns).padStart(3)} ${A.D}warns${A.R} ` +
|
|
1415
|
+
`${A.D}${Date.now() - started}ms${A.R}`,
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
for (const f of routeMap.values()) allFindings.push(f);
|
|
1419
|
+
}
|
|
1420
|
+
} finally {
|
|
1421
|
+
try {
|
|
1422
|
+
ws?.close();
|
|
1423
|
+
} catch {
|
|
1424
|
+
/* ignore */
|
|
1425
|
+
}
|
|
1426
|
+
process.off('SIGINT', onSigint);
|
|
1427
|
+
cleanup();
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// ---- baseline ------------------------------------------------------------
|
|
1431
|
+
const bkey = (f) => `${f.ruleId}|${f.route}|${f.selector}`;
|
|
1432
|
+
const currentKeys = [...new Set(allFindings.map(bkey))];
|
|
1433
|
+
|
|
1434
|
+
bootstrapAuditDir(auditDir);
|
|
1435
|
+
|
|
1436
|
+
if (updateBaseline) {
|
|
1437
|
+
writeFileSync(
|
|
1438
|
+
baselinePath,
|
|
1439
|
+
JSON.stringify({ createdAt: new Date().toISOString(), keys: currentKeys }, null, 2),
|
|
1440
|
+
);
|
|
1441
|
+
alog('');
|
|
1442
|
+
alog(`${A.G}✓${A.R} baseline updated — ${A.B}${currentKeys.length}${A.R} keys recorded`);
|
|
1443
|
+
alog(` ${A.D}${baselinePath}${A.R}`);
|
|
1444
|
+
// Still write the report + ndjson so the artifacts stay in sync.
|
|
1445
|
+
for (const f of allFindings) f.isNew = false;
|
|
1446
|
+
writeReportAndNdjson(allFindings, {
|
|
1447
|
+
url,
|
|
1448
|
+
routes,
|
|
1449
|
+
themes,
|
|
1450
|
+
outPath,
|
|
1451
|
+
ndjsonPath,
|
|
1452
|
+
newCount: 0,
|
|
1453
|
+
});
|
|
1454
|
+
return 0;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
let baselineKeys = null;
|
|
1458
|
+
let hasBaseline = false;
|
|
1459
|
+
try {
|
|
1460
|
+
baselineKeys = new Set(JSON.parse(readFileSync(baselinePath, 'utf8')).keys ?? []);
|
|
1461
|
+
hasBaseline = true;
|
|
1462
|
+
} catch {
|
|
1463
|
+
/* no baseline yet */
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
for (const f of allFindings) f.isNew = hasBaseline ? !baselineKeys.has(bkey(f)) : true;
|
|
1467
|
+
const newFindings = allFindings.filter((f) => f.isNew);
|
|
1468
|
+
|
|
1469
|
+
// ---- write artifacts -----------------------------------------------------
|
|
1470
|
+
const totalErr = allFindings.filter((f) => f.severity === 'error').length;
|
|
1471
|
+
const totalWarn = allFindings.filter((f) => f.severity === 'warn').length;
|
|
1472
|
+
writeReportAndNdjson(allFindings, {
|
|
1473
|
+
url,
|
|
1474
|
+
routes,
|
|
1475
|
+
themes,
|
|
1476
|
+
outPath,
|
|
1477
|
+
ndjsonPath,
|
|
1478
|
+
newCount: newFindings.length,
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
// ---- exit decision -------------------------------------------------------
|
|
1482
|
+
const gate =
|
|
1483
|
+
failOn === 'none'
|
|
1484
|
+
? []
|
|
1485
|
+
: failOn === 'warn'
|
|
1486
|
+
? newFindings.filter((f) => f.severity === 'error' || f.severity === 'warn')
|
|
1487
|
+
: newFindings.filter((f) => f.severity === 'error');
|
|
1488
|
+
const willFail = gate.length > 0;
|
|
1489
|
+
|
|
1490
|
+
alog('');
|
|
1491
|
+
alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
|
|
1492
|
+
alog(
|
|
1493
|
+
` ${A.B}totals${A.R} ${totalErr} errors ${totalWarn} warns ` +
|
|
1494
|
+
`across ${routes.length} route(s) × ${themes.length} theme(s)`,
|
|
1495
|
+
);
|
|
1496
|
+
if (failedRoutes.length) {
|
|
1497
|
+
alog(` ${A.Y}${failedRoutes.length} route-theme sweep(s) failed${A.R} ${A.D}(see ✗ above)${A.R}`);
|
|
1498
|
+
}
|
|
1499
|
+
if (!hasBaseline) {
|
|
1500
|
+
alog(
|
|
1501
|
+
` ${A.Y}no baseline${A.R} — all ${newFindings.length} findings count as NEW. ` +
|
|
1502
|
+
`Run ${A.C}--update-baseline${A.R} to create the ratchet.`,
|
|
1503
|
+
);
|
|
1504
|
+
} else {
|
|
1505
|
+
alog(` ${A.B}new vs baseline${A.R} ${newFindings.length} findings`);
|
|
1506
|
+
}
|
|
1507
|
+
alog(` report ${A.C}${outPath}${A.R}`);
|
|
1508
|
+
if (willFail) {
|
|
1509
|
+
alog(
|
|
1510
|
+
` ${A.Rd}${A.B}FAIL${A.R} — ${gate.length} new ${failOn === 'warn' ? 'error/warn' : 'error'}-severity finding(s) (fail-on=${failOn})`,
|
|
1511
|
+
);
|
|
1512
|
+
} else {
|
|
1513
|
+
alog(` ${A.G}${A.B}PASS${A.R} — 0 new findings at/above fail-on=${failOn}`);
|
|
1514
|
+
}
|
|
1515
|
+
alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
|
|
1516
|
+
return willFail ? 1 : 0;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const padRoute = (r) => r.slice(0, 24).padEnd(24);
|
|
1520
|
+
const padTheme = (t) => t.padEnd(5);
|
|
1521
|
+
|
|
1522
|
+
/** Bootstrap the .lineargrab dir + self-gitignore (mirrors /scan/events). */
|
|
1523
|
+
function bootstrapAuditDir(dir) {
|
|
1524
|
+
try {
|
|
1525
|
+
mkdirSync(dir, { recursive: true });
|
|
1526
|
+
writeFileSync(join(dir, '.gitignore'), '*\n', { flag: 'wx' });
|
|
1527
|
+
} catch {
|
|
1528
|
+
/* exists */
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/** Append audit findings to scan.ndjson with the same rotation as telemetry. */
|
|
1533
|
+
function appendNdjson(file, findings) {
|
|
1534
|
+
const lines =
|
|
1535
|
+
findings
|
|
1536
|
+
.slice(0, 5000)
|
|
1537
|
+
.map((f) =>
|
|
1538
|
+
JSON.stringify({
|
|
1539
|
+
kind: 'slop-scan',
|
|
1540
|
+
mode: 'headless',
|
|
1541
|
+
route: f.route,
|
|
1542
|
+
themes: f.themes,
|
|
1543
|
+
at: Date.now(),
|
|
1544
|
+
ruleId: f.ruleId,
|
|
1545
|
+
part: f.part,
|
|
1546
|
+
severity: f.severity,
|
|
1547
|
+
description: f.description,
|
|
1548
|
+
selector: f.selector,
|
|
1549
|
+
evidence: f.evidence,
|
|
1550
|
+
component: f.component ?? null,
|
|
1551
|
+
source: f.source ?? null,
|
|
1552
|
+
isNew: !!f.isNew,
|
|
1553
|
+
}),
|
|
1554
|
+
)
|
|
1555
|
+
.join('\n') + '\n';
|
|
1556
|
+
try {
|
|
1557
|
+
appendFileSync(file, lines);
|
|
1558
|
+
const size = statSync(file).size;
|
|
1559
|
+
if (size > 2_000_000) {
|
|
1560
|
+
const keep = readFileSync(file, 'utf8');
|
|
1561
|
+
writeFileSync(file, keep.slice(Math.floor(keep.length / 2)).replace(/^[^\n]*\n/, ''));
|
|
1562
|
+
}
|
|
1563
|
+
} catch {
|
|
1564
|
+
/* disk issues — never fail the audit on telemetry */
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Write the markdown report + append NDJSON events. */
|
|
1569
|
+
function writeReportAndNdjson(findings, { url, routes, themes, outPath, ndjsonPath, newCount }) {
|
|
1570
|
+
const totalErr = findings.filter((f) => f.severity === 'error').length;
|
|
1571
|
+
const totalWarn = findings.filter((f) => f.severity === 'warn').length;
|
|
1572
|
+
|
|
1573
|
+
const lines = [
|
|
1574
|
+
'# Design slop-scan report',
|
|
1575
|
+
'',
|
|
1576
|
+
`- **url**: ${url}`,
|
|
1577
|
+
`- **routes**: ${routes.length} (${routes.join(', ')})`,
|
|
1578
|
+
`- **themes**: ${themes.join(', ')}`,
|
|
1579
|
+
`- **totals**: ${totalErr} errors / ${totalWarn} warns`,
|
|
1580
|
+
`- **new vs baseline**: ${newCount}`,
|
|
1581
|
+
`- **generated**: ${new Date().toISOString()}`,
|
|
1582
|
+
'',
|
|
1583
|
+
];
|
|
1584
|
+
|
|
1585
|
+
// Group by route → rule. Errors first, then by count desc.
|
|
1586
|
+
const byRoute = new Map();
|
|
1587
|
+
for (const f of findings) {
|
|
1588
|
+
if (!byRoute.has(f.route)) byRoute.set(f.route, []);
|
|
1589
|
+
byRoute.get(f.route).push(f);
|
|
1590
|
+
}
|
|
1591
|
+
for (const route of routes) {
|
|
1592
|
+
const rf = byRoute.get(route);
|
|
1593
|
+
if (!rf || !rf.length) continue;
|
|
1594
|
+
lines.push(`## ${route}`, '');
|
|
1595
|
+
const byRule = new Map();
|
|
1596
|
+
for (const f of rf) {
|
|
1597
|
+
if (!byRule.has(f.ruleId)) byRule.set(f.ruleId, []);
|
|
1598
|
+
byRule.get(f.ruleId).push(f);
|
|
1599
|
+
}
|
|
1600
|
+
const rules = [...byRule.entries()].sort((a, b) => {
|
|
1601
|
+
const sev = (list) => (list.some((x) => x.severity === 'error') ? 0 : 1);
|
|
1602
|
+
return sev(a[1]) - sev(b[1]) || b[1].length - a[1].length;
|
|
1603
|
+
});
|
|
1604
|
+
for (const [ruleId, list] of rules) {
|
|
1605
|
+
const head = list[0];
|
|
1606
|
+
const sevBadge = head.severity === 'error' ? 'error' : 'warn';
|
|
1607
|
+
lines.push(
|
|
1608
|
+
`### ${ruleId} \`(${sevBadge})\`${head.part ? ` §${head.part}` : ''}`,
|
|
1609
|
+
head.description ? `${head.description}` : '',
|
|
1610
|
+
'',
|
|
1611
|
+
);
|
|
1612
|
+
for (const f of list) {
|
|
1613
|
+
const where = f.component || f.source ? ` — ${[f.component, f.source].filter(Boolean).join(' @ ')}` : '';
|
|
1614
|
+
lines.push(
|
|
1615
|
+
`- \`${f.selector}\` — ${f.evidence}${where} [${f.themes.join('/')}]${f.isNew ? ' **NEW**' : ''}`,
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
lines.push('');
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
try {
|
|
1623
|
+
mkdirSync(join(outPath, '..'), { recursive: true });
|
|
1624
|
+
} catch {
|
|
1625
|
+
/* ignore */
|
|
1626
|
+
}
|
|
1627
|
+
writeFileSync(outPath, lines.join('\n'));
|
|
1628
|
+
appendNdjson(ndjsonPath, findings);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// Dispatch audit LAST — every const/fn above is now initialized, so no TDZ.
|
|
1632
|
+
if (AUDIT_MODE) {
|
|
1633
|
+
const code = await runAudit();
|
|
1634
|
+
process.exit(code);
|
|
1635
|
+
}
|
package/package.json
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linear-grab-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
4
4
|
"description": "Local bridge for Linear Grab — delegate issues from the browser panel to headless Claude Code sessions running in your repo, with live status and an upload relay.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"linear-grab-bridge": "./linear-grab-bridge.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"linear-grab-bridge.mjs"
|
|
10
|
+
"linear-grab-bridge.mjs",
|
|
11
|
+
"slop-scan.global.js"
|
|
11
12
|
],
|
|
12
13
|
"engines": {
|
|
13
14
|
"node": ">=18"
|
|
14
15
|
},
|
|
15
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"linear",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"cursor",
|
|
20
|
+
"agent",
|
|
21
|
+
"bridge",
|
|
22
|
+
"linear-grab"
|
|
23
|
+
],
|
|
16
24
|
"repository": {
|
|
17
25
|
"type": "git",
|
|
18
26
|
"url": "git+https://github.com/ahmedbanihanibh/linear-grab.git"
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
(function(){function e(e){return e.split(`,`).map(e=>{let t=e.trim();return t.endsWith(`ms`)?parseFloat(t)||0:t.endsWith(`s`)?(parseFloat(t)||0)*1e3:0})}var t=new Set(`width.height.min-width.min-height.max-width.max-height.top.left.right.bottom.inset.margin.margin-top.margin-right.margin-bottom.margin-left.padding.padding-top.padding-right.padding-bottom.padding-left.font-size.line-height.letter-spacing.box-shadow.filter.background-position.grid-template-columns.grid-template-rows.flex-basis.gap`.split(`.`));function n(e){return e.includes(`all`)?[`all (includes layout/paint props)`]:e.filter(e=>t.has(e))}function r(e){let t=i(e);if(t===`ease-in`||t===`ease-in-out`)return t;let n=t.match(/cubic-bezier\(\s*([\d.]+)\s*,\s*(-?[\d.]+)/);if(n){let e=parseFloat(n[1]),r=parseFloat(n[2]);if(e>=.4&&r<e/2)return t}return null}function i(e){let t=0;for(let n=0;n<e.length;n++){let r=e[n];if(r===`(`)t++;else if(r===`)`)t--;else if(r===`,`&&t===0)return e.slice(0,n).trim()}return e.trim()}var a=`[data-slot*="content"], [role="menu"], [role="dialog"], [role="listbox"]`,o=new Set([`width`,`height`,`flex-basis`,`grid-template-columns`,`grid-template-rows`,`grid-template-areas`]);function s(e,t){if(e instanceof SVGElement)return!0;try{if(e.closest(`svg`))return!0}catch{}try{if(e.closest(a))return!0}catch{}try{if(e.closest(`[data-panel]`))return!t||t.length===0||t.every(e=>o.has(e))}catch{}return!1}function c(e){let t=e.id?`#${e.id}`:``,n=typeof e.className==`string`&&e.className?`.${e.className.split(/\s+/).filter(Boolean).slice(0,2).join(`.`)}`:``;return`${e.tagName.toLowerCase()}${t}${n}`}function l(e,t,n){return{ruleId:e.id,part:e.part,severity:e.severity,description:e.description,selector:c(t),evidence:n,el:typeof WeakRef<`u`?new WeakRef(t):void 0}}function u(e){return e.getBoundingClientRect()}function d(e){return e.split(`/`)[0].trim().split(/\s+/).filter(Boolean).map(e=>e.endsWith(`px`)?parseFloat(e):NaN)}function f(e){let t=e.match(/rgba?\(([^)]+)\)/);return t?t[1].split(`,`).map(e=>e.trim()).join(`,`):null}function p(e,t){let n=t,r=n.maskImage||n.webkitMaskImage||``;if(r&&r!==`none`)return r;let i=(e.getAttribute(`style`)??``).match(/(?:^|;|\s)(?:-webkit-)?mask-image\s*:\s*([^;]+)/i);return i&&i[1].trim()&&i[1].trim()!==`none`?i[1].trim():``}var m=new Set([0,8,12,16]),h={light:f(`rgb(240, 240, 241)`),dark:f(`rgb(35, 35, 37)`)},g=new Set([`#50e3c2`,`#ee0000`,`#f5a623`]),_=`button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], input, textarea, select`;function v(e){let t=[],n=e=>{for(let r of Array.from(e)){let e=r;if(e.cssRules&&e.cssRules.length){n(e.cssRules);continue}let i=r.selectorText;i&&i.includes(`:hover`)&&t.push(i.replace(/:hover/g,``))}};for(let t of Array.from(e.styleSheets))try{n(t.cssRules)}catch{}return t}function y(e,t){for(let n of t)if(n.trim())try{if(e.matches(n))return!0}catch{}return!1}function b(e){return!e.matches(`button, [role="button"]`)||e.querySelectorAll(`svg`).length===0?!1:(e.textContent??``).trim().length===0}function x(e){let t=e.parentElement;for(;t;){try{let e=S(t).overflowY;if(e===`auto`||e===`scroll`)return t}catch{}t=t.parentElement}return null}var S=e=>getComputedStyle(e),C={id:`radius-vocabulary`,part:`§40`,severity:`error`,description:`§40: radius vocabulary is {8, 12, 16, full} — never rounded-md/[4px]/[6px]/[10px]. Off-vocabulary radius.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).borderRadius;if(!r||r===`0px`)continue;let i=d(r);if(i.some(e=>Number.isNaN(e)))continue;let a=Math.max(...i);if(a===0)continue;let o=u(n),s=Math.min(o.width||0,o.height||0);s>0&&a>=s/2-.5||i.every(e=>m.has(e))||t.push(l(this,n,`border-radius: ${r}`))}return t}},w={id:`row-radius-token`,part:`§40`,severity:`error`,description:`§40: list rows consume --row-radius (8px) or 0 — no bespoke per-surface row radius.`,check(e){let t=[];for(let n of e.els){let r=u(n).height;if(r<26||r>50||!x(n))continue;let i=n.parentElement;if(!i||Array.from(i.children).filter(e=>{let t=u(e);return Math.abs(t.height-r)<2}).length<3)continue;let a=d(e.css(n).borderRadius);if(a.some(e=>Number.isNaN(e)))continue;let o=Math.max(0,...a);o===0||o===8||t.push(l(this,n,`row radius ${e.css(n).borderRadius} (want 0 or var(--row-radius)=8px)`))}return t}},T={id:`active-fill-token`,part:`§11`,severity:`error`,description:`§11: active/selected fill must be var(--selected) (theme-safe) — never bg-secondary (#F0F0F1 / #232325).`,check(e){let t=[],n=null;try{let t=e.root.documentElement??document.documentElement;t&&(n=f(e.css(t).getPropertyValue(`--selected`).trim()))}catch{}let r=[h.light,h.dark].filter(Boolean);if(n&&r.includes(n)){let t=e.root.documentElement??document.documentElement;return[l({id:this.id,part:this.part,severity:`warn`,description:`§11: active-fill-token unverifiable — --selected resolves EQUAL to bg-secondary this theme; cannot distinguish the token from the anti-pattern.`},t??e.els[0],`--selected == bg-secondary (${n})`)]}for(let n of e.els){if(!(n.matches(`[role="tab"], [role="button"], [aria-selected], [role="option"]`)||n.closest(`[role="tablist"], [role="menu"], [data-rail], nav`)))continue;let i=f(e.css(n).backgroundColor);i&&r.includes(i)&&t.push(l(this,n,`background bg-secondary (${e.css(n).backgroundColor}) — use var(--selected)`))}return t}},E={id:`inline-style-state`,part:`§40`,severity:`error`,description:`§40: the shared row-action class owns ALL visual states; inline styles carry LAYOUT ONLY — an inline background/color kills the class state.`,check(e){let t=[];for(let n of e.els){let e=typeof n.className==`string`?n.className:``;if(!/pb-row-action|row-action/.test(e))continue;let r=n.getAttribute(`style`)??``;/(^|;|\s)(background|color)\s*:/.test(r)&&t.push(l(this,n,`inline style="${r}" on a row-action element`))}return t}},D={id:`icon-button-no-hover`,part:`§40`,severity:`warn`,description:`§40/Part 10: every interactive control changes color on hover — icon-only button with no :hover rule and no .pb-row-action.`,check(e){let t=[],n=v(e.root.styleSheets?e.root:document);for(let r of e.els){if(!b(r))continue;let i=typeof r.className==`string`?r.className:``;/pb-row-action|row-action/.test(i)||r.hasAttribute(`data-framer-appear-id`)||Array.from(r.attributes).some(e=>e.name.startsWith(`data-framer`))||e.css(r).willChange.includes(`transform`)||y(r,n)||t.push(l(this,r,`icon-only ${r.tagName.toLowerCase()} with no hover rule / .pb-row-action`))}return t}},O={id:`icon-button-no-tooltip`,part:`§7`,severity:`warn`,description:`§7: every icon-only button gets the shared Tooltip — needs aria-describedby / data-slot="tooltip-trigger" (menu-row title exempt).`,check(e){let t=[];for(let n of e.els)b(n)&&(n.hasAttribute(`aria-describedby`)||n.getAttribute(`data-slot`)===`tooltip-trigger`||n.closest(`[data-slot="tooltip-trigger"]`)||n.closest(`[role="menuitem"]`)&&n.hasAttribute(`title`)||t.push(l(this,n,`icon-only ${n.tagName.toLowerCase()} with no tooltip trigger / aria-describedby`)));return t}},k={id:`unvirtualized-scroller`,part:`§0`,severity:`error`,description:`§0: every dynamic-length scrolling list virtualizes — a tall scroller with 60+ direct children is unvirtualized.`,check(e){let t=[];for(let n of e.els){let r=e.css(n);if(![r.overflowY,r.overflowX,r.overflow].some(e=>e===`auto`||e===`scroll`)||n.scrollHeight<=2*n.clientHeight)continue;let i=n.children.length;i<=60||t.push(l(this,n,`${i} direct children, scrollHeight ${n.scrollHeight} > 2×clientHeight ${n.clientHeight}`))}return t}},A={id:`scroll-no-fade`,part:`§1`,severity:`warn`,description:`§1: a horizontal scroller needs a truthful scroll fade — no scroll-fade-x/scroll-fade-x-js class and no mask-image.`,check(e){let t=[];for(let n of e.els){if(n.scrollWidth<=n.clientWidth+8)continue;let r=typeof n.className==`string`?n.className:``;/scroll-fade-x(-js)?/.test(r)||p(n,e.css(n))||t.push(l(this,n,`horizontal scroller (scrollWidth ${n.scrollWidth} > clientWidth ${n.clientWidth}) with no fade`))}return t}},j={id:`phantom-fade`,part:`§1`,severity:`warn`,description:`§1: a fade must NEVER paint when nothing is hidden — mask-image present but scrollWidth ≤ clientWidth (the fade lies).`,check(e){let t=[];for(let n of e.els)p(n,e.css(n))&&(n.scrollWidth>n.clientWidth||t.push(l(this,n,`mask-image fade but scrollWidth ${n.scrollWidth} ≤ clientWidth ${n.clientWidth}`)));return t}},M={id:`scrollbar-flush`,part:`Part 5`,severity:`warn`,description:`Part 5: scrollables carry asymmetric right padding so content clears the scrollbar — padding-right < 8px while overflowing vertically with content reaching the right edge.`,check(e){let t=[];for(let n of e.els){let r=e.css(n);if(![r.overflowY,r.overflow].some(e=>e===`auto`||e===`scroll`)||n.scrollHeight<=n.clientHeight)continue;let i=parseFloat(r.paddingRight)||0;if(i>=8)continue;let a=u(n);Array.from(n.children).some(e=>{let t=u(e);return a.right-t.right<=4})&&t.push(l(this,n,`padding-right ${i}px (<8px) with content flush to the scrollbar`))}return t}},N=/^("?(Geist|Geist Mono)"?|ui-monospace|ui-sans-serif|-apple-system|system-ui)/i,P={id:`font-family`,part:`Part 5`,severity:`warn`,description:`Part 5: Geist / Geist Mono ONLY — computed font-family does not start with the Geist / ui-monospace fallback chain.`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=e.css(n).fontFamily;r&&(N.test(r.trim())||t.push(l(this,n,`font-family: ${r}`)))}return t}},F={id:`uppercase-label`,part:`§25`,severity:`error`,description:`§25: group/section labels are sentence-case — never uppercase (text-transform: uppercase on small <13px text).`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=e.css(n);r.textTransform===`uppercase`&&((parseFloat(r.fontSize)||0)>=13||t.push(l(this,n,`text-transform: uppercase at ${r.fontSize}`)))}return t}},I={id:`hardcoded-status-hex`,part:`Part 5`,severity:`warn`,description:`Part 5: raw hex only for the status trio (#50E3C2/#EE0000/#F5A623) — a saturated non-token color on small UI text.`,check(e){let t=[];for(let n of e.els){if(!q(n))continue;let r=(n.getAttribute(`style`)??``).match(/#[0-9a-fA-F]{6}/g)??[],i=e.css(n);if((parseFloat(i.fontSize)||0)>=16)continue;for(let e of r)g.has(e.toLowerCase())||t.push(l(this,n,`hardcoded ${e} (not the brand status trio)`));let a=f(i.color);if(a&&r.length===0){let[e,r,o]=a.split(`,`).map(e=>parseFloat(e));Number.isFinite(e)&&J(e,r,o)&&t.push(l(this,n,`saturated non-token color ${i.color} on small UI text`))}}return t}},L={id:`editor-shadow`,part:`Part 5`,severity:`error`,description:`Part 5: editor surfaces are FLAT — box-shadow that is not a 0.5px hairline ring / approved popover/card/input token shadow.`,check(e){let t=[];if(!(e.root.querySelector?.(`[data-editor-surface]`)||typeof location<`u`&&/\/editor\//.test(location.pathname)))return t;for(let n of e.els){if(!n.closest(`[data-editor-surface]`)&&!/\/editor\//.test(typeof location<`u`?location.pathname:``))continue;let r=e.css(n).boxShadow;!r||r===`none`||Y(r)||t.push(l(this,n,`box-shadow: ${r}`))}return t}},R={id:`focusring-clip`,part:`§24`,severity:`warn`,description:`§24/Part 5: focus rings never clip — a focusable element whose nearest overflow-hidden ancestor sits <4px from its border box.`,check(e){let t=[];for(let n of e.els){if(!n.matches(_)&&n.getAttribute(`tabindex`)==null)continue;let r=n.parentElement;for(;r;){let t=e.css(r);if([t.overflow,t.overflowX,t.overflowY].some(e=>e===`hidden`))break;r=r.parentElement}if(!r)continue;let i=u(n),a=u(r);if(i.width===0&&i.height===0)continue;let o=Math.min(i.left-a.left,a.right-i.right,i.top-a.top,a.bottom-i.bottom);o>=4||t.push(l(this,n,`overflow-hidden ancestor ${o.toFixed(1)}px from border box — focus ring will clip`))}return t}},z={id:`fixed-width-in-pane`,part:`§24`,severity:`warn`,description:`§24: no fixed field widths in resizable panes — w-[Npx] (N ≥ 200) inside a [data-panel] / flex-basis sibling clips at the pane edge.`,check(e){let t=[];for(let n of e.els){if(!n.closest(`[data-panel]`))continue;let e=typeof n.className==`string`?n.className:``,r=n.getAttribute(`style`)??``,i=e.match(/w-\[(\d+)px\]/)??r.match(/(?:^|;|\s)width\s*:\s*(\d+)px/);if(!i)continue;let a=parseFloat(i[1]);a<200||t.push(l(this,n,`fixed width ${a}px inside a resizable pane — use w-full max-w-[${a}px]`))}return t}},B={id:`menu-icons-all-or-none`,part:`§21`,severity:`error`,description:`§21: within one menu ALL items carry a leading icon or NONE do — a mixed set reads broken.`,check(e){let t=[],n=Array.from(e.root.querySelectorAll(`[role="menu"]`));for(let e of n){let n=Array.from(e.querySelectorAll(`[role="menuitem"]`));if(n.length<2)continue;let r=n.filter(e=>e.querySelector(`svg`)).length;r===0||r===n.length||t.push(l(this,e,`${r}/${n.length} menu items have a leading icon`))}return t}},V={id:`menu-icon-mixed-weight`,part:`§21`,severity:`warn`,description:`§21: menu icons are ONE stroke family — some filled (fill=currentColor) and some stroke-based, or rendered sizes differing >2px, mixes two design systems.`,check(e){let t=[],n=Array.from(e.root.querySelectorAll(`[role="menu"]`));for(let e of n){let n=Array.from(e.querySelectorAll(`[role="menuitem"] svg`));if(n.length<2)continue;let r=0,i=0,a=[];for(let e of n){Array.from(e.querySelectorAll(`path, circle, rect, polygon`)).some(e=>{let t=(e.getAttribute(`fill`)??``).toLowerCase();return t===`currentcolor`||t&&t!==`none`})?r++:i++;let t=u(e);a.push(Math.max(t.width,t.height))}let o=Math.max(...a),s=Math.min(...a),c=r>0&&i>0,d=o>0&&o-s>2;!c&&!d||t.push(l(this,e,c?`${r} filled + ${i} stroke-based icons in one menu`:`icon sizes differ by ${(o-s).toFixed(1)}px`))}return t}},H={id:`chrome-selectable`,part:`Part 5`,severity:`warn`,description:`Part 5: chrome (rails/pills/tabs) is select-none — a rail/pill/tab without user-select:none.`,check(e){let t=[];for(let n of e.els){if(!(n.matches(`[role="tab"]`)||n.closest(`[role="tablist"], [data-rail], nav`)||/\bpill\b/.test(typeof n.className==`string`?n.className:``)))continue;let r=e.css(n).userSelect||e.css(n).webkitUserSelect;r!==`none`&&t.push(l(this,n,`chrome element without user-select:none (user-select: ${r||`auto`})`))}return t}},U={id:`cursor-mismatch`,part:`Part 5`,severity:`warn`,description:`Part 5: cursor semantics — buttons get pointer, static text gets default. A button with cursor:text, or static text with cursor:pointer and no interactive ancestor.`,check(e){let t=[];for(let n of e.els){let r=e.css(n).cursor;if(n.matches(`button, [role="button"]`)&&r===`text`){t.push(l(this,n,`interactive control with cursor:text`));continue}if(r===`pointer`&&n.matches(`p, h1, h2, h3, h4, h5, h6, span, label`)){if(n.closest(`button, a, [role="button"], [onclick]`)||n.matches(`button, a, [role="button"]`))continue;t.push(l(this,n,`static ${n.tagName.toLowerCase()} with cursor:pointer and no interactive ancestor`))}}return t}};function W(e){let t=e.transitionProperty.split(`,`).map(e=>e.trim()).filter(Boolean);return t.length===1&&t[0]===`none`?[]:t}var G=[C,w,T,E,D,O,k,A,j,M,P,F,I,L,R,z,B,V,H,U,{id:`transition-all`,part:`§42`,severity:`error`,description:`§42: never transition-all — scope to what changes (transition-colors / transition-[transform,...]).`,check(e){let t=[];for(let n of e.els){if(!n.matches(_))continue;let r=W(e.css(n));r.includes(`all`)&&(s(n,r)||t.push(l(this,n,`transition-property: all`)))}return t}},{id:`transition-paint-prop`,part:`§42`,severity:`error`,description:`§42: never transition layout/paint props (box-shadow, width, height, top/left, margin, padding) — they reflow/repaint every frame; focus rings SNAP, shadows animate a pseudo-element opacity.`,check(e){let t=[];for(let r of e.els){let i=W(e.css(r)),a=n(i).filter(e=>e!==`all (includes layout/paint props)`);a.length!==0&&(s(r,i)||t.push(l(this,r,`transitions layout/paint props: ${a.join(`, `)}`)))}return t}},{id:`transition-too-slow`,part:`§42`,severity:`error`,description:`§42: interaction feedback is ≤100ms (duration-75 for small controls) — the 100ms theme default is fine; an explicit duration-150+ crept in.`,check(t){let n=[];for(let r of t.els){if(!r.matches(_))continue;let i=t.css(r),a=W(i);if(a.length===0||s(r,a))continue;let o=e(i.transitionDuration),c=Math.max(0,...o);c<=100||n.push(l(this,r,`transition-duration ${c}ms (>100ms) on [${a.join(`, `)}]`))}return n}},{id:`ease-in-feedback`,part:`§42`,severity:`warn`,description:`§42: house curve is cubic-bezier(.2,0,.1,1) ease-out — never ease-in / ease-in-out (or an ease-in-shaped bezier) on feedback.`,check(e){let t=[];for(let n of e.els){if(!n.matches(_))continue;let i=e.css(n),a=W(i);if(a.length===0||s(n,a))continue;let o=r(i.transitionTimingFunction);o&&t.push(l(this,n,`transition-timing-function: ${o}`))}return t}},{id:`press-instant-rule-missing`,part:`§42`,severity:`error`,description:"§42: the global press-instant rule (globals.css) must exist — `button:active{transition-duration:0s}` (or transition:none). Absent = press feedback animates instead of snapping.",check(e){let t=e.root.styleSheets?e.root:document,n=!1,r=e=>{for(let t of Array.from(e)){if(n)return;let e=t;if(e.cssRules&&e.cssRules.length){r(e.cssRules);continue}let i=t,a=i.selectorText;if(a&&/:active\b/.test(a)&&/(^|[\s,])(button|a|\[role=("|')?(button|tab|menuitem|option)("|')?\])/i.test(a))try{let e=i.style.transitionDuration,t=i.style.transition;if(e===`0s`||/(^|\s)none(\s|$)/.test(t)||/\b0s\b/.test(t)){n=!0;return}}catch{}}};try{for(let e of Array.from(t.styleSheets)){if(n)break;try{r(e.cssRules)}catch{}}}catch{}if(n)return[];let i=e.root.documentElement??e.els[0]??document.documentElement;return[l(this,i,"no `button:active { transition-duration: 0s }` rule on this page")]}}],K=[];function q(e){for(let t of Array.from(e.childNodes))if(t.nodeType===3&&(t.textContent??``).trim().length>0)return!0;return!1}function J(e,t,n){let r=Math.max(e,t,n);return r<40?!1:r-Math.min(e,t,n)>60}function Y(e){return!!(/\b0px\s+0px\s+0px\s+0?\.?5px\b/.test(e)||/\b0px?\s+0px?\s+0px?\s+0?\.?5px\b/.test(e))}function X(e=typeof document<`u`?document:{}){let t=new Map,n=e=>{let n=t.get(e);return n||(n=getComputedStyle(e),t.set(e,n)),n};S=n;let r=Array.from(e.querySelectorAll(`*`)),i=[],a=e.documentElement??null,o=e.body??null;for(let e of r){if(e===a||e===o||e.closest(`#linear-grab-root, #claude-agent-glow-border, [id^="react-scan"]`))continue;let t=u(e);t.width===0&&t.height===0&&e.children.length===0||i.push(e)}let s={root:e,els:i,css:n},c=[];for(let e of[...G,...K])try{c.push(...e.check(s))}catch{}return S=e=>getComputedStyle(e),c}function Z(e){let t=new Map;for(let n of e){let e=t.get(n.ruleId);e?e.push(n):t.set(n.ruleId,[n])}let n=Array.from(t.entries()).sort((e,t)=>{let n=e[1][0].severity===`error`?0:1,r=t[1][0].severity===`error`?0:1;return n===r?t[1].length===e[1].length?e[0].localeCompare(t[0]):t[1].length-e[1].length:n-r}),r=e.length,i=e.filter(e=>e.severity===`error`).length,a=new Set(e.map(e=>e.page).filter(Boolean)).size>1,o=[`# Slop scan — ${i} error${i===1?``:`s`}, ${r-i} warn (${r} total)`,``];for(let[e,t]of n){let n=t[0];o.push(`## ${e} — ${n.severity} ×${t.length} [${n.part}]`),o.push(n.description);for(let e of t){let t=e.component||e.source?` — ${[e.component,e.source].filter(Boolean).join(` @ `)}`:``;o.push(`- ${a&&e.page?`\`${e.page}\` `:``}\`${e.selector}\` — ${e.evidence}${t}`)}o.push(``)}return o.join(`
|
|
2
|
+
`)}function Q(e){return[`Below is a design-system SLOP report from my running app — each finding is a`,"live-DOM violation of the Linear/Protocolbase primitives contract (`Linear","-primitives.md`). Every rule cites its §id. Goal: zero errors. Rules:",``,`1. Fix at the TOKEN / VARIANT level, not the call sites. Most of these come`,` from shared row/button/menu variants — change --row-radius, --selected,`,` .pb-row-action, the Button/Input variant, or the globals.css §42 rules —`,` not one component at a time.`,`2. Cite the primitives §id in your fix (each finding carries it). Radius →`,` §40 vocabulary {8,12,16,full}; active fill → §11 var(--selected); menus →`,` §21 all-or-none icons + one stroke family; uppercase labels → §25;`,` editor flatness → Part 5; scrollers → §0–1 virtualize + truthful fade.`,`3. §42 group (transition-all / paint-prop / too-slow / ease-in / press-`,` instant): scope transitions to what changes, drop box-shadow/width/height`,` from transition lists, keep feedback ≤100ms (duration-75 small controls),`,` use ease-out (cubic-bezier(.2,0,.1,1)), and keep the global`," `button:active{transition-duration:0s}` press-instant rule.",`4. Do NOT touch EXEMPT intentional motion — popover/menu/dialog entry-exit,`,` svg icon micro-motion, and panel width/height transitions are §42-blessed.`,` The scan already excludes them; do not "fix" them.`,`5. 'unverifiable' warnings mean the token collapsed to the anti-pattern this`,` theme — verify the intent in BOTH themes, do not just silence it.`,"6. Findings carry `Component @ file:line` when the fiber source resolved.",` When one file:line repeats across many components it is the nearest-`,` debug-fiber fallback — locate the real code by the selector classes.`,`7. PROOF REQUIRED: after the change I will re-run the scan — it must show 0`,` errors. State what you changed per token/variant; do not claim done blind.`,``,`---`,``,Z(e)].join(`
|
|
3
|
+
`)}function $(){return X(document).map(({el:e,...t})=>t)}async function ee(){let e=X(document),t=window.__REACT_GRAB__;if(t&&(t.getDisplayName||t.getSource)){let n=new Map;for(let t of e){let e=t.el?.deref();if(!e)continue;let r=n.get(e);r?r.push(t):n.set(e,[t])}let r=300;for(let[e,i]of n){if(r--<=0)break;let n=null,a=null;try{n=t.getDisplayName?.(e)??null}catch{}try{let n=await t.getSource?.(e);n?.filePath&&(a=`${n.filePath}${n.lineNumber==null?``:`:${n.lineNumber}`}`)}catch{}if(!(!n&&!a))for(let e of i)e.component=n,e.source=a}}return e.map(({el:e,...t})=>t)}window.__SLOP_SCAN__={version:`0.25.1`,run:$,runAttributed:ee,report:Z,prompt:Q}})();
|