@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.
- package/README.md +41 -29
- package/dist/cli.js +477 -311
- 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
|
-
|
|
337
|
-
|
|
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.
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
519
|
-
const expression = unwrap(
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
+
"'": "'",
|
|
755
|
+
"'": "'",
|
|
756
|
+
""": '"',
|
|
757
|
+
"&": "&",
|
|
758
|
+
"<": "<",
|
|
759
|
+
">": ">",
|
|
760
|
+
" ": " "
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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 ===
|
|
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
|
-
|
|
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().
|
|
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
|
-
|
|
1170
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1365
|
-
const
|
|
1366
|
-
const
|
|
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 {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
|
|
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,
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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) ||
|
|
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 (
|
|
1822
|
+
if (logFailed) {
|
|
1579
1823
|
throw new Error("Could not write scan diagnostics.");
|
|
1580
1824
|
}
|
|
1581
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
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
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
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
|
|
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
|
-
|
|
1660
|
-
|
|
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 =
|
|
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
|
-
|
|
1689
|
-
|
|
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
|
|
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)
|
|
1712
|
-
const
|
|
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
|
|
1998
|
+
title,
|
|
1722
1999
|
"",
|
|
1723
|
-
` ${
|
|
2000
|
+
` ${s.interrupted ? "Stopped" : "Scan complete"} · ${flagged.length} findings in ${uniqueFiles(flagged).length} files · ${elapsed(s.wallSeconds)}`,
|
|
1724
2001
|
"",
|
|
1725
|
-
|
|
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(
|
|
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 (
|
|
1738
|
-
lines.push(` ${
|
|
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
|
-
|
|
1830
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1856
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1893
|
-
|
|
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
|
});
|