linear-grab 0.24.1 → 0.25.0
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/bin/linear-grab-bridge.mjs +688 -8
- package/bin/slop-scan.global.js +3 -0
- package/dist/index.global.js +37 -37
- package/dist/linear-grab.js +6923 -6824
- package/dist/slop-scan.global.js +3 -0
- package/package.json +2 -2
|
@@ -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.0';
|
|
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);
|
|
@@ -608,8 +626,8 @@ createServer(async (req, res) => {
|
|
|
608
626
|
if (cached.preview) previews[prUrl] = cached.preview;
|
|
609
627
|
return;
|
|
610
628
|
}
|
|
611
|
-
// comments carry the Vercel bot's preview link;
|
|
612
|
-
// has it too
|
|
629
|
+
// comments carry the Vercel bot's preview link; the PR body often
|
|
630
|
+
// has it too (agents embed it per the completion gate).
|
|
613
631
|
const out = await run('gh', ['pr', 'view', prUrl, '--json', 'state,comments,body']);
|
|
614
632
|
try {
|
|
615
633
|
const parsed = JSON.parse(out ?? '');
|
|
@@ -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,663 @@ 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
|
+
{ expression: '__SLOP_SCAN__.run()', returnByValue: true },
|
|
1370
|
+
sessionId,
|
|
1371
|
+
);
|
|
1372
|
+
if (evalRes.exceptionDetails) {
|
|
1373
|
+
scanFailed = evalRes.exceptionDetails.exception?.description ?? 'scan threw';
|
|
1374
|
+
break;
|
|
1375
|
+
}
|
|
1376
|
+
const next = Array.isArray(evalRes.result?.value) ? evalRes.result.value : [];
|
|
1377
|
+
if (findings && next.length === findings.length) {
|
|
1378
|
+
findings = next;
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
findings = next;
|
|
1382
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
1383
|
+
}
|
|
1384
|
+
if (scanFailed) {
|
|
1385
|
+
failedRoutes.push({ route, theme, reason: scanFailed });
|
|
1386
|
+
alog(` ${A.Rd}✗${A.R} ${padRoute(route)} ${padTheme(theme)} ${A.D}${scanFailed.slice(0, 60)}${A.R}`);
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
findings ??= [];
|
|
1390
|
+
|
|
1391
|
+
// Dedupe across themes within this route.
|
|
1392
|
+
for (const f of findings) {
|
|
1393
|
+
const key = `${f.ruleId}|${f.selector}|${f.evidence}`;
|
|
1394
|
+
const existing = routeMap.get(key);
|
|
1395
|
+
if (existing) {
|
|
1396
|
+
if (!existing.themes.includes(theme)) existing.themes.push(theme);
|
|
1397
|
+
} else {
|
|
1398
|
+
routeMap.set(key, { ...f, route, themes: [theme] });
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const errs = findings.filter((f) => f.severity === 'error').length;
|
|
1403
|
+
const warns = findings.filter((f) => f.severity === 'warn').length;
|
|
1404
|
+
alog(
|
|
1405
|
+
` ${A.G}✓${A.R} ${padRoute(route)} ${padTheme(theme)} ` +
|
|
1406
|
+
`${String(errs).padStart(4)} ${A.D}errors${A.R} ${String(warns).padStart(3)} ${A.D}warns${A.R} ` +
|
|
1407
|
+
`${A.D}${Date.now() - started}ms${A.R}`,
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
for (const f of routeMap.values()) allFindings.push(f);
|
|
1411
|
+
}
|
|
1412
|
+
} finally {
|
|
1413
|
+
try {
|
|
1414
|
+
ws?.close();
|
|
1415
|
+
} catch {
|
|
1416
|
+
/* ignore */
|
|
1417
|
+
}
|
|
1418
|
+
process.off('SIGINT', onSigint);
|
|
1419
|
+
cleanup();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// ---- baseline ------------------------------------------------------------
|
|
1423
|
+
const bkey = (f) => `${f.ruleId}|${f.route}|${f.selector}`;
|
|
1424
|
+
const currentKeys = [...new Set(allFindings.map(bkey))];
|
|
1425
|
+
|
|
1426
|
+
bootstrapAuditDir(auditDir);
|
|
1427
|
+
|
|
1428
|
+
if (updateBaseline) {
|
|
1429
|
+
writeFileSync(
|
|
1430
|
+
baselinePath,
|
|
1431
|
+
JSON.stringify({ createdAt: new Date().toISOString(), keys: currentKeys }, null, 2),
|
|
1432
|
+
);
|
|
1433
|
+
alog('');
|
|
1434
|
+
alog(`${A.G}✓${A.R} baseline updated — ${A.B}${currentKeys.length}${A.R} keys recorded`);
|
|
1435
|
+
alog(` ${A.D}${baselinePath}${A.R}`);
|
|
1436
|
+
// Still write the report + ndjson so the artifacts stay in sync.
|
|
1437
|
+
for (const f of allFindings) f.isNew = false;
|
|
1438
|
+
writeReportAndNdjson(allFindings, {
|
|
1439
|
+
url,
|
|
1440
|
+
routes,
|
|
1441
|
+
themes,
|
|
1442
|
+
outPath,
|
|
1443
|
+
ndjsonPath,
|
|
1444
|
+
newCount: 0,
|
|
1445
|
+
});
|
|
1446
|
+
return 0;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
let baselineKeys = null;
|
|
1450
|
+
let hasBaseline = false;
|
|
1451
|
+
try {
|
|
1452
|
+
baselineKeys = new Set(JSON.parse(readFileSync(baselinePath, 'utf8')).keys ?? []);
|
|
1453
|
+
hasBaseline = true;
|
|
1454
|
+
} catch {
|
|
1455
|
+
/* no baseline yet */
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
for (const f of allFindings) f.isNew = hasBaseline ? !baselineKeys.has(bkey(f)) : true;
|
|
1459
|
+
const newFindings = allFindings.filter((f) => f.isNew);
|
|
1460
|
+
|
|
1461
|
+
// ---- write artifacts -----------------------------------------------------
|
|
1462
|
+
const totalErr = allFindings.filter((f) => f.severity === 'error').length;
|
|
1463
|
+
const totalWarn = allFindings.filter((f) => f.severity === 'warn').length;
|
|
1464
|
+
writeReportAndNdjson(allFindings, {
|
|
1465
|
+
url,
|
|
1466
|
+
routes,
|
|
1467
|
+
themes,
|
|
1468
|
+
outPath,
|
|
1469
|
+
ndjsonPath,
|
|
1470
|
+
newCount: newFindings.length,
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
// ---- exit decision -------------------------------------------------------
|
|
1474
|
+
const gate =
|
|
1475
|
+
failOn === 'none'
|
|
1476
|
+
? []
|
|
1477
|
+
: failOn === 'warn'
|
|
1478
|
+
? newFindings.filter((f) => f.severity === 'error' || f.severity === 'warn')
|
|
1479
|
+
: newFindings.filter((f) => f.severity === 'error');
|
|
1480
|
+
const willFail = gate.length > 0;
|
|
1481
|
+
|
|
1482
|
+
alog('');
|
|
1483
|
+
alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
|
|
1484
|
+
alog(
|
|
1485
|
+
` ${A.B}totals${A.R} ${totalErr} errors ${totalWarn} warns ` +
|
|
1486
|
+
`across ${routes.length} route(s) × ${themes.length} theme(s)`,
|
|
1487
|
+
);
|
|
1488
|
+
if (failedRoutes.length) {
|
|
1489
|
+
alog(` ${A.Y}${failedRoutes.length} route-theme sweep(s) failed${A.R} ${A.D}(see ✗ above)${A.R}`);
|
|
1490
|
+
}
|
|
1491
|
+
if (!hasBaseline) {
|
|
1492
|
+
alog(
|
|
1493
|
+
` ${A.Y}no baseline${A.R} — all ${newFindings.length} findings count as NEW. ` +
|
|
1494
|
+
`Run ${A.C}--update-baseline${A.R} to create the ratchet.`,
|
|
1495
|
+
);
|
|
1496
|
+
} else {
|
|
1497
|
+
alog(` ${A.B}new vs baseline${A.R} ${newFindings.length} findings`);
|
|
1498
|
+
}
|
|
1499
|
+
alog(` report ${A.C}${outPath}${A.R}`);
|
|
1500
|
+
if (willFail) {
|
|
1501
|
+
alog(
|
|
1502
|
+
` ${A.Rd}${A.B}FAIL${A.R} — ${gate.length} new ${failOn === 'warn' ? 'error/warn' : 'error'}-severity finding(s) (fail-on=${failOn})`,
|
|
1503
|
+
);
|
|
1504
|
+
} else {
|
|
1505
|
+
alog(` ${A.G}${A.B}PASS${A.R} — 0 new findings at/above fail-on=${failOn}`);
|
|
1506
|
+
}
|
|
1507
|
+
alog(`${A.D}──────────────────────────────────────────────────${A.R}`);
|
|
1508
|
+
return willFail ? 1 : 0;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const padRoute = (r) => r.slice(0, 24).padEnd(24);
|
|
1512
|
+
const padTheme = (t) => t.padEnd(5);
|
|
1513
|
+
|
|
1514
|
+
/** Bootstrap the .lineargrab dir + self-gitignore (mirrors /scan/events). */
|
|
1515
|
+
function bootstrapAuditDir(dir) {
|
|
1516
|
+
try {
|
|
1517
|
+
mkdirSync(dir, { recursive: true });
|
|
1518
|
+
writeFileSync(join(dir, '.gitignore'), '*\n', { flag: 'wx' });
|
|
1519
|
+
} catch {
|
|
1520
|
+
/* exists */
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/** Append audit findings to scan.ndjson with the same rotation as telemetry. */
|
|
1525
|
+
function appendNdjson(file, findings) {
|
|
1526
|
+
const lines =
|
|
1527
|
+
findings
|
|
1528
|
+
.slice(0, 5000)
|
|
1529
|
+
.map((f) =>
|
|
1530
|
+
JSON.stringify({
|
|
1531
|
+
kind: 'slop-scan',
|
|
1532
|
+
mode: 'headless',
|
|
1533
|
+
route: f.route,
|
|
1534
|
+
themes: f.themes,
|
|
1535
|
+
at: Date.now(),
|
|
1536
|
+
ruleId: f.ruleId,
|
|
1537
|
+
part: f.part,
|
|
1538
|
+
severity: f.severity,
|
|
1539
|
+
description: f.description,
|
|
1540
|
+
selector: f.selector,
|
|
1541
|
+
evidence: f.evidence,
|
|
1542
|
+
isNew: !!f.isNew,
|
|
1543
|
+
}),
|
|
1544
|
+
)
|
|
1545
|
+
.join('\n') + '\n';
|
|
1546
|
+
try {
|
|
1547
|
+
appendFileSync(file, lines);
|
|
1548
|
+
const size = statSync(file).size;
|
|
1549
|
+
if (size > 2_000_000) {
|
|
1550
|
+
const keep = readFileSync(file, 'utf8');
|
|
1551
|
+
writeFileSync(file, keep.slice(Math.floor(keep.length / 2)).replace(/^[^\n]*\n/, ''));
|
|
1552
|
+
}
|
|
1553
|
+
} catch {
|
|
1554
|
+
/* disk issues — never fail the audit on telemetry */
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/** Write the markdown report + append NDJSON events. */
|
|
1559
|
+
function writeReportAndNdjson(findings, { url, routes, themes, outPath, ndjsonPath, newCount }) {
|
|
1560
|
+
const totalErr = findings.filter((f) => f.severity === 'error').length;
|
|
1561
|
+
const totalWarn = findings.filter((f) => f.severity === 'warn').length;
|
|
1562
|
+
|
|
1563
|
+
const lines = [
|
|
1564
|
+
'# Design slop-scan report',
|
|
1565
|
+
'',
|
|
1566
|
+
`- **url**: ${url}`,
|
|
1567
|
+
`- **routes**: ${routes.length} (${routes.join(', ')})`,
|
|
1568
|
+
`- **themes**: ${themes.join(', ')}`,
|
|
1569
|
+
`- **totals**: ${totalErr} errors / ${totalWarn} warns`,
|
|
1570
|
+
`- **new vs baseline**: ${newCount}`,
|
|
1571
|
+
`- **generated**: ${new Date().toISOString()}`,
|
|
1572
|
+
'',
|
|
1573
|
+
];
|
|
1574
|
+
|
|
1575
|
+
// Group by route → rule. Errors first, then by count desc.
|
|
1576
|
+
const byRoute = new Map();
|
|
1577
|
+
for (const f of findings) {
|
|
1578
|
+
if (!byRoute.has(f.route)) byRoute.set(f.route, []);
|
|
1579
|
+
byRoute.get(f.route).push(f);
|
|
1580
|
+
}
|
|
1581
|
+
for (const route of routes) {
|
|
1582
|
+
const rf = byRoute.get(route);
|
|
1583
|
+
if (!rf || !rf.length) continue;
|
|
1584
|
+
lines.push(`## ${route}`, '');
|
|
1585
|
+
const byRule = new Map();
|
|
1586
|
+
for (const f of rf) {
|
|
1587
|
+
if (!byRule.has(f.ruleId)) byRule.set(f.ruleId, []);
|
|
1588
|
+
byRule.get(f.ruleId).push(f);
|
|
1589
|
+
}
|
|
1590
|
+
const rules = [...byRule.entries()].sort((a, b) => {
|
|
1591
|
+
const sev = (list) => (list.some((x) => x.severity === 'error') ? 0 : 1);
|
|
1592
|
+
return sev(a[1]) - sev(b[1]) || b[1].length - a[1].length;
|
|
1593
|
+
});
|
|
1594
|
+
for (const [ruleId, list] of rules) {
|
|
1595
|
+
const head = list[0];
|
|
1596
|
+
const sevBadge = head.severity === 'error' ? 'error' : 'warn';
|
|
1597
|
+
lines.push(
|
|
1598
|
+
`### ${ruleId} \`(${sevBadge})\`${head.part ? ` §${head.part}` : ''}`,
|
|
1599
|
+
head.description ? `${head.description}` : '',
|
|
1600
|
+
'',
|
|
1601
|
+
);
|
|
1602
|
+
for (const f of list) {
|
|
1603
|
+
lines.push(
|
|
1604
|
+
`- \`${f.selector}\` — ${f.evidence} [${f.themes.join('/')}]${f.isNew ? ' **NEW**' : ''}`,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
lines.push('');
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
try {
|
|
1612
|
+
mkdirSync(join(outPath, '..'), { recursive: true });
|
|
1613
|
+
} catch {
|
|
1614
|
+
/* ignore */
|
|
1615
|
+
}
|
|
1616
|
+
writeFileSync(outPath, lines.join('\n'));
|
|
1617
|
+
appendNdjson(ndjsonPath, findings);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// Dispatch audit LAST — every const/fn above is now initialized, so no TDZ.
|
|
1621
|
+
if (AUDIT_MODE) {
|
|
1622
|
+
const code = await runAudit();
|
|
1623
|
+
process.exit(code);
|
|
1624
|
+
}
|