kryptheon 0.1.26 → 0.1.29

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/README.md CHANGED
@@ -6,6 +6,30 @@ plain English what broke.
6
6
  Runs entirely on your machine. No account, no API key, nothing leaves your
7
7
  computer. It is a wrapper around [Playwright](https://playwright.dev).
8
8
 
9
+ ## The one command
10
+
11
+ ```
12
+ npx kryptheon@latest dashboard
13
+ ```
14
+
15
+ Run it in your project's folder. It opens a page in your browser, and
16
+ everything else is a button on that page:
17
+
18
+ | | how often |
19
+ | --- | --- |
20
+ | Read my code | after every change |
21
+ | Record a flow | one time - again only when that flow changes |
22
+ | Replay | after every change |
23
+ | Connect your database | one time |
24
+ | Check my database | after every change |
25
+ | Set up the nightly check | one time |
26
+ | Re-check a finding | after every change |
27
+
28
+ Every button shows the terminal command that does the same thing, in small
29
+ print, for when the page cannot be used. The database connection string is
30
+ pasted into the page, used for one run, and never saved, logged or shown
31
+ again; reload the page and it is gone.
32
+
9
33
  ## Only have a web address?
10
34
 
11
35
  Built your app in Lovable, Bolt or v0, and there is no project folder on your
@@ -168,15 +192,17 @@ npx kryptheon
168
192
  ```
169
193
 
170
194
  In a terminal, with no command, this opens a page on your own machine
171
- (`http://127.0.0.1:4789/`) for this project: what is broken, what needs a
172
- look, every run, recent changes, and switches for each check. `Run now`
173
- replays your recordings or reads your code from the page. It is only
174
- reachable from this machine; press Ctrl+C to stop it. From a script, use
175
- `npx kryptheon dashboard`.
195
+ (`http://127.0.0.1:4789/`) for this project, in your browser by itself (the
196
+ address is printed too): what is broken, what needs a look, every run, recent
197
+ changes, and switches for each check. Every check runs from a button, one at a
198
+ time, with its progress shown on the page as it goes. It is only reachable
199
+ from this machine; press Ctrl+C to stop it. From a script, use
200
+ `npx kryptheon dashboard`; set `KRYPTHEON_NO_OPEN=1` to keep the browser shut.
176
201
 
177
202
  It opens on the one thing to do next, and under it "Start here": read your
178
- code, record your main flows, replay them, check your database - in that
179
- order, each marked done only once it has actually run.
203
+ code, record your main flows, replay them, connect and check your database,
204
+ and set up the nightly check - each marked done only once it has actually run,
205
+ and each labelled "one time" or "after every change".
180
206
 
181
207
  Checks that are not built yet are listed as "not available" - never as
182
208
  passed.
package/bin/kryptheon.js CHANGED
@@ -80,6 +80,16 @@ function usage() {
80
80
  console.log('');
81
81
  console.log(' kryptheon - record and check your app');
82
82
  console.log('');
83
+ console.log(' The one command to remember:');
84
+ console.log('');
85
+ console.log(' npx kryptheon@latest dashboard');
86
+ console.log('');
87
+ console.log(' It opens a page in your browser, and everything else is a button there:');
88
+ console.log(' read your code, record and replay your flows, check your database, set up');
89
+ console.log(' the nightly check, and re-check a fix.');
90
+ console.log('');
91
+ console.log(' The same, for scripts and AI tools:');
92
+ console.log('');
83
93
  console.log(' npx kryptheon record <url> open your app and record what you do as a test');
84
94
  console.log(' npx kryptheon check run every recorded test and report in plain language');
85
95
  console.log(' npx kryptheon accept <name> agree that one test\'s new result is the correct one');
@@ -88,7 +98,7 @@ function usage() {
88
98
  console.log(' npx kryptheon verify after a change: what changed, what broke, what got fixed');
89
99
  console.log(' npx kryptheon recheck [id] prove a fix: run the check again, and say FIXED or not');
90
100
  console.log(' npx kryptheon code read your frontend code for text inserted as HTML');
91
- console.log(' npx kryptheon dashboard open this project\'s dashboard (also: npx kryptheon, in a terminal)');
101
+ console.log(' npx kryptheon dashboard the page above (also: npx kryptheon, in a terminal)');
92
102
  console.log('');
93
103
  console.log(' add --quiet to check for one line when everything passes');
94
104
  console.log(' npx kryptheon --version print the version you have installed');
@@ -2313,29 +2323,194 @@ function recheckCommand(id) {
2313
2323
 
2314
2324
  const DASHBOARD_PORT = 4789;
2315
2325
 
2316
- function runFromDashboard(id, opened) {
2326
+ /**
2327
+ * Runs a command and hands every line it prints to `say`, as it prints it -
2328
+ * so the page can show progress instead of a spinner. Nothing is read from
2329
+ * the keyboard: a command started from the page has nobody at one.
2330
+ */
2331
+ function spawnLines(file, args, options, say) {
2332
+ return new Promise((resolve) => {
2333
+ let child;
2334
+ try {
2335
+ child = spawn(file, args, Object.assign({ cwd: USER_DIR, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }, options || {}));
2336
+ } catch (err) {
2337
+ say('Could not start it: ' + err.message);
2338
+ resolve({ code: null, why: err.message });
2339
+ return;
2340
+ }
2341
+ const pending = { out: '', err: '' };
2342
+ const feed = (which) => (chunk) => {
2343
+ pending[which] += chunk.toString();
2344
+ const parts = pending[which].split(/\r?\n/);
2345
+ pending[which] = parts.pop();
2346
+ for (const line of parts) say(line);
2347
+ };
2348
+ child.stdout.on('data', feed('out'));
2349
+ child.stderr.on('data', feed('err'));
2350
+ child.on('error', (err) => {
2351
+ say('Could not start it: ' + err.message);
2352
+ resolve({ code: null, why: err.message });
2353
+ });
2354
+ child.on('close', (code) => {
2355
+ if (pending.out) say(pending.out);
2356
+ if (pending.err) say(pending.err);
2357
+ resolve({ code: code });
2358
+ });
2359
+ });
2360
+ }
2361
+
2362
+ /** This same command, as a child, with these arguments. */
2363
+ function kryptheonChild(args, say) {
2364
+ return spawnLines(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js')].concat(args), {}, say);
2365
+ }
2366
+
2367
+ /**
2368
+ * How kryptheon-night is started from the page: the newest one from npm, the
2369
+ * same as `npx kryptheon-night` in a terminal. KRYPTHEON_NIGHT_BIN points at
2370
+ * a copy on disk instead, for the checks. The arguments are only ever this
2371
+ * file's own fixed words - the connection string travels in the environment,
2372
+ * never on a command line, where other programs on the machine could read it.
2373
+ */
2374
+ function nightChild(words, env, say) {
2375
+ const own = process.env.KRYPTHEON_NIGHT_BIN;
2376
+ const options = { env: env };
2377
+ if (own) return spawnLines(process.execPath, [own].concat(words), options, say);
2378
+ const npx = 'npx --yes kryptheon-night@latest ' + words.join(' ');
2379
+ if (process.platform === 'win32') {
2380
+ return spawnLines(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', npx], Object.assign({ windowsVerbatimArguments: true }, options), say);
2381
+ }
2382
+ return spawnLines('npx', ['--yes', 'kryptheon-night@latest'].concat(words), options, say);
2383
+ }
2384
+
2385
+ /** The environment a child is given: this one, never with a connection string of its own. */
2386
+ function childEnv(extra) {
2387
+ const env = Object.assign({}, process.env, extra || {});
2388
+ if (!extra || !('KN_DATABASE_URL' in extra)) delete env.KN_DATABASE_URL;
2389
+ return env;
2390
+ }
2391
+
2392
+ // What each database button asks kryptheon-night to do. `--yes` only where
2393
+ // the page has already shown the same consent screen and been told yes.
2394
+ const NIGHT_WORDS = {
2395
+ scan: ['--yes'],
2396
+ install: ['install', '--yes'],
2397
+ night: ['night'],
2398
+ uninstall: ['uninstall'],
2399
+ };
2400
+
2401
+ const NIGHT_SAYS = { 0: 'nothing got through', 1: 'something got through - see Findings', 2: 'it could not check' };
2402
+
2403
+ function runFromDashboard(id, opened, say) {
2317
2404
  if (id === 'frontend') {
2405
+ say('Reading the code in ' + USER_DIR + ' ...');
2318
2406
  const result = readCodeAndSave(opened);
2319
- return result.ran ? { ok: true } : { ok: false, why: result.why };
2407
+ if (!result.ran) {
2408
+ say(result.why);
2409
+ return { ok: false, why: result.why };
2410
+ }
2411
+ const n = (result.findings || []).length;
2412
+ say('Read ' + (result.filesRead || 0) + ((result.filesRead || 0) === 1 ? ' file. ' : ' files. ') + (n ? n + ' thing' + (n === 1 ? '' : 's') + ' to look at.' : 'Nothing found.'));
2413
+ return { ok: true, summary: n ? n + ' thing' + (n === 1 ? '' : 's') + ' to look at' : 'nothing found' };
2320
2414
  }
2321
2415
  if (id === 'regression') {
2322
2416
  // The same command a person types, as a separate process, so the page
2323
2417
  // stays answerable while a browser replays the flows.
2324
- return new Promise((resolve) => {
2325
- const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2326
- cwd: USER_DIR,
2327
- stdio: 'ignore',
2328
- env: process.env,
2329
- });
2330
- child.on('error', (err) => resolve({ ok: false, why: err.message }));
2418
+ return kryptheonChild(['check'], say).then(({ code }) =>
2331
2419
  // A run that found broken flows is a run that finished; its results are
2332
2420
  // in the history the page reads next.
2333
- child.on('close', (code) => resolve(code === 0 || code === 1 ? { ok: true, exit: code } : { ok: false, why: 'the run stopped with exit ' + code }));
2334
- });
2421
+ code === 0 ? { ok: true, summary: 'every flow held' }
2422
+ : code === 1 ? { ok: true, summary: 'a flow broke - see Findings' }
2423
+ : { ok: false, why: 'the replay stopped with exit ' + code });
2335
2424
  }
2336
2425
  return { ok: false, why: '"' + id + '" cannot be run from the dashboard' };
2337
2426
  }
2338
2427
 
2428
+ /** Re-checks one finding the way `npx kryptheon recheck <id>` does, and reads back its verdict. */
2429
+ function recheckFromDashboard(id, opened, say) {
2430
+ // Only a verdict written by this re-check counts: an older one for the
2431
+ // same finding, read after a child that failed early, would be a stale
2432
+ // answer shown as a fresh one.
2433
+ const startedAt = new Date().toISOString();
2434
+ return kryptheonChild(['recheck', id], say).then(() => {
2435
+ let last = null;
2436
+ try {
2437
+ const lines = fs.readFileSync(opened.fixes, 'utf8').split(String.fromCharCode(10)).filter(Boolean);
2438
+ for (let i = lines.length - 1; i >= 0 && !last; i--) {
2439
+ const a = JSON.parse(lines[i]);
2440
+ if (a.id === id && a.at >= startedAt) last = a;
2441
+ }
2442
+ } catch (err) {
2443
+ last = null;
2444
+ }
2445
+ if (!last) return { ok: false, why: 'the re-check did not record a verdict' };
2446
+ return Object.assign({ ok: true, summary: last.verdict }, last);
2447
+ });
2448
+ }
2449
+
2450
+ /** Records one flow the way `npx kryptheon record <address>` does, and names what it saved. */
2451
+ function recordFromDashboard(address, say) {
2452
+ const before = listSpecFiles();
2453
+ say('A browser window opens on ' + address + '. Use your app, then close that window to save.');
2454
+ return kryptheonChild(['record', address], say).then(({ code }) => {
2455
+ const added = listSpecFiles().filter((name) => !before.includes(name));
2456
+ if (!added.length) return { ok: false, exit: code, why: 'no recording was saved' };
2457
+ return { ok: true, exit: code, recordings: added, summary: 'saved ' + added.join(', ') };
2458
+ });
2459
+ }
2460
+
2461
+ /** One database action, through kryptheon-night, with the string in its environment only. */
2462
+ function databaseFromDashboard(action, connection, say) {
2463
+ return nightChild(NIGHT_WORDS[action], childEnv({ KN_DATABASE_URL: connection }), say).then(({ code }) => {
2464
+ if (code === null || code === undefined) return { ok: false, why: 'kryptheon-night could not be started' };
2465
+ if (action === 'scan') return { ok: code !== 2, exit: code, summary: NIGHT_SAYS[code] || 'it ended with exit ' + code };
2466
+ return code === 2 && action !== 'night'
2467
+ ? { ok: false, exit: code, why: 'it could not do that - the lines above say why' }
2468
+ : code === 2 ? { ok: false, exit: code, why: 'nothing to read back - the lines above say why' }
2469
+ : { ok: true, exit: code, summary: 'done' };
2470
+ });
2471
+ }
2472
+
2473
+ /** What kryptheon-night itself would say - help, consent, warnings - without connecting. */
2474
+ function nightWords(connection) {
2475
+ const said = [];
2476
+ return nightChild(['words'], childEnv(connection ? { KN_DATABASE_URL: connection } : {}), (line) => said.push(line))
2477
+ .then(({ code }) => {
2478
+ for (const line of said) {
2479
+ if (!/^\s*\{/.test(line)) continue;
2480
+ try {
2481
+ return JSON.parse(line);
2482
+ } catch (err) {
2483
+ /* not the JSON line */
2484
+ }
2485
+ }
2486
+ throw new Error('kryptheon-night did not answer (exit ' + code + ')');
2487
+ });
2488
+ }
2489
+
2490
+ /**
2491
+ * Opens the page in the person's own browser. Not when KRYPTHEON_NO_OPEN or
2492
+ * CI is set - a script or a check has nobody to show a page to - and never
2493
+ * fatal: the address is printed either way.
2494
+ */
2495
+ function openInBrowser(url) {
2496
+ // KRYPTHEON_BROWSER names the program to open it with instead (a .js file
2497
+ // is run with Node) - for a browser of one's own choosing, and the checks.
2498
+ const chosen = process.env.KRYPTHEON_BROWSER;
2499
+ if (process.env.KRYPTHEON_NO_OPEN || (process.env.CI && !chosen)) return false;
2500
+ const how = chosen ? (/\.[cm]?js$/i.test(chosen) ? [process.execPath, [chosen, url]] : [chosen, [url]])
2501
+ : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
2502
+ : process.platform === 'darwin' ? ['open', [url]]
2503
+ : ['xdg-open', [url]];
2504
+ try {
2505
+ const child = spawn(how[0], how[1], { stdio: 'ignore', detached: true, windowsHide: true });
2506
+ child.on('error', () => {});
2507
+ child.unref();
2508
+ return true;
2509
+ } catch (err) {
2510
+ return false;
2511
+ }
2512
+ }
2513
+
2339
2514
  async function dashboardCommand() {
2340
2515
  const here = project.inspectProject(USER_DIR);
2341
2516
  if (!here.ok) {
@@ -2346,34 +2521,11 @@ async function dashboardCommand() {
2346
2521
  if (!opened) return 1;
2347
2522
  const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
2348
2523
  const app = dash.createServer(USER_DIR, {
2349
- run: (id) => runFromDashboard(id, opened),
2350
- // The same `recheck` a person types, in its own process, so the page stays
2351
- // answerable while a flow is replayed. Its verdict is read from the store.
2352
- recheck: (id) => new Promise((resolve) => {
2353
- // Only a verdict written by this re-check counts: an older one for the
2354
- // same finding, read after a child that failed early, would be a stale
2355
- // answer shown as a fresh one.
2356
- const startedAt = new Date().toISOString();
2357
- const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'recheck', id], {
2358
- cwd: USER_DIR,
2359
- stdio: 'ignore',
2360
- env: process.env,
2361
- });
2362
- child.on('error', (err) => resolve({ ok: false, why: err.message }));
2363
- child.on('close', () => {
2364
- let last = null;
2365
- try {
2366
- const lines = fs.readFileSync(opened.fixes, 'utf8').split(String.fromCharCode(10)).filter(Boolean);
2367
- for (let i = lines.length - 1; i >= 0 && !last; i--) {
2368
- const a = JSON.parse(lines[i]);
2369
- if (a.id === id && a.at >= startedAt) last = a;
2370
- }
2371
- } catch (err) {
2372
- last = null;
2373
- }
2374
- resolve(last || { ok: false, why: 'the re-check did not record a verdict' });
2375
- });
2376
- }),
2524
+ run: (id, say) => runFromDashboard(id, opened, say),
2525
+ recheck: (id, say) => recheckFromDashboard(id, opened, say),
2526
+ record: (address, say) => recordFromDashboard(address, say),
2527
+ db: (action, connection, say) => databaseFromDashboard(action, connection, say),
2528
+ words: (connection) => nightWords(connection),
2377
2529
  });
2378
2530
  let port;
2379
2531
  try {
@@ -2382,13 +2534,17 @@ async function dashboardCommand() {
2382
2534
  // Taken - by another project's dashboard, most likely. Any free port will do.
2383
2535
  port = await app.listen(0);
2384
2536
  }
2537
+ const address = 'http://127.0.0.1:' + port + '/';
2538
+ const shown = openInBrowser(address);
2385
2539
  console.log('');
2386
2540
  console.log(' Kryptheon dashboard for ' + path.basename(USER_DIR) + ':');
2387
2541
  console.log('');
2388
- console.log(' http://127.0.0.1:' + port + '/');
2542
+ console.log(' ' + address);
2389
2543
  console.log('');
2390
- console.log(' Open that address in your browser. It is only reachable from this');
2391
- console.log(' machine. Press Ctrl+C here to stop it.');
2544
+ console.log(shown ? ' It is opening in your browser. If it does not, open that address.'
2545
+ : ' Open that address in your browser.');
2546
+ console.log(' It is only reachable from this machine. Everything else is a button');
2547
+ console.log(' on that page. Press Ctrl+C here to stop it.');
2392
2548
  console.log('');
2393
2549
 
2394
2550
  // While the page is open, Kryptheon notices changes by itself: once a change
@@ -2503,8 +2659,22 @@ function accept(name) {
2503
2659
 
2504
2660
  console.error('');
2505
2661
  if (result.reason === 'nothing-pending') {
2506
- console.error(' "' + titleOf(key) + '" has no new result waiting.');
2507
- console.error(' Nothing to accept - it last matched its saved result.');
2662
+ // Said from the last run of this test, not assumed: a run that broke on
2663
+ // a step is not one that matched (see baselines.nothingToAcceptLines).
2664
+ const last = baselines.lastRunOf(store.pathsFor(USER_DIR).history, key);
2665
+ baselines.nothingToAcceptLines(titleOf(key), last).forEach((paragraph, i) => {
2666
+ if (i) console.error('');
2667
+ let current = '';
2668
+ for (const word of paragraph.split(' ')) {
2669
+ if ((current + ' ' + word).trim().length > 72) {
2670
+ console.error(' ' + current.trim());
2671
+ current = word;
2672
+ } else {
2673
+ current = (current + ' ' + word).trim();
2674
+ }
2675
+ }
2676
+ if (current) console.error(' ' + current);
2677
+ });
2508
2678
  } else if (result.reason === 'no-entry') {
2509
2679
  console.error(' "' + titleOf(key) + '" has no saved result yet.');
2510
2680
  } else {
@@ -166,6 +166,60 @@ function pruneLines(dropped) {
166
166
  return lines;
167
167
  }
168
168
 
169
+ /**
170
+ * The last run of one recorded test, from the run history: whether it
171
+ * passed, and where it broke if it did not. Null when it has never run.
172
+ */
173
+ function lastRunOf(historyFile, key) {
174
+ let lines = [];
175
+ try {
176
+ lines = fs.readFileSync(historyFile, 'utf8').split('\n').filter(Boolean);
177
+ } catch (e) {
178
+ return null;
179
+ }
180
+ const spec = specOf(key);
181
+ const title = titleOf(key);
182
+ for (let i = lines.length - 1; i >= 0; i--) {
183
+ let entry;
184
+ try {
185
+ entry = JSON.parse(lines[i]);
186
+ } catch (e) {
187
+ continue;
188
+ }
189
+ const test = (entry.tests || []).find((t) => t && t.title === title && specOf(t.specFile) === spec);
190
+ if (test) return { status: test.status, at: entry.runAt, failure: test.failure || null };
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * What accept says when there is nothing it can take, from the last run.
197
+ *
198
+ * Found on a blind test (HelixOps): a step was broken on purpose, and accept
199
+ * answered "it last matched its saved result" - the opposite of what had
200
+ * happened. accept only takes a run that went all the way through and ended
201
+ * on a different page; a step that no longer matches is a changed flow or a
202
+ * broken app, and neither is accepted.
203
+ */
204
+ function nothingToAcceptLines(name, last) {
205
+ if (last && last.status === 'failed') {
206
+ const failure = last.failure || {};
207
+ const where = failure.line ? ' at line ' + failure.line + (failure.file ? ' of ' + specOf(failure.file) : '') : '';
208
+ const what = failure.plainLanguage ? ' (' + String(failure.plainLanguage).replace(/\.$/, '') + ')' : '';
209
+ return [
210
+ '"' + name + '" has nothing to accept: its last run broke' + where + ', before the end' + what + '.',
211
+ 'accept only covers a run that went all the way through and finished on a different page - ' +
212
+ 'a new address or a new title.',
213
+ 'A step that no longer matches cannot be accepted. If the flow changed on purpose, record it ' +
214
+ 'again (npx kryptheon record). If it did not, the app broke, and npx kryptheon check says what to fix.',
215
+ ];
216
+ }
217
+ if (last && last.status === 'passed') {
218
+ return ['"' + name + '" has no new result waiting.', 'Nothing to accept - its last run matched its saved result.'];
219
+ }
220
+ return ['"' + name + '" has no new result waiting.', 'Nothing to accept - no run has finished on a different page.'];
221
+ }
222
+
169
223
  module.exports = {
170
224
  specOf: specOf,
171
225
  titleOf: titleOf,
@@ -173,4 +227,6 @@ module.exports = {
173
227
  classifyBaselines: classifyBaselines,
174
228
  pruneBaselines: pruneBaselines,
175
229
  pruneLines: pruneLines,
230
+ lastRunOf: lastRunOf,
231
+ nothingToAcceptLines: nothingToAcceptLines,
176
232
  };
package/kryptheon-code.js CHANGED
@@ -412,7 +412,7 @@ function riskyParts(node, scope, code, aliases, out, depth) {
412
412
  }
413
413
  }
414
414
  const text = code.slice(node.start, node.end).replace(/\s+/g, ' ').slice(0, 80);
415
- if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin });
415
+ if (!out.some((p) => p.text === text)) out.push({ text: text, origin: origin, line: node.loc ? node.loc.start.line : null });
416
416
  }
417
417
 
418
418
  function rootIdentifier(node) {
@@ -684,7 +684,89 @@ function checkedFirst(p, value, code) {
684
684
  return false;
685
685
  }
686
686
 
687
- function sinksIn(parser, code, file, lineOffset) {
687
+ /**
688
+ * The helper a value is the result of: a call to a function this file
689
+ * declares, or one it imports from a file of the project. Its returns, each
690
+ * with its scope and its file's code, and the line where the first of them
691
+ * that is not plain text is built. Null for anything else - an import from a
692
+ * package, a default export, a function that cannot be found.
693
+ */
694
+ function helperOf(value, scope, code, file, lineOffset, resolver) {
695
+ if (!value || !/CallExpression$/.test(value.type) || !value.callee || value.callee.type !== 'Identifier' || !scope) return null;
696
+ const name = value.callee.name;
697
+ let fnPath = localFunction(name, scope);
698
+ let helper = { code: code, file: null, lineOffset: lineOffset };
699
+ if (!fnPath) {
700
+ const binding = scope.getBinding(name);
701
+ const spec = binding && binding.kind === 'module' && binding.path && binding.path.node;
702
+ if (!spec || spec.type !== 'ImportSpecifier' || !resolver) return null;
703
+ const from = binding.path.parentPath && binding.path.parentPath.node.source && binding.path.parentPath.node.source.value;
704
+ const imported = spec.imported && (spec.imported.name || spec.imported.value);
705
+ const found = from && imported ? resolver(file, from, imported) : null;
706
+ if (!found) return null;
707
+ fnPath = found.fnPath;
708
+ helper = { code: found.code, file: found.file, lineOffset: 1 };
709
+ }
710
+ const returns = returnsOf(fnPath);
711
+ if (!returns.length) return null;
712
+ const risky = returns.find((r) => classify(r.node, r.scope, helper.code, 0) !== 'safe') || returns[0];
713
+ return {
714
+ fn: name,
715
+ file: helper.file,
716
+ line: (risky.node.loc ? risky.node.loc.start.line : 1) + helper.lineOffset - 1,
717
+ code: helper.code,
718
+ lineOffset: helper.lineOffset,
719
+ returns: returns,
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Finds a function another file of the project exports, for helperOf. Parsed
725
+ * once a file; its functions are handed to the map() reader each time, since
726
+ * that table is rebuilt for every file read.
727
+ */
728
+ function importResolver(parser, root) {
729
+ const parsed = new Map();
730
+ const EXTS = ['', '.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '/index.js', '/index.ts'];
731
+ return (fromFile, source, name) => {
732
+ if (!/^\.\.?\//.test(source)) return null;
733
+ const base = path.resolve(path.dirname(fromFile), source);
734
+ const full = EXTS.map((ext) => base + ext).find((candidate) => {
735
+ try {
736
+ return fs.statSync(candidate).isFile() && CODE_EXT.test(candidate);
737
+ } catch (err) {
738
+ return false;
739
+ }
740
+ });
741
+ if (!full) return null;
742
+ if (!parsed.has(full)) {
743
+ let entry = null;
744
+ try {
745
+ if (fs.statSync(full).size <= MAX_BYTES) {
746
+ const code = fs.readFileSync(full, 'utf8');
747
+ const ast = parser.babelParse(code, full, true);
748
+ const fns = [];
749
+ let program = null;
750
+ parser.traverse(ast, {
751
+ Program(p) { program = p.scope; },
752
+ Function(p) { fns.push(p); },
753
+ });
754
+ entry = { code: code, program: program, fns: fns, file: path.relative(root, full).split(path.sep).join('/') };
755
+ }
756
+ } catch (err) {
757
+ entry = null;
758
+ }
759
+ parsed.set(full, entry);
760
+ }
761
+ const entry = parsed.get(full);
762
+ if (!entry || !entry.program) return null;
763
+ for (const p of entry.fns) FN_PATHS.set(p.node, p);
764
+ const fnPath = localFunction(name, entry.program);
765
+ return fnPath ? { fnPath: fnPath, code: entry.code, file: entry.file } : null;
766
+ };
767
+ }
768
+
769
+ function sinksIn(parser, code, file, lineOffset, resolver) {
688
770
  const found = [];
689
771
  const ast = parser.babelParse(code, file, true);
690
772
  const lineOf = (node) => (node.loc ? node.loc.start.line : 1) + lineOffset - 1;
@@ -704,6 +786,25 @@ function sinksIn(parser, code, file, lineOffset) {
704
786
  viaHelper.push({ node: node, sink: sink, value: value, scope: scope, param: param });
705
787
  return;
706
788
  }
789
+ // The HTML built by a helper - root.innerHTML = renderBoard(rows) - is
790
+ // judged by what the helper returns, in its own file, and the place it
791
+ // puts a value in is named. Found on a blind test (HelixOps): appShell(),
792
+ // a fixed string in ui.js, was reported as unknown, and renderBoard() was
793
+ // reported at its call with no word of ui.js:13, where the title goes in.
794
+ const built = helperOf(value, scope, code, file, lineOffset, resolver);
795
+ if (built) {
796
+ const origin = worst(built.returns.map((r) => classify(r.node, r.scope, built.code, 0)));
797
+ if (origin === 'safe') return;
798
+ const parts = [];
799
+ for (const r of built.returns) riskyParts(r.node, r.scope, built.code, undefined, parts, 0);
800
+ // The line of the first value that goes in, not of the return around it.
801
+ const first = parts.find((p) => p.line);
802
+ found.push({
803
+ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: parts,
804
+ helper: { fn: built.fn, file: built.file, line: first ? first.line + built.lineOffset - 1 : built.line },
805
+ });
806
+ return;
807
+ }
707
808
  const origin = classify(value, scope, code, 0);
708
809
  if (origin === 'safe') return;
709
810
  found.push({ line: lineOf(node), sink: sink, expression: textOf(value), origin: origin, parts: partsOf(value, scope) });
@@ -834,6 +935,7 @@ function scanProject(root, options) {
834
935
  }
835
936
  }
836
937
  const loadedBy = keys.scriptsLoadedBy(pages);
938
+ const resolver = importResolver(parser, root);
837
939
  for (const full of files) {
838
940
  let size = 0;
839
941
  try {
@@ -852,7 +954,7 @@ function scanProject(root, options) {
852
954
  const pieces = PAGE_EXT.test(full) ? scriptsIn(source) : [{ code: source, line: 1 }];
853
955
  for (const piece of pieces) {
854
956
  try {
855
- for (const f of sinksIn(parser, piece.code, full, piece.line)) findings.push(Object.assign({ file: rel }, f));
957
+ for (const f of sinksIn(parser, piece.code, full, piece.line, resolver)) findings.push(Object.assign({ file: rel }, f));
856
958
  } catch (err) {
857
959
  unreadable.push({ file: rel + (piece.line > 1 ? ':' + piece.line : ''), why: 'could not be parsed: ' + String(err.message).split('\n')[0].slice(0, 120) });
858
960
  }
@@ -931,9 +1033,14 @@ function describe(f) {
931
1033
  if (f.kind === 'redirect') return describeRedirect(f);
932
1034
  const severity = f.origin === 'unknown' ? 'MEDIUM' : 'HIGH';
933
1035
  const how = f.sink === 'v-html' ? 'v-html' : f.sink;
1036
+ // Where the helper builds it: its own file when it is another one.
1037
+ const builtAt = f.helper ? (f.helper.file ? f.helper.file + ':' : 'line ') + f.helper.line : '';
934
1038
  const where = f.via
935
1039
  ? 'In ' + f.file + ' at line ' + f.line + ', this value is passed to ' + f.via.fn + '(), which puts ' +
936
1040
  'what it is given into the page as HTML with ' + how + ' at line ' + f.via.line + ':'
1041
+ : f.helper
1042
+ ? 'In ' + f.file + ' at line ' + f.line + ', the HTML that ' + f.helper.fn + '() builds - at ' + builtAt +
1043
+ ' - is put into the page with ' + how + ':'
937
1044
  : 'In ' + f.file + ' at line ' + f.line + ', this value is put into the page as HTML with ' + how + ':';
938
1045
  // The fix for a helper is different, and getting it wrong breaks the page:
939
1046
  // switching the helper to textContent makes every other call - the ones
@@ -946,6 +1053,12 @@ function describe(f) {
946
1053
  'this value before it is joined into the HTML (replace & < > " \' with their HTML entities), ' +
947
1054
  'or pass it through a sanitizer such as DOMPurify if it must keep some formatting.',
948
1055
  ]
1056
+ : f.helper
1057
+ ? [
1058
+ 'Fix it inside ' + f.helper.fn + '() at ' + builtAt + ', where the values are joined into the HTML: ' +
1059
+ 'escape each dynamic value there (replace & < > " \' with their HTML entities), or pass the ' +
1060
+ 'result through a sanitizer such as DOMPurify if it must keep some formatting.',
1061
+ ]
949
1062
  : [
950
1063
  'Fix it so the value is always shown as text: set textContent instead of innerHTML for the ' +
951
1064
  'dynamic part, or escape it before it is joined into HTML (replace & < > " \' with their ' +
@@ -993,9 +1106,11 @@ function describe(f) {
993
1106
  origin: f.origin,
994
1107
  parts: (f.parts || []).map((p) => ({ text: p.text, origin: p.origin })),
995
1108
  via: f.via || null,
1109
+ helper: f.helper || null,
996
1110
  headline: 'Text ' + (f.origin === 'unknown' ? 'of unknown origin' : ORIGIN_WORDS[f.origin].split(',')[0]) +
997
1111
  ' is inserted as HTML in ' + f.file + ':' + f.line +
998
- (f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') + '.',
1112
+ (f.via ? ' (through ' + f.via.fn + '() at line ' + f.via.line + ')' : '') +
1113
+ (f.helper ? ', built by ' + f.helper.fn + '() at ' + builtAt : '') + '.',
999
1114
  fixPrompt: fixPrompt,
1000
1115
  };
1001
1116
  }