@databuddy/scan 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +41 -29
  2. package/dist/cli.js +477 -311
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -32,6 +32,17 @@ __export(exports_actions, {
32
32
  });
33
33
  import { posix } from "node:path";
34
34
  import ts2 from "typescript";
35
+ function writesOverFetch(call, owner) {
36
+ const init = call.arguments[1];
37
+ if (!(init && ts2.isObjectLiteralExpression(init))) {
38
+ return false;
39
+ }
40
+ const method = init.properties.find((property) => ts2.isPropertyAssignment(property) && property.name.getText(owner.file) === "method");
41
+ if (!(method && ts2.isPropertyAssignment(method))) {
42
+ return false;
43
+ }
44
+ return !(ts2.isStringLiteralLike(method.initializer) && readMethod.test(method.initializer.text));
45
+ }
35
46
  function walk2(node, visit) {
36
47
  visit(node);
37
48
  ts2.forEachChild(node, (child) => walk2(child, visit));
@@ -90,6 +101,47 @@ function lookup(unit, name, at) {
90
101
  }
91
102
  }
92
103
  }
104
+ function routeTable(sources) {
105
+ let table = routeTables.get(sources);
106
+ if (!table) {
107
+ table = [];
108
+ for (const path of sources.keys()) {
109
+ const app = appRoute.exec(path);
110
+ const pages = app ? null : pagesRoute.exec(path);
111
+ const route = app?.[1] ?? pages?.[1];
112
+ if (route) {
113
+ table.push({
114
+ path,
115
+ export: app ? "method" : "default",
116
+ parts: route.split("/").filter((part) => !routeGroup.test(part))
117
+ });
118
+ }
119
+ }
120
+ routeTables.set(sources, table);
121
+ }
122
+ return table;
123
+ }
124
+ function urlParts(input) {
125
+ const node = unwrap(input);
126
+ const text = ts2.isStringLiteralLike(node) ? node.text : ts2.isTemplateExpression(node) ? node.head.text + node.templateSpans.map((span) => `\x00${span.literal.text}`).join("") : undefined;
127
+ const pathname = text?.split(queryOrHash)[0];
128
+ if (!pathname?.startsWith("/")) {
129
+ return;
130
+ }
131
+ return pathname.split("/").filter(Boolean).map((part) => part.includes("\x00") ? null : part);
132
+ }
133
+ function routeMatches(route, url) {
134
+ for (const [index, part] of route.entries()) {
135
+ if (part.startsWith("[...") || part.startsWith("[[...")) {
136
+ return url.length > index || part.startsWith("[[");
137
+ }
138
+ const piece = url[index];
139
+ if (piece === undefined || !(part.startsWith("[") || piece === part)) {
140
+ return false;
141
+ }
142
+ }
143
+ return route.length === url.length;
144
+ }
93
145
  function groupActions(path, source, sources) {
94
146
  const units = new Map;
95
147
  function parse(key, text) {
@@ -311,6 +363,33 @@ function groupActions(path, source, sources) {
311
363
  return routineCall.test(name) || ts2.isIdentifier(call.expression) && routine(call.expression, owner, new Set(visited));
312
364
  });
313
365
  }
366
+ function describe(initializer, labels) {
367
+ const expression = initializer && ts2.isJsxExpression(initializer) && initializer.expression ? unwrap(initializer.expression) : undefined;
368
+ if (expression && ts2.isIdentifier(expression) && !genericHandler.test(expression.text)) {
369
+ return ` ${expression.text}`;
370
+ }
371
+ const text = labels.join(" ").replace(entity, (match) => entities[match] ?? match).replace(whitespaceRun, " ").trim();
372
+ return text ? ` "${text.slice(0, 40)}"` : "";
373
+ }
374
+ function routeHandler(call, owner, issues) {
375
+ const url = call.arguments[0] && urlParts(call.arguments[0]);
376
+ if (!url) {
377
+ return;
378
+ }
379
+ const dynamic = (parts) => parts.filter((part) => part.startsWith("[")).length;
380
+ const matches = routeTable(sources).filter((route) => routeMatches(route.parts, url)).sort((a, b) => dynamic(a.parts) - dynamic(b.parts));
381
+ const route = matches.length === 1 || matches[0] && matches[1] && dynamic(matches[0].parts) < dynamic(matches[1].parts) ? matches[0] : undefined;
382
+ if (!route) {
383
+ return;
384
+ }
385
+ const init = call.arguments[1];
386
+ const method = init && ts2.isObjectLiteralExpression(init) ? init.properties.find((property) => ts2.isPropertyAssignment(property) && property.name.getText(owner.file) === "method")?.initializer : undefined;
387
+ const target = parse(route.path, sources.get(route.path) ?? "");
388
+ if (!target) {
389
+ return;
390
+ }
391
+ return exported(target, route.export === "default" ? "default" : method && ts2.isStringLiteralLike(method) ? method.text.toUpperCase() : "GET", issues);
392
+ }
314
393
  const roots = [];
315
394
  walk2(unit.file, (node) => {
316
395
  if (ts2.isJsxOpeningElement(node) || ts2.isJsxSelfClosingElement(node)) {
@@ -321,20 +400,26 @@ function groupActions(path, source, sources) {
321
400
  return;
322
401
  }
323
402
  const attributes = node.attributes.properties.filter(ts2.isJsxAttribute);
324
- const events = attributes.filter((attribute) => handlers.has(attribute.name.getText(unit.file)) && attribute.initializer && ts2.isJsxExpression(attribute.initializer) && attribute.initializer.expression && ![ts2.SyntaxKind.NullKeyword, ts2.SyntaxKind.FalseKeyword].includes(attribute.initializer.expression.kind));
403
+ const events = attributes.filter((attribute) => handlers.has(attribute.name.getText(unit.file)) && attribute.initializer && ts2.isJsxExpression(attribute.initializer) && attribute.initializer.expression && ![ts2.SyntaxKind.NullKeyword, ts2.SyntaxKind.FalseKeyword].includes(attribute.initializer.expression.kind) && (!formAttributes.has(attribute.name.getText(unit.file)) || ts2.isIdentifier(unwrap(attribute.initializer.expression)) || isFunction(unwrap(attribute.initializer.expression))));
325
404
  const labels = [];
405
+ const buttonText = (part) => ts2.isJsxElement(part) && part.openingElement.tagName.getText(unit.file).toLowerCase().endsWith("button") ? part : ts2.forEachChild(part, buttonText);
326
406
  const visible = (part) => {
327
407
  if (ts2.isJsxAttribute(part)) {
328
408
  return;
329
409
  }
330
- if (ts2.isJsxText(part) || ts2.isStringLiteralLike(part)) {
410
+ if ((ts2.isJsxText(part) || ts2.isStringLiteralLike(part) && !ts2.isBinaryExpression(part.parent)) && !pending.test(part.text)) {
331
411
  labels.push(part.text);
332
412
  }
333
413
  ts2.forEachChild(part, visible);
334
414
  };
335
415
  if (ts2.isJsxElement(whole)) {
336
- for (const child of whole.children) {
337
- visible(child);
416
+ const button = component.toLowerCase() === "form" ? whole.children.map(buttonText).find(Boolean) : undefined;
417
+ if (button) {
418
+ visible(button);
419
+ } else if (component.toLowerCase() !== "form") {
420
+ for (const child of whole.children) {
421
+ visible(child);
422
+ }
338
423
  }
339
424
  }
340
425
  for (const attribute of attributes) {
@@ -355,7 +440,7 @@ function groupActions(path, source, sources) {
355
440
  node: active[0] ?? whole,
356
441
  owner: whole,
357
442
  callbacks: active.map((attribute) => attribute.initializer.expression).filter((expression) => !!expression),
358
- label: `${tag}.${active[0]?.name.getText(unit.file) ?? (component === "CopyButton" ? "copy" : "intent")}`,
443
+ label: `${tag}.${active[0]?.name.getText(unit.file) ?? (component === "CopyButton" ? "copy" : "intent")}${describe(active[0]?.initializer, labels)}`,
359
444
  ...component === "CopyButton" ? { component: tag } : {}
360
445
  });
361
446
  }
@@ -385,6 +470,15 @@ function groupActions(path, source, sources) {
385
470
  label: node.name.text
386
471
  });
387
472
  }
473
+ if (ts2.isFunctionDeclaration(node) && node.name && node.body && httpMethods.has(node.name.text) && ts2.getModifiers(node)?.some((modifier) => modifier.kind === ts2.SyntaxKind.ExportKeyword)) {
474
+ roots.push({
475
+ node,
476
+ owner: node,
477
+ callbacks: [node],
478
+ label: node.name.text,
479
+ needsWrite: !writeMethods.has(node.name.text)
480
+ });
481
+ }
388
482
  if (ts2.isPropertyAssignment(node) && afterHook.test(node.name.getText(unit.file)) && functionValue(node.initializer)) {
389
483
  for (let ancestor = node.parent;ancestor; ancestor = ancestor.parent) {
390
484
  if (ts2.isPropertyAssignment(ancestor) && hookContainer.test(ancestor.name.getText(unit.file))) {
@@ -399,8 +493,8 @@ function groupActions(path, source, sources) {
399
493
  }
400
494
  }
401
495
  });
402
- return roots.map((root) => {
403
- const issues = new Set, sites = new Map, contexts = new Map;
496
+ return roots.flatMap((root) => {
497
+ const issues = new Set, sites = new Map, contexts = new Map, excerpts = [];
404
498
  let characters = 0;
405
499
  const addSite = (owner, node) => {
406
500
  const location = site(owner, node);
@@ -418,6 +512,7 @@ ${text}`;
418
512
  return;
419
513
  }
420
514
  contexts.set(key, framed);
515
+ excerpts.push(location);
421
516
  characters += framed.length + 2;
422
517
  };
423
518
  addSite(unit, root.node);
@@ -445,6 +540,7 @@ ${text}`;
445
540
  }
446
541
  }
447
542
  const visited = new Set;
543
+ let commits = false;
448
544
  function evidence(owner, node, depth) {
449
545
  if (visited.has(node)) {
450
546
  return;
@@ -458,20 +554,11 @@ ${text}`;
458
554
  const callee = child.expression;
459
555
  if (ts2.isPropertyAccessExpression(callee)) {
460
556
  const method = callee.name.text;
461
- if ([
462
- "mutate",
463
- "mutateAsync",
464
- "writeText",
465
- "write",
466
- "setItem",
467
- "removeItem",
468
- "insert",
469
- "update",
470
- "delete",
471
- "track",
472
- "capture",
473
- "logEvent"
474
- ].includes(method)) {
557
+ const write = writes.has(method) && callee.expression.getText(owner.file) !== "Object";
558
+ if (write || httpWrites.has(method)) {
559
+ commits = true;
560
+ }
561
+ if (write || ["track", "capture", "logEvent"].includes(method)) {
475
562
  addSite(owner, child);
476
563
  }
477
564
  if (["mutate", "mutateAsync"].includes(method) && ts2.isIdentifier(callee.expression)) {
@@ -490,6 +577,16 @@ ${text}`;
490
577
  if (trackingCall2.test(callee.text)) {
491
578
  addSite(owner, child);
492
579
  }
580
+ if (callee.text === "fetch") {
581
+ if (writesOverFetch(child, owner)) {
582
+ commits = true;
583
+ }
584
+ const handler = routeHandler(child, owner, issues);
585
+ if (handler && depth < 2) {
586
+ addSite(handler.unit, handler.node);
587
+ evidence(handler.unit, handler.node, depth + 1);
588
+ }
589
+ }
493
590
  const resolved = resolve(owner, callee.text, child, issues);
494
591
  if (resolved?.unit.stateSetters.has(resolved.node)) {
495
592
  return;
@@ -515,20 +612,43 @@ ${text}`;
515
612
  }
516
613
  });
517
614
  }
518
- for (const callback of root.callbacks) {
519
- const expression = unwrap(callback);
520
- if (ts2.isIdentifier(expression)) {
521
- const resolved = resolve(unit, expression.text, expression, issues);
522
- if (resolved && !ts2.isParameter(resolved.node)) {
523
- evidence(resolved.unit, resolved.node, 0);
524
- } else {
525
- issues.add(`unresolved_callback:${expression.text}`);
615
+ function follow(owner, input) {
616
+ const expression = unwrap(input);
617
+ if (isFunction(expression)) {
618
+ evidence(owner, expression, 0);
619
+ return;
620
+ }
621
+ if (ts2.isConditionalExpression(expression)) {
622
+ follow(owner, expression.whenTrue);
623
+ follow(owner, expression.whenFalse);
624
+ return;
625
+ }
626
+ if (ts2.isCallExpression(expression)) {
627
+ const handlers = expression.arguments.filter((argument) => isFunction(unwrap(argument)) || ts2.isIdentifier(unwrap(argument)));
628
+ for (const argument of handlers) {
629
+ follow(owner, argument);
630
+ }
631
+ if (handlers.length) {
632
+ return;
526
633
  }
527
- } else if (isFunction(expression)) {
528
- evidence(unit, expression, 0);
529
- } else {
530
- issues.add(`unresolved_callback:${expression.getText(unit.file)}`);
531
634
  }
635
+ const resolved = ts2.isIdentifier(expression) ? resolve(owner, expression.text, expression, issues) : undefined;
636
+ if (!resolved || ts2.isParameter(resolved.node)) {
637
+ issues.add(`unresolved_callback:${expression.getText(owner.file)}`);
638
+ return;
639
+ }
640
+ const holder = resolved.node.parent?.parent;
641
+ const hook = ts2.isBindingElement(resolved.node) && holder && ts2.isVariableDeclaration(holder) && holder.initializer ? unwrap(holder.initializer) : undefined;
642
+ if (hook && ts2.isCallExpression(hook) && formHook.test(hook.expression.getText(resolved.unit.file)) && hook.arguments[0] && !visited.has(hook)) {
643
+ visited.add(hook);
644
+ addContext(resolved.unit, declaration(holder));
645
+ follow(resolved.unit, hook.arguments[0]);
646
+ return;
647
+ }
648
+ evidence(resolved.unit, resolved.node, 0);
649
+ }
650
+ for (const callback of root.callbacks) {
651
+ follow(unit, callback);
532
652
  }
533
653
  if (root.component) {
534
654
  const resolved = resolve(unit, root.component, root.node, issues);
@@ -552,24 +672,58 @@ ${text}`;
552
672
  }
553
673
  }
554
674
  }
675
+ if ((selection.test(root.label) || root.needsWrite) && !commits) {
676
+ return [];
677
+ }
555
678
  const location = site(unit, root.node);
556
- return {
557
- start: location.start,
558
- end: location.end,
559
- label: root.label,
560
- source: [...contexts.values()].join(`
679
+ return [
680
+ {
681
+ start: location.start,
682
+ end: location.end,
683
+ label: root.label,
684
+ source: [...contexts.values()].join(`
561
685
 
562
686
  `),
563
- sites: [...sites.values()],
564
- issues: [...issues]
565
- };
687
+ sites: [...sites.values()],
688
+ issues: [...issues],
689
+ excerpts
690
+ }
691
+ ];
566
692
  });
567
693
  }
568
- var extension, intent, handlers, routeMethods, httpMethods, contextLimit = 16000, jsxExtension, jsExtension, importExtension, routineCall, afterHook, hookContainer, trackingCall2;
694
+ var extension, intent, selection, handlers, writes, httpWrites, readMethod, routeMethods, httpMethods, writeMethods, contextLimit = 16000, jsxExtension, jsExtension, importExtension, routineCall, afterHook, hookContainer, trackingCall2, whitespaceRun, pending, formAttributes, formHook, appRoute, pagesRoute, routeGroup, queryOrHash, genericHandler, entities, entity, routeTables;
569
695
  var init_actions = __esm(() => {
570
696
  extension = /\.[cm]?[jt]sx?$/i;
571
697
  intent = /\b(?:copy|export|download|connect|install|upgrade|checkout|subscribe|sign up|register|start trial|accept invitation)\b/i;
572
- handlers = new Set(["onClick", "onSubmit", "onCopy"]);
698
+ selection = /\.on(?:ValueChange|CheckedChange|Select)$/;
699
+ handlers = new Set([
700
+ "onClick",
701
+ "onSubmit",
702
+ "onCopy",
703
+ "action",
704
+ "formAction",
705
+ "onValueChange",
706
+ "onCheckedChange",
707
+ "onSelect"
708
+ ]);
709
+ writes = new Set([
710
+ "mutate",
711
+ "mutateAsync",
712
+ "writeText",
713
+ "write",
714
+ "setItem",
715
+ "removeItem",
716
+ "insert",
717
+ "update",
718
+ "delete",
719
+ "create",
720
+ "upsert",
721
+ "createMany",
722
+ "updateMany",
723
+ "deleteMany"
724
+ ]);
725
+ httpWrites = new Set(["post", "put", "patch"]);
726
+ readMethod = /^(?:get|head|options)$/i;
573
727
  routeMethods = new Set([
574
728
  "post",
575
729
  "put",
@@ -579,6 +733,7 @@ var init_actions = __esm(() => {
579
733
  "handler"
580
734
  ]);
581
735
  httpMethods = new Set(["POST", "PUT", "PATCH", "DELETE", "GET"]);
736
+ writeMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
582
737
  jsxExtension = /x$/i;
583
738
  jsExtension = /\.[cm]?js$/i;
584
739
  importExtension = /\.[cm]?jsx?$/i;
@@ -586,22 +741,43 @@ var init_actions = __esm(() => {
586
741
  afterHook = /^after[A-Z]/;
587
742
  hookContainer = /^(?:hooks|organizationHooks|databaseHooks)$/;
588
743
  trackingCall2 = /^(?:track|capture|logEvent)/;
744
+ whitespaceRun = /\s+/g;
745
+ pending = /(?:\.\.\.|\u2026)\s*$/;
746
+ formAttributes = new Set(["action", "formAction"]);
747
+ formHook = /^(?:React\.)?use(?:ActionState|FormState)$/;
748
+ appRoute = /(?:^|\/)app\/(.+)\/route\.[cm]?[jt]sx?$/;
749
+ pagesRoute = /(?:^|\/)pages\/(api\/.+?)(?:\/index)?\.[cm]?[jt]sx?$/;
750
+ routeGroup = /^\(.*\)$/;
751
+ queryOrHash = /[?#]/;
752
+ genericHandler = /^(?:on(?:Click|Submit|Select|Change)|handle(?:Click|Submit|Change)|submit|handler|callback|formAction)$/;
753
+ entities = {
754
+ "&apos;": "'",
755
+ "&#39;": "'",
756
+ "&quot;": '"',
757
+ "&amp;": "&",
758
+ "&lt;": "<",
759
+ "&gt;": ">",
760
+ "&nbsp;": " "
761
+ };
762
+ entity = /&(?:apos|#39|quot|amp|lt|gt|nbsp);/g;
763
+ routeTables = new WeakMap;
589
764
  });
590
765
 
591
766
  // src/cli.ts
592
767
  import { execFileSync as execFileSync2 } from "node:child_process";
593
- import { realpath as realpath2 } from "node:fs/promises";
768
+ import { realpath as realpath2, stat } from "node:fs/promises";
594
769
  import { homedir } from "node:os";
595
- import { join as join3, resolve } from "node:path";
770
+ import { dirname, join as join3, relative, resolve } from "node:path";
596
771
  import { Command, CommanderError, Option } from "commander";
597
772
  import { z as z3 } from "zod";
598
773
  // package.json
599
- var version = "0.1.0";
774
+ var version = "0.1.1";
600
775
 
601
776
  // src/scan.ts
602
777
  import { execFileSync } from "node:child_process";
603
778
  import { createHash, randomUUID } from "node:crypto";
604
779
  import {
780
+ access,
605
781
  lstat,
606
782
  mkdir,
607
783
  open,
@@ -917,6 +1093,24 @@ var rowSchema = z.object({
917
1093
  categoryProbability: probability.nullable(),
918
1094
  action: actionSchema.optional()
919
1095
  });
1096
+ var catalogEntries = z.array(z.string().max(600)).max(400);
1097
+ var scanRequestSchema = z.object({
1098
+ segments: z.array(z.object({
1099
+ path: z.string().min(1).max(512),
1100
+ start: z.number().int().positive(),
1101
+ end: z.number().int().positive(),
1102
+ source: z.string().max(40000),
1103
+ action: actionSchema.optional()
1104
+ })).min(1).max(4),
1105
+ catalog: z.object({
1106
+ attributeTracking: catalogEntries,
1107
+ directTrackingCandidates: catalogEntries,
1108
+ note: z.string().max(4000),
1109
+ trackedRoutes: catalogEntries,
1110
+ trackingHelpers: catalogEntries,
1111
+ warehouseWrites: catalogEntries
1112
+ })
1113
+ });
920
1114
  var responseSchema = z.object({ answers: z.record(z.string(), z.unknown()) });
921
1115
  var numeric = z.union([z.number(), z.string().trim().min(1)]).pipe(z.coerce.number().nonnegative());
922
1116
  var usageSchema = z.object({ inputTokens: numeric.catch(0), outputTokens: numeric.catch(0) }).catch({ inputTokens: 0, outputTokens: 0 });
@@ -1057,8 +1251,29 @@ function retryDelay(value, attempt, timeoutMs) {
1057
1251
  const duration = Number.isFinite(Number(value)) ? Number(value) * 1000 : Date.parse(value) - Date.now();
1058
1252
  return Number.isFinite(duration) ? Math.min(Math.max(0, duration), 60000) : 0;
1059
1253
  }
1254
+ var gatewayUrl = "https://ai-gateway.vercel.sh/v4/ai/evaluation-model";
1255
+ var hostedScanUrl = process.env.DATABUDDY_SCAN_URL ?? "https://api.databuddy.cc/public/v1/scan/evaluate";
1060
1256
  async function requestEvaluation(body, options) {
1061
- for (let attempt = 1;attempt <= maxAttempts; attempt++) {
1257
+ const attempts = options.attempts ?? maxAttempts;
1258
+ const direct = Boolean(options.apiKey);
1259
+ const { state } = direct ? { state: null } : JSON.parse(body);
1260
+ const payload = direct ? body : JSON.stringify({ segments: state.segments, catalog: state.catalog });
1261
+ const headers = direct ? {
1262
+ Authorization: `Bearer ${options.apiKey}`,
1263
+ "Content-Type": "application/json",
1264
+ "ai-gateway-protocol-version": "0.0.1",
1265
+ "ai-gateway-auth-method": "api-key",
1266
+ "ai-evaluation-model-specification-version": "4",
1267
+ "ai-model-id": "typesafe-ai/jev"
1268
+ } : {
1269
+ "Content-Type": "application/json",
1270
+ "x-databuddy-scan-version": version,
1271
+ ...options.run ? {
1272
+ "x-databuddy-scan-run": options.run.id,
1273
+ "x-databuddy-scan-mode": options.run.mode
1274
+ } : {}
1275
+ };
1276
+ for (let attempt = 1;attempt <= attempts; attempt++) {
1062
1277
  options.signal.throwIfAborted();
1063
1278
  const started = performance.now();
1064
1279
  const timeout = AbortSignal.timeout(options.timeoutMs);
@@ -1066,17 +1281,10 @@ async function requestEvaluation(body, options) {
1066
1281
  let providerCode;
1067
1282
  let requestId = "";
1068
1283
  try {
1069
- response = await fetch("https://ai-gateway.vercel.sh/v4/ai/evaluation-model", {
1284
+ response = await fetch(direct ? gatewayUrl : hostedScanUrl, {
1070
1285
  method: "POST",
1071
- body,
1072
- headers: {
1073
- Authorization: `Bearer ${options.apiKey}`,
1074
- "Content-Type": "application/json",
1075
- "ai-gateway-protocol-version": "0.0.1",
1076
- "ai-gateway-auth-method": "api-key",
1077
- "ai-evaluation-model-specification-version": "4",
1078
- "ai-model-id": "typesafe-ai/jev"
1079
- },
1286
+ body: payload,
1287
+ headers,
1080
1288
  signal: AbortSignal.any([timeout, options.signal])
1081
1289
  });
1082
1290
  requestId = safeRequestId.parse(response.headers.get("x-vercel-id") ?? response.headers.get("x-request-id"));
@@ -1108,7 +1316,7 @@ async function requestEvaluation(body, options) {
1108
1316
  ...providerCode ? { providerCode } : {},
1109
1317
  ...requestId ? { requestId } : {}
1110
1318
  });
1111
- if (options.signal.aborted || attempt === maxAttempts || !(response && response.status >= 500 || name === "TimeoutError")) {
1319
+ if (options.signal.aborted || attempt === attempts || !(response && response.status >= 500 || name === "TimeoutError")) {
1112
1320
  throw failure;
1113
1321
  }
1114
1322
  const waitMs = retryDelay(response?.headers.get("retry-after") ?? null, attempt, options.timeoutMs);
@@ -1129,12 +1337,12 @@ async function requestEvaluation(body, options) {
1129
1337
  var scanOptionsSchema = z2.object({
1130
1338
  root: z2.string().trim().min(1).default("."),
1131
1339
  output: z2.string().trim().min(1).optional(),
1132
- run: z2.boolean().default(false),
1340
+ dryRun: z2.boolean().default(false),
1133
1341
  fresh: z2.boolean().default(false),
1134
1342
  cacheOnly: z2.boolean().default(false),
1135
1343
  actions: z2.boolean().default(true),
1136
1344
  concurrency: z2.coerce.number().int().positive().default(8),
1137
- batchFiles: z2.coerce.number().int().positive().default(4)
1345
+ batchFiles: z2.coerce.number().int().positive().max(4).default(2)
1138
1346
  });
1139
1347
  var timeoutMs = 15000;
1140
1348
  var maxRequestBytes = 48000;
@@ -1154,52 +1362,49 @@ var callSchema = z2.object({
1154
1362
  cached: z2.boolean(),
1155
1363
  split: z2.boolean(),
1156
1364
  ms: z2.number(),
1157
- inputTokens: z2.number(),
1158
- costUsd: z2.number().nullable(),
1159
1365
  attempts: z2.array(attemptSchema),
1160
1366
  error: z2.string().optional()
1161
1367
  });
1162
1368
  var resultSchema = z2.object({
1163
1369
  summary: z2.object({
1164
1370
  root: z2.string(),
1371
+ scope: z2.string(),
1165
1372
  head: z2.string().nullable(),
1166
1373
  model: z2.string(),
1167
1374
  runId: z2.string(),
1168
1375
  finishedAt: z2.string(),
1169
- sourceHash: z2.string(),
1170
- cacheOnly: z2.boolean(),
1376
+ destination: z2.object({
1377
+ host: z2.string(),
1378
+ kind: z2.enum(["databuddy", "gateway"])
1379
+ }),
1380
+ zeroDataRetention: z2.literal(true),
1171
1381
  interrupted: z2.boolean(),
1172
1382
  includedFiles: z2.number(),
1173
1383
  skippedFiles: z2.number(),
1174
1384
  classifiedFiles: z2.number(),
1175
- segments: z2.number(),
1176
- classifiedSegments: z2.number(),
1177
1385
  batches: z2.number(),
1178
- oversizedBatches: z2.number(),
1179
- splitBatches: z2.number(),
1180
- completedBatches: z2.number(),
1181
1386
  unattemptedBatches: z2.number(),
1182
1387
  failures: z2.number(),
1183
1388
  cachedBatches: z2.number(),
1184
1389
  requestAttempts: z2.number(),
1185
1390
  retries: z2.number(),
1186
- wallSeconds: z2.number(),
1187
- currentRunReportedCostUsd: z2.number(),
1188
- unknownFailedCallCosts: z2.number(),
1189
- missingCostReports: z2.number()
1391
+ wallSeconds: z2.number()
1190
1392
  }),
1191
- rows: z2.array(rowSchema),
1192
- calls: z2.array(callSchema)
1393
+ rows: z2.array(rowSchema)
1193
1394
  });
1194
1395
  var hash = (value) => createHash("sha256").update(value).digest("hex");
1195
1396
  var excluded = /(?:^|\/)(?:tests?|__tests__|fixtures?|__fixtures__|__mocks__|examples?|playground|node_modules|dist|\.next|\.agents|\.codex|vendor)(?:\/|$)|\.(?:test|spec|stories|generated|d)\.[^.]+$/i;
1196
1397
  var sourceFile = /\.(?:[cm]?[jt]sx?|vue|swift|py|sh|sql|html|css)$/;
1398
+ var repositoryKey = /^[ \t]*(?:export[ \t]+)?AI_GATEWAY_API_KEY[ \t]*=[ \t]*(.*?)[ \t]*$/m;
1399
+ var quoted = /^(["'])(.*)\1$/;
1400
+ var routeHandlerLabel = /^(?:GET|POST|PUT|PATCH|DELETE)$/;
1401
+ var reviewable = /\.(?:[cm]?[jt]sx?|vue|swift|py)$/;
1197
1402
  var sourceLineBoundary = /(?<=\n)/;
1198
1403
  var secret = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|(?:sk_live_|sk-proj-|ghp_|github_pat_)[A-Za-z0-9_-]{20,}/;
1199
1404
  var trackingCall3 = /\b((?:[\w$]+\.)?(?:track[A-Z]\w*|track|capture|logEvent))\s*\(/;
1200
1405
  var closers = { "(": ")", "[": "]", "{": "}" };
1201
1406
  var trailingSeparator = /[\s,]+$/;
1202
- var whitespaceRun = /\s+/g;
1407
+ var whitespaceRun2 = /\s+/g;
1203
1408
  function firstArgument(text) {
1204
1409
  const open = [];
1205
1410
  let argument = "";
@@ -1219,11 +1424,12 @@ function firstArgument(text) {
1219
1424
  break;
1220
1425
  }
1221
1426
  }
1222
- return argument.trim().replace(whitespaceRun, " ").replace(trailingSeparator, "") + open.reverse().map((character) => closers[character]).join("");
1427
+ return argument.trim().replace(whitespaceRun2, " ").replace(trailingSeparator, "") + open.reverse().map((character) => closers[character]).join("");
1223
1428
  }
1224
1429
  var safeError = /^(?:Gateway HTTP [0-9]{3}|No matching cached response; network disabled)$/;
1225
1430
  var shedError = /^(?:Gateway HTTP 5[0-9]{2}|Gateway request timed out)$/;
1226
1431
  var splitDepth = 2;
1432
+ var keyHelp = "Check the key, or unset it to use Databuddy's scan API.";
1227
1433
  var catalogByteLimit = 24000;
1228
1434
  async function readJSON(path) {
1229
1435
  try {
@@ -1281,9 +1487,9 @@ function planRequests(segments, catalog, batchFiles) {
1281
1487
  }
1282
1488
  return batches.map((jobs) => ({ body: build(jobs), jobs }));
1283
1489
  }
1284
- async function readSources(root) {
1490
+ async function readSources(root, scope = "") {
1285
1491
  const inventory = [], sources = new Map, tracking = new Set;
1286
- for (const path of execFileSync("git", ["ls-files", "-z"], {
1492
+ for (const path of execFileSync("git", ["ls-files", "-z", ...scope ? ["--", `:(literal)${scope}`] : []], {
1287
1493
  cwd: root,
1288
1494
  encoding: "utf8",
1289
1495
  maxBuffer: 64 * 1024 * 1024
@@ -1361,9 +1567,28 @@ async function readSources(root) {
1361
1567
  };
1362
1568
  return { inventory, sources, catalog };
1363
1569
  }
1364
- async function scan(options, onProgress) {
1365
- const { root, output, cacheOnly, fresh, concurrency, batchFiles } = options;
1366
- const run = options.run || fresh || cacheOnly;
1570
+ function sentFiles(segments, excerpts) {
1571
+ const files = new Map;
1572
+ for (const segment of segments) {
1573
+ for (const { path, start, end } of excerpts.get(segment) ?? [segment]) {
1574
+ files.set(path, [...files.get(path) ?? [], [start, end]]);
1575
+ }
1576
+ }
1577
+ return [...files].sort(([left], [right]) => left.localeCompare(right)).map(([path, ranges]) => {
1578
+ const lines = [];
1579
+ for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) {
1580
+ const last = lines.at(-1);
1581
+ if (last && start <= last[1] + 1) {
1582
+ last[1] = Math.max(last[1], end);
1583
+ } else {
1584
+ lines.push([start, end]);
1585
+ }
1586
+ }
1587
+ return { path, lines };
1588
+ });
1589
+ }
1590
+ async function scan(options, onProgress, announce) {
1591
+ const { root, scope, output, cacheOnly, fresh, concurrency, batchFiles } = options;
1367
1592
  const git = (...args) => execFileSync("git", args, {
1368
1593
  cwd: root,
1369
1594
  encoding: "utf8",
@@ -1377,24 +1602,37 @@ async function scan(options, onProgress) {
1377
1602
  try {
1378
1603
  head = git("rev-parse", "--verify", "HEAD").trim();
1379
1604
  } catch {}
1380
- const { inventory, sources, catalog } = await readSources(root);
1605
+ const { inventory, sources, catalog } = await readSources(root, scope);
1381
1606
  const { groupActions } = options.actions ? await Promise.resolve().then(() => (init_actions(), exports_actions)) : { groupActions: null };
1382
1607
  const segments = [];
1608
+ const excerpts = new Map;
1383
1609
  const noActionFiles = [];
1384
1610
  for (const [path, source] of sources) {
1385
1611
  const actions = groupActions?.(path, source, sources);
1386
1612
  if (actions?.length) {
1387
- for (const { start, end, source: context, ...action } of actions) {
1388
- segments.push({ path, start, end, source: context, action });
1389
- }
1390
- } else if (actions) {
1613
+ for (const {
1614
+ start,
1615
+ end,
1616
+ source: context,
1617
+ excerpts: lines,
1618
+ ...action
1619
+ } of actions) {
1620
+ const segment = { path, start, end, source: context, action };
1621
+ segments.push(segment);
1622
+ excerpts.set(segment, lines);
1623
+ }
1624
+ } else if (actions || !reviewable.test(path)) {
1391
1625
  noActionFiles.push(path);
1392
1626
  } else {
1393
1627
  segments.push(...splitSource(path, source));
1394
1628
  }
1395
1629
  }
1630
+ const linkedRoutes = new Set(segments.flatMap((segment) => (segment.action?.sites ?? []).filter((site) => site.path !== segment.path).map((site) => `${site.path}:${site.start}`)));
1631
+ const linked = (segment) => routeHandlerLabel.test(segment.action?.label ?? "") && linkedRoutes.has(`${segment.path}:${segment.start}`);
1632
+ for (const segment of segments.filter(linked)) {
1633
+ segments.splice(segments.indexOf(segment), 1);
1634
+ }
1396
1635
  const includedFiles = new Set(segments.map((s) => s.path)).size;
1397
- const sourceHash = hash(JSON.stringify(inventory.filter((f) => f.status === "included").map((f) => [f.path, f.sha256])));
1398
1636
  if (!cacheOnly) {
1399
1637
  await mkdir(join(output, "responses"), { recursive: true, mode: 448 });
1400
1638
  await saveJSON(join(output, "inventory.json"), {
@@ -1405,26 +1643,30 @@ async function scan(options, onProgress) {
1405
1643
  catalog
1406
1644
  });
1407
1645
  }
1408
- if (!run) {
1409
- return { includedFiles, skippedFiles: noActionFiles.length };
1646
+ const apiKey = process.env.AI_GATEWAY_API_KEY?.trim() || repositoryKey.exec(await readFile(join(root, ".env"), "utf8").catch(() => ""))?.[1]?.trim().replace(quoted, "$2") || undefined;
1647
+ const destination = apiKey ? { host: "ai-gateway.vercel.sh", kind: "gateway" } : { host: new URL(hostedScanUrl).host, kind: "databuddy" };
1648
+ if (options.dryRun) {
1649
+ return {
1650
+ dryRun: true,
1651
+ destination,
1652
+ files: sentFiles(segments, excerpts),
1653
+ skippedFiles: noActionFiles.length,
1654
+ payload: { catalog, segments }
1655
+ };
1410
1656
  }
1411
1657
  const batches = planRequests(segments, catalog, batchFiles);
1412
- if (!(cacheOnly || process.env.AI_GATEWAY_API_KEY)) {
1413
- try {
1414
- process.loadEnvFile(join(root, ".env"));
1415
- } catch (error) {
1416
- if (!(error instanceof Error && ("code" in error) && error.code === "ENOENT")) {
1417
- throw new Error("Could not load the repository .env.");
1418
- }
1419
- }
1420
- }
1421
- const apiKey = process.env.AI_GATEWAY_API_KEY;
1422
- if (!(cacheOnly || apiKey)) {
1423
- throw new Error("Set AI_GATEWAY_API_KEY in the repository .env to run Jev.");
1658
+ const cacheFile = (body, kind = "json") => join(output, "responses", `${hash(body)}.${kind}`);
1659
+ const exists = (path) => access(path).then(() => true, () => false);
1660
+ if (!cacheOnly) {
1661
+ const pending = await Promise.all(batches.map(async ({ body, jobs }) => fresh || !(await exists(cacheFile(body)) || await exists(cacheFile(body, "split"))) ? jobs : []));
1662
+ announce({
1663
+ destination,
1664
+ files: sentFiles(pending.flat(), excerpts).length
1665
+ });
1424
1666
  }
1425
1667
  const started = performance.now(), runId = randomUUID(), controller = new AbortController, limit = pLimit(concurrency);
1426
1668
  const rows = [], calls = [];
1427
- let plannedBatches = batches.length, interrupted = false, logFailure;
1669
+ let plannedBatches = batches.length, interrupted = false, logFailed = false;
1428
1670
  const logFile = cacheOnly ? null : await open(join(output, "progress.ndjson"), "a", 384);
1429
1671
  let writes = Promise.resolve();
1430
1672
  const log = (type, fields = {}) => {
@@ -1436,7 +1678,7 @@ async function scan(options, onProgress) {
1436
1678
  `);
1437
1679
  });
1438
1680
  writes.catch((error) => {
1439
- logFailure = error;
1681
+ logFailed = true;
1440
1682
  controller.abort(error);
1441
1683
  });
1442
1684
  };
@@ -1449,8 +1691,7 @@ async function scan(options, onProgress) {
1449
1691
  retries: calls.reduce((n, c) => n + Math.max(0, c.attempts.length - 1), 0),
1450
1692
  failures: calls.filter((c) => c.error && !c.split).length,
1451
1693
  elapsedSeconds: (performance.now() - started) / 1000,
1452
- rows,
1453
- cacheOnly
1694
+ rows
1454
1695
  });
1455
1696
  const cancel = () => {
1456
1697
  if (!interrupted) {
@@ -1466,8 +1707,8 @@ async function scan(options, onProgress) {
1466
1707
  log("start", {
1467
1708
  pid: process.pid,
1468
1709
  root,
1710
+ scope,
1469
1711
  head,
1470
- sourceHash,
1471
1712
  concurrency,
1472
1713
  timeoutMs,
1473
1714
  includedFiles,
@@ -1478,7 +1719,7 @@ async function scan(options, onProgress) {
1478
1719
  if (controller.signal.aborted) {
1479
1720
  return;
1480
1721
  }
1481
- const cacheKey = hash(body), cacheFile = join(output, "responses", `${cacheKey}.json`), at = performance.now();
1722
+ const cacheKey = hash(body), at = performance.now();
1482
1723
  const attempts = [], call = {
1483
1724
  batch: index,
1484
1725
  depth,
@@ -1487,8 +1728,6 @@ async function scan(options, onProgress) {
1487
1728
  cached: false,
1488
1729
  split: false,
1489
1730
  ms: 0,
1490
- inputTokens: 0,
1491
- costUsd: null,
1492
1731
  attempts
1493
1732
  };
1494
1733
  let response = null;
@@ -1502,7 +1741,7 @@ async function scan(options, onProgress) {
1502
1741
  try {
1503
1742
  if (!fresh) {
1504
1743
  try {
1505
- response = await readJSON(cacheFile);
1744
+ response = await readJSON(cacheFile(body));
1506
1745
  if (response !== null) {
1507
1746
  parseResponse(response, jobs);
1508
1747
  call.cached = true;
@@ -1514,13 +1753,18 @@ async function scan(options, onProgress) {
1514
1753
  log("cache_invalid", { batch: index, cacheKey });
1515
1754
  }
1516
1755
  }
1517
- if (!call.cached) {
1756
+ const splittable = jobs.length > 1 && depth < splitDepth;
1757
+ if (!(call.cached || fresh) && splittable && await exists(cacheFile(body, "split"))) {
1758
+ call.split = true;
1759
+ } else if (!call.cached) {
1518
1760
  response = null;
1519
1761
  if (cacheOnly) {
1520
1762
  throw new Error("No matching cached response; network disabled");
1521
1763
  }
1522
1764
  response = await requestEvaluation(body, {
1523
- apiKey: apiKey ?? "",
1765
+ apiKey,
1766
+ attempts: splittable ? 2 : undefined,
1767
+ run: { id: runId, mode: options.actions ? "actions" : "files" },
1524
1768
  timeoutMs,
1525
1769
  signal: controller.signal,
1526
1770
  onAttempt: (attempt) => {
@@ -1530,20 +1774,17 @@ async function scan(options, onProgress) {
1530
1774
  onRetry: (retry) => log("retry", { batch: index, ...retry })
1531
1775
  });
1532
1776
  }
1533
- const parsed = parseResponse(response, jobs);
1534
- call.inputTokens = parsed.inputTokens;
1535
- call.costUsd = parsed.costUsd;
1536
- if (!call.cached) {
1537
- await saveJSON(cacheFile, response);
1777
+ if (!call.split) {
1778
+ const parsed = parseResponse(response, jobs);
1779
+ if (!call.cached) {
1780
+ await saveJSON(cacheFile(body), response);
1781
+ }
1782
+ rows.push(...parsed.rows);
1538
1783
  }
1539
- rows.push(...parsed.rows);
1540
1784
  } catch (error) {
1541
- const usage = readUsage(response);
1542
- call.inputTokens = usage.inputTokens;
1543
- call.costUsd = usage.costUsd;
1544
1785
  call.error = interrupted ? "Interrupted by user" : error instanceof z2.ZodError ? "Invalid model response" : error instanceof Error && safeError.test(error.message) ? error.message : error instanceof Error && error.name === "TimeoutError" ? "Gateway request timed out" : error instanceof Error && ("code" in error) ? `Scan failed (${String(error.code)})` : "Scan failed";
1545
1786
  call.split = !(cacheOnly || controller.signal.aborted) && jobs.length > 1 && depth < splitDepth && shedError.test(call.error);
1546
- if (attempts.some((a) => a.status === 401 || a.status === 403) || logFailure) {
1787
+ if (attempts.some((a) => a.status === 401 || a.status === 403) || logFailed) {
1547
1788
  controller.abort();
1548
1789
  }
1549
1790
  } finally {
@@ -1561,6 +1802,9 @@ async function scan(options, onProgress) {
1561
1802
  update();
1562
1803
  }
1563
1804
  if (call.split) {
1805
+ if (!cacheOnly) {
1806
+ await writeFile(cacheFile(body, "split"), "", { mode: 384 });
1807
+ }
1564
1808
  const half = Math.ceil(jobs.length / 2);
1565
1809
  const halves = [jobs.slice(0, half), jobs.slice(half)];
1566
1810
  plannedBatches += halves.length - 1;
@@ -1575,39 +1819,50 @@ async function scan(options, onProgress) {
1575
1819
  throw rejected.reason;
1576
1820
  }
1577
1821
  await writes;
1578
- if (logFailure) {
1822
+ if (logFailed) {
1579
1823
  throw new Error("Could not write scan diagnostics.");
1580
1824
  }
1581
- rows.sort((a, b) => b.priority - a.priority || a.path.localeCompare(b.path) || a.start - b.start);
1825
+ const statuses = calls.flatMap((c) => c.attempts).map((a) => a.status);
1826
+ const failures = calls.filter((c) => c.error && !c.split).length;
1827
+ const denied = statuses.find((status) => status === 401 || status === 403);
1828
+ if (apiKey && denied) {
1829
+ throw new Error(`Vercel AI Gateway rejected AI_GATEWAY_API_KEY (HTTP ${denied}). ${keyHelp}`);
1830
+ }
1831
+ if (!apiKey && failures && statuses.includes(429)) {
1832
+ throw new Error("Databuddy's scan API rate limit was reached. Try again later, or set AI_GATEWAY_API_KEY to use your own Vercel AI Gateway key.");
1833
+ }
1834
+ rows.sort((a, b) => Number(a.category === "none") - Number(b.category === "none") || b.priority - a.priority || a.path.localeCompare(b.path) || a.start - b.start);
1582
1835
  const summary = {
1583
1836
  root,
1837
+ scope,
1584
1838
  head,
1585
1839
  model: "typesafe-ai/jev",
1586
1840
  runId,
1587
1841
  finishedAt: new Date().toISOString(),
1588
- sourceHash,
1589
- cacheOnly,
1842
+ destination,
1843
+ zeroDataRetention: true,
1590
1844
  interrupted,
1591
1845
  includedFiles,
1592
1846
  skippedFiles: noActionFiles.length,
1593
1847
  classifiedFiles: new Set(rows.map((r) => r.path)).size,
1594
- segments: segments.length,
1595
- classifiedSegments: rows.length,
1596
1848
  batches: plannedBatches,
1597
- oversizedBatches: calls.filter((c) => c.requestBytes > maxRequestBytes).length,
1598
- splitBatches: calls.filter((c) => c.split).length,
1599
- completedBatches: calls.filter((c) => !c.split).length,
1600
1849
  unattemptedBatches: plannedBatches - calls.filter((c) => !c.split).length,
1601
- failures: calls.filter((c) => c.error && !c.split).length,
1850
+ failures,
1602
1851
  cachedBatches: calls.filter((c) => c.cached).length,
1603
1852
  requestAttempts: calls.reduce((n, c) => n + c.attempts.length, 0),
1604
1853
  retries: calls.reduce((n, c) => n + Math.max(0, c.attempts.length - 1), 0),
1605
- wallSeconds: Math.round((performance.now() - started) / 100) / 10,
1606
- currentRunReportedCostUsd: calls.reduce((n, c) => n + (c.cached ? 0 : c.costUsd ?? 0), 0),
1607
- unknownFailedCallCosts: calls.flatMap((c) => c.attempts).filter((a) => a.error).length,
1608
- missingCostReports: calls.filter((c) => c.costUsd === null && (c.cached || c.attempts.some((a) => a.status === 200 && !a.error))).length
1854
+ wallSeconds: Math.round((performance.now() - started) / 100) / 10
1855
+ };
1856
+ const round = (value) => value === null ? null : Math.round(value * 100) / 100;
1857
+ const result = {
1858
+ summary,
1859
+ rows: rows.map((row) => ({
1860
+ ...row,
1861
+ priority: round(row.priority) ?? 0,
1862
+ coverageProbability: round(row.coverageProbability),
1863
+ categoryProbability: round(row.categoryProbability)
1864
+ }))
1609
1865
  };
1610
- const result = { summary, rows, calls };
1611
1866
  if (!cacheOnly) {
1612
1867
  await saveJSON(join(output, "results.json"), result);
1613
1868
  }
@@ -1640,10 +1895,10 @@ var areas = {
1640
1895
  acquisition: "Acquisition",
1641
1896
  none: "Other actions"
1642
1897
  };
1898
+ var privacy = `Code is sent to Databuddy's scan API (${new URL(hostedScanUrl).host}), which classifies it with the Jev model (typesafe-ai/jev) on Vercel AI Gateway under zero data retention: your source is never stored or logged. For JavaScript and TypeScript only the actions it finds and the functions they call are sent, not whole files; --dry-run lists every line without sending anything. Set AI_GATEWAY_API_KEY to send it to your own Vercel AI Gateway account instead, and Databuddy receives nothing. Privacy policy: https://www.databuddy.cc/privacy`;
1643
1899
  var controls = /[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g;
1644
1900
  var clean = (value) => stripVTControlCharacters(String(value ?? "")).replace(controls, "");
1645
- var disputed = (row) => row.category === "none" ? 1 : 0;
1646
- var gaps = (rows) => rows.filter((row) => row.coverage === "missing" || row.coverage === "partial").sort((a, b) => disputed(a) - disputed(b) || b.priority - a.priority);
1901
+ var gaps = (rows) => rows.filter((row) => row.coverage === "missing" || row.coverage === "partial");
1647
1902
  function uniqueFiles(rows) {
1648
1903
  const found = new Map;
1649
1904
  for (const row of rows) {
@@ -1655,13 +1910,9 @@ function uniqueFiles(rows) {
1655
1910
  }
1656
1911
  var elapsed = (seconds) => seconds < 60 ? `${Math.round(seconds)}s` : `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
1657
1912
  var location = (row, root = "") => `${clean(root ? join2(root, row.path) : row.path)}:${row.start}`;
1658
- function createTerminal({
1659
- json = false,
1660
- plain = false,
1661
- verbose = false
1662
- } = {}) {
1663
- const interactive = process.stderr.isTTY && process.env.TERM !== "dumb" && !plain && !json;
1664
- const noColor = plain || json || Object.hasOwn(process.env, "NO_COLOR") || process.env.TERM === "dumb";
1913
+ function createTerminal({ json = false } = {}) {
1914
+ const interactive = process.stderr.isTTY && process.env.TERM !== "dumb" && !json;
1915
+ const noColor = json || Object.hasOwn(process.env, "NO_COLOR") || process.env.TERM === "dumb";
1665
1916
  const monochrome = new Chalk({ level: 0 });
1666
1917
  const output = noColor ? monochrome : chalk;
1667
1918
  const progress = noColor ? monochrome : chalkStderr;
@@ -1669,7 +1920,7 @@ function createTerminal({
1669
1920
  const print = (lines) => process.stdout.write(`${lines.join(`
1670
1921
  `)}
1671
1922
  `);
1672
- const title = (suffix = "") => ` ${output.bold("databuddy.")} ${output.dim(`/ event scan${suffix}`)}`;
1923
+ const title = ` ${output.bold("databuddy.")} ${output.dim("/ event scan")}`;
1673
1924
  let rendered = false;
1674
1925
  function stop() {
1675
1926
  if (rendered) {
@@ -1685,149 +1936,93 @@ function createTerminal({
1685
1936
  const found = uniqueFiles(gaps(snapshot.rows));
1686
1937
  const fraction = snapshot.batches ? Math.min(1, snapshot.completedBatches / snapshot.batches) : 0;
1687
1938
  const filled = Math.round(fraction * 28);
1688
- const lines = [
1689
- ` ${progress.bold("databuddy.")} ${progress.dim("/ event scan")}`,
1939
+ rendered = true;
1940
+ live([
1941
+ title,
1690
1942
  "",
1691
- ` ${snapshot.cacheOnly ? "Reading saved responses" : "Scanning your code"}`,
1692
1943
  ` ${progress.hex("#e3a514")("━".repeat(filled))}${progress.dim("─".repeat(28 - filled))} ${Math.round(fraction * 100)}%`,
1693
1944
  ` ${snapshot.classifiedFiles} / ${snapshot.includedFiles} files · ${elapsed(snapshot.elapsedSeconds)}`,
1694
1945
  "",
1695
1946
  progress.hex("#e3a514")(` ${found.length} files to review`),
1696
1947
  ...found.slice(0, 3).map((row) => ` ${location(row)} · ${areas[row.category]}`)
1697
- ];
1698
- if (verbose) {
1699
- lines.push(` ${snapshot.active} active · ${snapshot.retries} retries · ${snapshot.failures} failed`);
1700
- }
1701
- rendered = true;
1702
- live(lines.join(`
1948
+ ].join(`
1703
1949
  `));
1704
1950
  }
1705
- function finish(result, options = {}) {
1951
+ function announce({
1952
+ destination,
1953
+ files
1954
+ }) {
1955
+ process.stderr.write(files === 0 ? `Nothing to send: every result is cached from an earlier scan.
1956
+ ` : destination.kind === "databuddy" ? `Sending code from ${files} files to Databuddy's scan API (${destination.host}). Jev classifies it on Vercel AI Gateway with zero data retention; your source is never stored or logged. Preview with --dry-run, or set AI_GATEWAY_API_KEY to use your own gateway. https://www.databuddy.cc/privacy
1957
+ ` : `Sending code from ${files} files directly to your Vercel AI Gateway account (${destination.host}) with zero data retention. Databuddy receives nothing.
1958
+ `);
1959
+ }
1960
+ function dryRun(result) {
1961
+ if (json) {
1962
+ return print([
1963
+ JSON.stringify({ ...result, sent: false, zeroDataRetention: true })
1964
+ ]);
1965
+ }
1966
+ const width = Math.max(0, ...result.files.map((file) => file.path.length));
1967
+ if (result.files.length === 0) {
1968
+ return print([
1969
+ "",
1970
+ title,
1971
+ "",
1972
+ ` Nothing to send: none of the ${result.skippedFiles} files here has a user action to review. Scan a wider folder.`,
1973
+ ""
1974
+ ]);
1975
+ }
1976
+ print([
1977
+ "",
1978
+ title,
1979
+ "",
1980
+ ` Would send these lines from ${result.files.length} ${result.files.length === 1 ? "file" : "files"} to ${result.destination.host}. Nothing was sent.`,
1981
+ "",
1982
+ ...result.files.map((file) => ` ${clean(file.path).padEnd(width)} ${file.lines.map(([start, end]) => start === end ? start : `${start}-${end}`).join(", ")}`),
1983
+ "",
1984
+ ` ${result.skippedFiles} files have nothing to review and are not sent. --dry-run --json prints the exact payload.`,
1985
+ ""
1986
+ ]);
1987
+ }
1988
+ function finish(result) {
1706
1989
  stop();
1707
1990
  if (json) {
1708
1991
  return print([JSON.stringify(result)]);
1709
1992
  }
1710
1993
  const { summary: s, rows } = result;
1711
- const flagged = gaps(rows), found = uniqueFiles(flagged);
1712
- const uncertain = rows.filter((row) => row.coverage === "uncertain");
1713
- const incomplete = s.failures > 0 || s.unattemptedBatches > 0 || s.classifiedFiles < s.includedFiles;
1714
- let status = incomplete ? "Scan incomplete" : "Scan complete";
1715
- if (s.interrupted) {
1716
- status = "Stopped";
1717
- }
1718
- const suffix = options.saved || s.cacheOnly ? " · saved results" : "";
1994
+ const flagged = gaps(rows);
1995
+ const visible = interactive ? flagged.slice(0, 10) : flagged;
1719
1996
  const lines = [
1720
1997
  "",
1721
- title(suffix),
1998
+ title,
1722
1999
  "",
1723
- ` ${output.hex("#e3a514")(status)} · ${s.classifiedFiles} / ${s.includedFiles} files · ${incomplete ? `${s.classifiedSegments} / ${s.segments} segments · ` : ""}${s.skippedFiles ? `${s.skippedFiles} skipped · ` : ""}${elapsed(s.wallSeconds)}`,
2000
+ ` ${s.interrupted ? "Stopped" : "Scan complete"} · ${flagged.length} findings in ${uniqueFiles(flagged).length} files · ${elapsed(s.wallSeconds)}`,
1724
2001
  "",
1725
- output.hex("#e3a514")(` ${found.length} files to review`)
2002
+ ...visible.map((row) => ` ${location(row)} · ${row.action ? `${clean(row.action.label)} · ` : ""}${row.coverage} · ${areas[row.category]}`)
1726
2003
  ];
1727
- if (flagged.length) {
1728
- lines.push(" Potential gaps · review the source");
1729
- }
1730
- const visible = verbose ? flagged : found.slice(0, 3);
1731
- for (const row of visible) {
1732
- lines.push(` ${location(row, verbose ? s.root : "")} · ${row.action ? `${clean(row.action.label)} · ` : ""}${row.coverage} · ${areas[row.category]}`);
1733
- }
1734
- if (!verbose && flagged.length > visible.length) {
1735
- lines.push(" All locations: databuddy-scan --report --verbose");
2004
+ if (flagged.length > visible.length) {
2005
+ lines.push(` ${flagged.length - visible.length} more · databuddy-scan --json lists every finding`);
1736
2006
  }
1737
- if (uncertain.length) {
1738
- lines.push(` ${uniqueFiles(uncertain).length} files have uncertain segments`);
1739
- if (verbose) {
1740
- lines.push(...uncertain.map((row) => ` ${location(row, s.root)} · ${row.action ? `${clean(row.action.label)} · ` : ""}needs context`));
1741
- }
1742
- }
1743
- if (incomplete || s.interrupted) {
1744
- lines.push("", " Progress saved. Continue: databuddy-scan --run", " Diagnose: databuddy-scan --diagnostics");
1745
- }
1746
- if (verbose) {
1747
- for (const call of result.calls.filter((call) => call.error).slice(0, 3)) {
1748
- lines.push(` Batch ${call.batch + 1}: ${clean(call.error)}`);
1749
- }
1750
- if (options.directory) {
1751
- lines.push(` Reports: ${clean(options.directory)}`);
1752
- }
2007
+ if (s.failures || s.unattemptedBatches || s.interrupted) {
2008
+ lines.push("", ` ${s.failures + s.unattemptedBatches} batches did not finish. Run again to retry them; finished work is kept.`);
1753
2009
  }
1754
2010
  print([...lines, ""]);
1755
2011
  }
1756
- function diagnostics(result, directory) {
1757
- stop();
1758
- const s = result.summary;
1759
- const attempts = result.calls.flatMap((call) => call.attempts);
1760
- const statuses = new Map;
1761
- for (const attempt of attempts) {
1762
- const status = attempt.providerCode ?? attempt.error ?? String(attempt.status);
1763
- statuses.set(status, (statuses.get(status) ?? 0) + 1);
1764
- }
1765
- const latency = attempts.map((attempt) => attempt.ms).filter(Number.isFinite).sort((a, b) => a - b);
1766
- const percentile = (fraction) => latency[Math.ceil(latency.length * fraction) - 1] ?? null;
1767
- const data = {
1768
- files: `${s.classifiedFiles}/${s.includedFiles}`,
1769
- requests: attempts.length,
1770
- retries: s.retries,
1771
- cachedBatches: s.cachedBatches,
1772
- failedBatches: s.failures,
1773
- unattemptedBatches: s.unattemptedBatches,
1774
- statuses: Object.fromEntries(statuses),
1775
- latencyMs: { p50: percentile(0.5), p95: percentile(0.95) },
1776
- batchLatencyMs: (() => {
1777
- const spent = result.calls.map((call) => call.ms).sort((a, b) => a - b);
1778
- return {
1779
- p50: spent[Math.ceil(spent.length * 0.5) - 1] ?? null,
1780
- p95: spent[Math.ceil(spent.length * 0.95) - 1] ?? null
1781
- };
1782
- })(),
1783
- skippedFiles: s.skippedFiles,
1784
- oversizedBatches: s.oversizedBatches,
1785
- splitBatches: s.splitBatches,
1786
- reviewFiles: uniqueFiles(gaps(result.rows)).length,
1787
- uncertainSegments: result.rows.filter((row) => row.coverage === "uncertain").length,
1788
- reportedCostUsd: s.currentRunReportedCostUsd,
1789
- unknownFailedCallCosts: s.unknownFailedCallCosts,
1790
- missingCostReports: s.missingCostReports
1791
- };
1792
- if (json) {
1793
- return print([JSON.stringify(data)]);
1794
- }
1795
- const statusCounts = [...statuses].map(([status, count]) => `${clean(status)}: ${count}`).join(", ");
1796
- print([
1797
- "",
1798
- title(" · diagnostics"),
1799
- "",
1800
- ` ${data.files} files · ${data.failedBatches} failed batches · ${data.unattemptedBatches} unattempted`,
1801
- ` ${data.skippedFiles} files skipped with no detected action · listed in inventory.json`,
1802
- ` ${data.requests} requests · ${data.retries} retries · ${statusCounts}`,
1803
- ` ${data.splitBatches} batches split after a failure · ${data.oversizedBatches} single segments over the request limit`,
1804
- ` Request latency: median ${data.latencyMs.p50 ?? "—"} ms · p95 ${data.latencyMs.p95 ?? "—"} ms`,
1805
- ` Batch latency, retries included: median ${data.batchLatencyMs.p50 ?? "—"} ms · p95 ${data.batchLatencyMs.p95 ?? "—"} ms`,
1806
- ` ${data.cachedBatches} cached responses · no new requests for cached results`,
1807
- ` $${data.reportedCostUsd.toFixed(3)} reported this run · ${data.unknownFailedCallCosts} failed request costs unknown · ${data.missingCostReports} responses missing cost`,
1808
- ` ${data.reviewFiles} files flagged · ${data.uncertainSegments} uncertain segments`,
1809
- ` Request log: ${clean(join2(directory, "progress.ndjson"))}`,
1810
- ""
1811
- ]);
1812
- }
1813
2012
  return {
1814
2013
  update,
2014
+ announce,
2015
+ dryRun,
1815
2016
  finish,
1816
- diagnostics,
1817
2017
  stop,
1818
- inventory(info) {
1819
- print(json ? [JSON.stringify(info)] : [
1820
- "",
1821
- title(),
1822
- "",
1823
- ` ${info.includedFiles} files with product actions ready to scan${info.skippedFiles ? ` · ${info.skippedFiles} with none detected` : ""}. Start: databuddy-scan --run`,
1824
- ""
1825
- ]);
1826
- },
1827
2018
  error(message) {
1828
2019
  stop();
1829
- const text = json ? JSON.stringify({ error: clean(message) }) : `Error: ${clean(message)}`;
1830
- process.stderr.write(`${text}
2020
+ if (json) {
2021
+ process.stdout.write(`${JSON.stringify({ error: clean(message) })}
2022
+ `);
2023
+ return;
2024
+ }
2025
+ process.stderr.write(`Error: ${clean(message)}
1831
2026
  `);
1832
2027
  }
1833
2028
  };
@@ -1835,67 +2030,40 @@ function createTerminal({
1835
2030
 
1836
2031
  // src/cli.ts
1837
2032
  var optionsSchema = scanOptionsSchema.extend({
1838
- report: z3.boolean().default(false),
1839
- diagnostics: z3.boolean().default(false),
1840
- plain: z3.boolean().default(false),
1841
- json: z3.boolean().default(false),
1842
- verbose: z3.boolean().default(false)
2033
+ json: z3.boolean().default(false)
1843
2034
  });
1844
- var command = new Command().name("databuddy-scan").description(`Find product analytics gaps in your Git repository.
1845
- Preview offline, or use --run to send source to Jev through Vercel AI Gateway.`).version(version).option("--run", "Scan or resume").option("--actions", "Group product actions with their evidence (default)").option("--no-actions", "Review whole files instead: slower, and weaker at spotting existing coverage").addOption(new Option("--fresh", "Scan without reusing responses").conflicts("cacheOnly")).option("--cache-only", "Replay matching responses without network or writes").addOption(new Option("--report", "Show saved findings").conflicts([
1846
- "run",
1847
- "fresh",
1848
- "cacheOnly",
1849
- "diagnostics"
1850
- ])).addOption(new Option("--diagnostics", "Explain request failures and output quality").conflicts(["run", "fresh", "cacheOnly"])).option("--root <path>", "Repository to scan (default: current repository)").option("--output <path>", "Results directory (default: per-repository user cache)").option("--concurrency <count>", "Concurrent requests (default: 8)").option("--batch-files <count>", "Maximum segments per request (default: 4)").option("--verbose", "Show every source location and request details").option("--plain", "Disable terminal control codes").option("--json", "Write JSON without progress output").addHelpText("after", `
1851
- Examples:
1852
- npx @databuddy/scan --run
1853
- bunx @databuddy/scan --report --verbose
2035
+ var command = new Command().name("databuddy-scan").description(`Find where your product is missing analytics events.
1854
2036
 
1855
- Requires AI_GATEWAY_API_KEY for new requests. Zero data retention is requested.
1856
- Ctrl+C saves completed work; repeat --run to resume.`).exitOverride();
2037
+ ${privacy}
2038
+
2039
+ Results are cached in ~/.cache/databuddy/scan, so unchanged code is not sent again.`).version(version).argument("[path]", "directory or file to scan; nothing outside it is read", ".").option("--dry-run", "list the files that would be sent, and send nothing").option("--json", "print results as JSON").addOption(new Option("--output <path>").hideHelp()).addOption(new Option("--concurrency <count>").hideHelp()).addOption(new Option("--batch-files <count>").hideHelp()).addOption(new Option("--cache-only").hideHelp()).addOption(new Option("--fresh").conflicts("cacheOnly").hideHelp()).addOption(new Option("--no-actions").hideHelp()).exitOverride();
1857
2040
  async function main() {
1858
2041
  command.parse();
1859
- const options = optionsSchema.parse(command.opts());
2042
+ const options = optionsSchema.parse({
2043
+ ...command.opts(),
2044
+ root: command.args[0] ?? "."
2045
+ });
1860
2046
  const terminal = createTerminal(options);
1861
2047
  try {
1862
- let root;
2048
+ let root, target;
1863
2049
  try {
2050
+ target = await realpath2(resolve(options.root));
1864
2051
  root = await realpath2(execFileSync2("git", ["rev-parse", "--show-toplevel"], {
1865
- cwd: resolve(options.root),
2052
+ cwd: (await stat(target)).isFile() ? dirname(target) : target,
1866
2053
  encoding: "utf8",
1867
2054
  stdio: ["ignore", "pipe", "pipe"]
1868
2055
  }).trim());
1869
2056
  } catch {
1870
- throw new Error("Run inside a Git repository, or use --root=/path/to/repository.");
2057
+ throw new Error("Run inside a Git repository, or pass its path: databuddy-scan <path>");
1871
2058
  }
1872
2059
  const output = resolve(options.output ?? join3(process.env.XDG_CACHE_HOME ?? join3(homedir(), ".cache"), "databuddy", "scan", hash(root).slice(0, 16)));
1873
- if (options.report || options.diagnostics) {
1874
- const saved = await readJSON(join3(output, "results.json"));
1875
- if (!saved) {
1876
- throw new Error("No saved scan for this repository. Use --run first.");
1877
- }
1878
- const parsed = resultSchema.safeParse(saved);
1879
- if (!parsed.success) {
1880
- throw new Error("Saved results use an older or invalid format. Run --run to rebuild them using the cached responses.");
1881
- }
1882
- if (parsed.data.summary.root !== root) {
1883
- throw new Error("This output directory belongs to another repository. Choose another --output.");
1884
- }
1885
- if (options.diagnostics) {
1886
- terminal.diagnostics(parsed.data, output);
1887
- } else {
1888
- terminal.finish(parsed.data, { saved: true, directory: output });
1889
- }
2060
+ const result = await scan({ ...options, root, output, scope: relative(root, target) }, terminal.update, terminal.announce);
2061
+ if ("dryRun" in result) {
2062
+ terminal.dryRun(result);
1890
2063
  return;
1891
2064
  }
1892
- const result = await scan({ ...options, root, output }, terminal.update);
1893
- if ("summary" in result) {
1894
- terminal.finish(result, { directory: output });
1895
- process.exitCode = result.summary.interrupted ? 130 : result.summary.failures || result.summary.unattemptedBatches ? 1 : 0;
1896
- } else {
1897
- terminal.inventory(result);
1898
- }
2065
+ terminal.finish(result);
2066
+ process.exitCode = result.summary.interrupted ? 130 : result.summary.failures || result.summary.unattemptedBatches ? 1 : 0;
1899
2067
  } catch (error) {
1900
2068
  terminal.error(error instanceof Error ? error.message : "Scan failed");
1901
2069
  process.exitCode = 1;
@@ -1910,9 +2078,7 @@ main().catch((error) => {
1910
2078
  }
1911
2079
  const message = error instanceof z3.ZodError ? error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ") : "Could not start the scanner.";
1912
2080
  createTerminal({
1913
- json: command.opts().json ?? false,
1914
- plain: true,
1915
- verbose: false
2081
+ json: command.opts().json ?? false
1916
2082
  }).error(message);
1917
2083
  process.exitCode = 1;
1918
2084
  });