brainblast 0.4.0 → 0.4.2

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.
@@ -4,9 +4,10 @@ import { Project, SyntaxKind } from "ts-morph";
4
4
  // src/walk.ts
5
5
  import { readdirSync, statSync } from "fs";
6
6
  import { join } from "path";
7
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".gen", "dist", ".next", ".agent-research"]);
7
8
  function walk(dir, out = []) {
8
9
  for (const entry of readdirSync(dir)) {
9
- if (entry === "node_modules" || entry === ".git" || entry === ".gen") continue;
10
+ if (SKIP_DIRS.has(entry)) continue;
10
11
  const p = join(dir, entry);
11
12
  const st = statSync(p);
12
13
  if (st.isDirectory()) walk(p, out);
@@ -14,6 +15,16 @@ function walk(dir, out = []) {
14
15
  }
15
16
  return out;
16
17
  }
18
+ function walkAllFiles(dir, out = []) {
19
+ for (const entry of readdirSync(dir)) {
20
+ if (SKIP_DIRS.has(entry)) continue;
21
+ const p = join(dir, entry);
22
+ const st = statSync(p);
23
+ if (st.isDirectory()) walkAllFiles(p, out);
24
+ else out.push(p);
25
+ }
26
+ return out;
27
+ }
17
28
 
18
29
  // src/finder.ts
19
30
  function bodyCallsAnyOf(fn, names) {
@@ -61,6 +72,42 @@ function findCandidates(targetDir, rule) {
61
72
  return out;
62
73
  }
63
74
 
75
+ // src/configFinder.ts
76
+ import { execFileSync } from "child_process";
77
+ import { readFileSync } from "fs";
78
+ import { relative, sep } from "path";
79
+ function isGitIgnored(targetDir, rel) {
80
+ try {
81
+ execFileSync("git", ["check-ignore", "-q", "--", rel], { cwd: targetDir, stdio: "ignore" });
82
+ return true;
83
+ } catch (e) {
84
+ if (typeof e?.status === "number") return false;
85
+ return false;
86
+ }
87
+ }
88
+ function findConfigCandidates(targetDir, rule) {
89
+ const patterns = (rule.detect.filePatterns ?? []).map((p) => new RegExp(p));
90
+ if (patterns.length === 0) return [];
91
+ const files = walkAllFiles(targetDir);
92
+ const out = [];
93
+ for (const file of files) {
94
+ const rel = relative(targetDir, file).split(sep).join("/");
95
+ if (!patterns.some((re) => re.test(rel))) continue;
96
+ let content;
97
+ try {
98
+ content = readFileSync(file, "utf8");
99
+ } catch {
100
+ continue;
101
+ }
102
+ out.push({
103
+ filePath: file,
104
+ content,
105
+ tracked: !isGitIgnored(targetDir, rel)
106
+ });
107
+ }
108
+ return out;
109
+ }
110
+
64
111
  // src/checkers/positionalArgIdentity.ts
65
112
  import { SyntaxKind as SyntaxKind2 } from "ts-morph";
66
113
  var positionalArgIdentity = (c, p) => {
@@ -410,6 +457,126 @@ function anchorInitIfNeededGuarded(c, p) {
410
457
  };
411
458
  }
412
459
 
460
+ // src/checkers/envSecretsCommitted.ts
461
+ var envSecretsCommitted = (c, p) => {
462
+ if (!c.tracked) {
463
+ return {
464
+ result: "pass",
465
+ detail: p.ignoredDetail ?? "File is git-ignored; not committed to source control."
466
+ };
467
+ }
468
+ const keyRe = new RegExp(p.secretKeyPattern, "i");
469
+ const placeholderRe = new RegExp(p.placeholderPattern, "i");
470
+ const offenders = [];
471
+ for (const rawLine of c.content.split("\n")) {
472
+ const line = rawLine.trim();
473
+ if (!line || line.startsWith("#")) continue;
474
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
475
+ if (!m) continue;
476
+ const [, key, rawValue] = m;
477
+ if (!keyRe.test(key)) continue;
478
+ const value = (rawValue ?? "").trim().replace(/^["']|["']$/g, "");
479
+ if (!value) continue;
480
+ if (placeholderRe.test(value)) continue;
481
+ offenders.push(key);
482
+ }
483
+ if (offenders.length > 0) {
484
+ const prefix = p.failDetailPrefix ?? "This file is tracked by git and contains secret-looking values";
485
+ return { result: "fail", detail: `${prefix}: ${offenders.join(", ")}.` };
486
+ }
487
+ return { result: "pass", detail: p.passDetail ?? "No committed secret-looking values found." };
488
+ };
489
+
490
+ // src/checkers/envTaintToSink.ts
491
+ import { SyntaxKind as SyntaxKind7 } from "ts-morph";
492
+ function calleeName(call) {
493
+ const exp = call.getExpression();
494
+ if (exp.getKind() === SyntaxKind7.Identifier) return exp.getText();
495
+ if (exp.getKind() === SyntaxKind7.PropertyAccessExpression) {
496
+ return exp.asKind(SyntaxKind7.PropertyAccessExpression).getName();
497
+ }
498
+ return "";
499
+ }
500
+ function envVarIn(text) {
501
+ const m = text.match(/process\.env\.([A-Za-z0-9_]+)/);
502
+ return m?.[1];
503
+ }
504
+ function wordIn(text, name) {
505
+ return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(text);
506
+ }
507
+ function findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames) {
508
+ for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
509
+ const name = calleeName(call);
510
+ if (!sinkCalls.has(name)) continue;
511
+ for (const arg of call.getArguments()) {
512
+ const text = arg.getText();
513
+ const envVar = envVarIn(text);
514
+ if (envVar && secretKeyRe.test(envVar)) {
515
+ return `process.env.${envVar} is passed directly to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
516
+ }
517
+ for (const tv of taintedNames) {
518
+ if (wordIn(text, tv)) {
519
+ return `'${tv}' (holding a secret-shaped process.env value) is passed to ${name}(...) \u2014 secret values must not be logged or returned to clients.`;
520
+ }
521
+ }
522
+ }
523
+ }
524
+ return void 0;
525
+ }
526
+ var envTaintToSink = (c, p) => {
527
+ const sinkCalls = new Set(p.sinkCalls ?? []);
528
+ const secretKeyRe = new RegExp(p.secretKeyPattern, "i");
529
+ const fn = c.fn;
530
+ const taintedNames = /* @__PURE__ */ new Set();
531
+ for (const decl of fn.getDescendantsOfKind(SyntaxKind7.VariableDeclaration)) {
532
+ const init = decl.getInitializer();
533
+ if (!init) continue;
534
+ const envVar = envVarIn(init.getText());
535
+ if (envVar && secretKeyRe.test(envVar)) {
536
+ taintedNames.add(decl.getName());
537
+ }
538
+ }
539
+ const direct = findDirectLeak(fn, sinkCalls, secretKeyRe, taintedNames);
540
+ if (direct) return { result: "fail", detail: direct };
541
+ const sourceFile = fn.getSourceFile();
542
+ for (const call of fn.getDescendantsOfKind(SyntaxKind7.CallExpression)) {
543
+ const calleeExp = call.getExpression();
544
+ if (calleeExp.getKind() !== SyntaxKind7.Identifier) continue;
545
+ const calleeFnName = calleeExp.getText();
546
+ if (calleeFnName === (c.fnName ?? "")) continue;
547
+ const args = call.getArguments();
548
+ const taintedArgIndices = [];
549
+ args.forEach((arg, i) => {
550
+ const text = arg.getText();
551
+ const envVar = envVarIn(text);
552
+ if (envVar && secretKeyRe.test(envVar)) {
553
+ taintedArgIndices.push(i);
554
+ return;
555
+ }
556
+ for (const tv of taintedNames) {
557
+ if (wordIn(text, tv)) {
558
+ taintedArgIndices.push(i);
559
+ return;
560
+ }
561
+ }
562
+ });
563
+ if (taintedArgIndices.length === 0) continue;
564
+ const calleeFn = sourceFile.getFunction(calleeFnName);
565
+ if (!calleeFn) continue;
566
+ const params = calleeFn.getParameters().map((pr) => pr.getName());
567
+ const calleeTainted = new Set(taintedArgIndices.map((i) => params[i]).filter((x) => !!x));
568
+ if (calleeTainted.size === 0) continue;
569
+ const hop = findDirectLeak(calleeFn, sinkCalls, secretKeyRe, calleeTainted);
570
+ if (hop) {
571
+ return {
572
+ result: "fail",
573
+ detail: `A secret-shaped process.env value flows into '${calleeFnName}(...)' (called from '${c.fnName}'), where ${hop}`
574
+ };
575
+ }
576
+ }
577
+ return { result: "pass", detail: "No secret-shaped process.env value flows to a logging/response sink." };
578
+ };
579
+
413
580
  // src/checkers/index.ts
414
581
  var registry = {
415
582
  "positional-arg-identity": positionalArgIdentity,
@@ -417,7 +584,9 @@ var registry = {
417
584
  "fee-allocation-shape": feeAllocationShape,
418
585
  "arg-equals-constant-identifier": argEqualsConstantIdentifier,
419
586
  "object-arg-property-literal-equals": objectArgPropertyLiteralEquals,
420
- "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded
587
+ "anchor-init-if-needed-guarded": anchorInitIfNeededGuarded,
588
+ "env-secrets-committed": envSecretsCommitted,
589
+ "env-taint-to-sink": envTaintToSink
421
590
  };
422
591
  function runChecker(kind, c, params) {
423
592
  const fn = registry[kind];
@@ -426,14 +595,89 @@ function runChecker(kind, c, params) {
426
595
  }
427
596
  var checkerKinds = Object.keys(registry);
428
597
 
429
- // src/rustFinder.ts
430
- import { readFileSync, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
598
+ // src/gitDiff.ts
599
+ import { execFileSync as execFileSync2 } from "child_process";
600
+ import { readFileSync as readFileSync2 } from "fs";
431
601
  import { join as join2 } from "path";
602
+ function getChangedRanges(targetDir, ref) {
603
+ let out;
604
+ try {
605
+ out = execFileSync2(
606
+ "git",
607
+ ["diff", "--unified=0", "--no-color", "--no-renames", "--diff-filter=ACMR", "--relative", ref, "--"],
608
+ { cwd: targetDir, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
609
+ );
610
+ } catch (e) {
611
+ const stderr = e?.stderr?.toString?.() ?? e?.message ?? String(e);
612
+ throw new Error(`brainblast: 'git diff ${ref}' failed: ${stderr.trim()}`);
613
+ }
614
+ const ranges = /* @__PURE__ */ new Map();
615
+ let currentFile = null;
616
+ for (const line of out.split("\n")) {
617
+ if (line.startsWith("+++ ")) {
618
+ const raw = line.slice(4).trim();
619
+ if (raw === "/dev/null") {
620
+ currentFile = null;
621
+ continue;
622
+ }
623
+ const rel = raw.startsWith("b/") ? raw.slice(2) : raw;
624
+ currentFile = join2(targetDir, rel);
625
+ continue;
626
+ }
627
+ if (line.startsWith("@@") && currentFile) {
628
+ const m = line.match(/\+(\d+)(?:,(\d+))?/);
629
+ if (!m) continue;
630
+ const start = parseInt(m[1], 10);
631
+ const count = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
632
+ if (count === 0) continue;
633
+ const end = start + count - 1;
634
+ const arr = ranges.get(currentFile) ?? [];
635
+ arr.push([start, end]);
636
+ ranges.set(currentFile, arr);
637
+ }
638
+ }
639
+ return ranges;
640
+ }
641
+ function getWorkingTreeChanges(targetDir) {
642
+ const ranges = getChangedRanges(targetDir, "HEAD");
643
+ let untracked;
644
+ try {
645
+ untracked = execFileSync2("git", ["ls-files", "--others", "--exclude-standard", "--", "."], {
646
+ cwd: targetDir,
647
+ encoding: "utf8"
648
+ });
649
+ } catch {
650
+ return ranges;
651
+ }
652
+ for (const rel of untracked.split("\n").map((s) => s.trim()).filter(Boolean)) {
653
+ const abs = join2(targetDir, rel);
654
+ let lineCount = 1;
655
+ try {
656
+ lineCount = readFileSync2(abs, "utf8").split("\n").length;
657
+ } catch {
658
+ continue;
659
+ }
660
+ ranges.set(abs, [[1, lineCount]]);
661
+ }
662
+ return ranges;
663
+ }
664
+ function fileChanged(ranges, file) {
665
+ return ranges.has(file);
666
+ }
667
+ function rangeChanged(ranges, file, startLine, endLine) {
668
+ const fileRanges = ranges.get(file);
669
+ if (!fileRanges) return false;
670
+ return fileRanges.some(([s, e]) => startLine <= e && endLine >= s);
671
+ }
672
+
673
+ // src/rustFinder.ts
674
+ import { readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
675
+ import { join as join3 } from "path";
432
676
  import { createRequire } from "module";
433
677
  function walkRust(dir, out = []) {
434
678
  for (const entry of readdirSync2(dir)) {
435
679
  if (entry === "node_modules" || entry === ".git" || entry === "target") continue;
436
- const p = join2(dir, entry);
680
+ const p = join3(dir, entry);
437
681
  const st = statSync2(p);
438
682
  if (st.isDirectory()) walkRust(p, out);
439
683
  else if (p.endsWith(".rs")) out.push(p);
@@ -513,7 +757,7 @@ function findRustCandidates(targetDir, rule) {
513
757
  const out = [];
514
758
  for (const file of walkRust(targetDir)) {
515
759
  if (!file.endsWith(".rs")) continue;
516
- const src = readFileSync(file, "utf8");
760
+ const src = readFileSync3(file, "utf8");
517
761
  const tree = parser.parse(src);
518
762
  const structMap = /* @__PURE__ */ new Map();
519
763
  const topPairs = itemsWithAttrs(tree.rootNode);
@@ -558,7 +802,7 @@ function findRustCandidates(targetDir, rule) {
558
802
  }
559
803
 
560
804
  // src/fixers/positionalArgIdentity.ts
561
- import { SyntaxKind as SyntaxKind7 } from "ts-morph";
805
+ import { SyntaxKind as SyntaxKind8 } from "ts-morph";
562
806
 
563
807
  // src/fixers/diffUtil.ts
564
808
  function buildDiff(node, replacement) {
@@ -583,9 +827,9 @@ function buildDiff(node, replacement) {
583
827
  // src/fixers/positionalArgIdentity.ts
584
828
  var fixPositionalArgIdentity = (c, p, outcome) => {
585
829
  if (outcome.result !== "fail") return void 0;
586
- const calls = c.fn.getDescendantsOfKind(SyntaxKind7.CallExpression).filter((call) => {
830
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression).filter((call) => {
587
831
  const exp = call.getExpression();
588
- return exp.getKind() === SyntaxKind7.PropertyAccessExpression && exp.asKind(SyntaxKind7.PropertyAccessExpression).getName() === p.call;
832
+ return exp.getKind() === SyntaxKind8.PropertyAccessExpression && exp.asKind(SyntaxKind8.PropertyAccessExpression).getName() === p.call;
589
833
  });
590
834
  if (calls.length === 0) {
591
835
  const wantParam2 = c.params[p.paramIndex] ?? "<rawBodyParam>";
@@ -600,7 +844,7 @@ Do not call JSON.parse() on the body before this verification step.`
600
844
  }
601
845
  const arg = calls[0].getArguments()[p.argIndex];
602
846
  const wantParam = c.params[p.paramIndex];
603
- if (arg && wantParam && arg.getKind() === SyntaxKind7.CallExpression) {
847
+ if (arg && wantParam && arg.getKind() === SyntaxKind8.CallExpression) {
604
848
  return {
605
849
  summary: `Pass the raw body parameter '${wantParam}' to ${p.call} instead of a parsed value`,
606
850
  diff: buildDiff(arg, wantParam)
@@ -610,12 +854,12 @@ Do not call JSON.parse() on the body before this verification step.`
610
854
  };
611
855
 
612
856
  // src/fixers/requiredCallWithOptions.ts
613
- import { SyntaxKind as SyntaxKind8 } from "ts-morph";
857
+ import { SyntaxKind as SyntaxKind9 } from "ts-morph";
614
858
  function callName4(call) {
615
859
  const exp = call.getExpression();
616
- if (exp.getKind() === SyntaxKind8.Identifier) return exp.getText();
617
- if (exp.getKind() === SyntaxKind8.PropertyAccessExpression) {
618
- return exp.asKind(SyntaxKind8.PropertyAccessExpression).getName();
860
+ if (exp.getKind() === SyntaxKind9.Identifier) return exp.getText();
861
+ if (exp.getKind() === SyntaxKind9.PropertyAccessExpression) {
862
+ return exp.asKind(SyntaxKind9.PropertyAccessExpression).getName();
619
863
  }
620
864
  return "";
621
865
  }
@@ -633,15 +877,15 @@ function placeholderFor(propName) {
633
877
  }
634
878
  var fixRequiredCallWithOptions = (c, p, outcome) => {
635
879
  if (outcome.result !== "fail") return void 0;
636
- const calls = c.fn.getDescendantsOfKind(SyntaxKind8.CallExpression);
880
+ const calls = c.fn.getDescendantsOfKind(SyntaxKind9.CallExpression);
637
881
  const verify = calls.filter((x) => p.verifyCalls.includes(callName4(x)));
638
882
  if (verify.length > 0) {
639
883
  const call = verify[0];
640
884
  const args = call.getArguments();
641
885
  const lastArg = args[args.length - 1];
642
- const obj = lastArg?.asKind(SyntaxKind8.ObjectLiteralExpression);
886
+ const obj = lastArg?.asKind(SyntaxKind9.ObjectLiteralExpression);
643
887
  const presentNames = obj ? obj.getProperties().map((pr) => {
644
- const pa = pr.asKind(SyntaxKind8.PropertyAssignment) ?? pr.asKind(SyntaxKind8.ShorthandPropertyAssignment);
888
+ const pa = pr.asKind(SyntaxKind9.PropertyAssignment) ?? pr.asKind(SyntaxKind9.ShorthandPropertyAssignment);
645
889
  return pa?.getName() ?? "";
646
890
  }) : [];
647
891
  const missingGroups = p.requiredProps.filter(
@@ -744,9 +988,28 @@ function buildReport(target, checks, rules2, costReport) {
744
988
  }
745
989
 
746
990
  // src/audit.ts
747
- function auditWithRule(targetDir, rule) {
991
+ function auditWithRule(targetDir, rule, changedRanges) {
992
+ if (rule.detect.lang === "config") {
993
+ return findConfigCandidates(targetDir, rule).filter((c) => !changedRanges || fileChanged(changedRanges, c.filePath)).map((c) => {
994
+ const outcome = runChecker(rule.check.kind, c, rule.check.params);
995
+ return {
996
+ ruleId: rule.id,
997
+ severity: rule.severity,
998
+ title: rule.title,
999
+ file: c.filePath,
1000
+ line: 1,
1001
+ exportName: c.filePath,
1002
+ ...outcome
1003
+ };
1004
+ });
1005
+ }
748
1006
  if (rule.detect.lang === "rust") {
749
- return findRustCandidates(targetDir, rule).map((c) => {
1007
+ return findRustCandidates(targetDir, rule).filter((c) => {
1008
+ if (!changedRanges) return true;
1009
+ const start = (c.fnBodyNode?.startPosition?.row ?? 0) + 1;
1010
+ const end = (c.fnBodyNode?.endPosition?.row ?? start - 1) + 1;
1011
+ return rangeChanged(changedRanges, c.filePath, start, end);
1012
+ }).map((c) => {
750
1013
  const outcome = runChecker(rule.check.kind, c, rule.check.params);
751
1014
  return {
752
1015
  ruleId: rule.id,
@@ -760,7 +1023,10 @@ function auditWithRule(targetDir, rule) {
760
1023
  };
761
1024
  });
762
1025
  }
763
- return findCandidates(targetDir, rule).map((c) => {
1026
+ return findCandidates(targetDir, rule).filter((c) => {
1027
+ if (!changedRanges) return true;
1028
+ return rangeChanged(changedRanges, c.filePath, c.fn.getStartLineNumber(), c.fn.getEndLineNumber());
1029
+ }).map((c) => {
764
1030
  const outcome = runChecker(rule.check.kind, c, rule.check.params);
765
1031
  const fix = runFixer(rule.check.kind, c, rule.check.params, outcome);
766
1032
  return {
@@ -775,8 +1041,8 @@ function auditWithRule(targetDir, rule) {
775
1041
  };
776
1042
  });
777
1043
  }
778
- function audit(targetDir, rules2) {
779
- const checks = rules2.flatMap((r) => auditWithRule(targetDir, r));
1044
+ function audit(targetDir, rules2, changedRanges) {
1045
+ const checks = rules2.flatMap((r) => auditWithRule(targetDir, r, changedRanges));
780
1046
  const report = buildReport(targetDir, checks, rules2);
781
1047
  return { checks, report };
782
1048
  }
@@ -1055,6 +1321,11 @@ mod brainblast_reinit_guard_test {
1055
1321
  }
1056
1322
  `;
1057
1323
 
1324
+ // src/testTemplates/none.ts
1325
+ var none = (opts) => `// No behavioral contract test applies to this rule.
1326
+ // Finding: ${opts.handlerExport} (${opts.handlerImportPath})
1327
+ `;
1328
+
1058
1329
  // src/testTemplates/index.ts
1059
1330
  var registry3 = {
1060
1331
  "stripe-webhook-signature": stripeWebhookSignature,
@@ -1062,7 +1333,8 @@ var registry3 = {
1062
1333
  "bags-fee-share": bagsFeeShare,
1063
1334
  "token-program-consistency": tokenProgramConsistency,
1064
1335
  "metaplex-immutable-metadata": metaplexImmutableMetadata,
1065
- "anchor-program-test": anchorProgramTest
1336
+ "anchor-program-test": anchorProgramTest,
1337
+ none
1066
1338
  };
1067
1339
  var JS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
1068
1340
  function renderTest(kind, opts) {
@@ -1076,8 +1348,8 @@ function renderTest(kind, opts) {
1076
1348
  var testKinds = Object.keys(registry3);
1077
1349
 
1078
1350
  // src/loadRules.ts
1079
- import { readdirSync as readdirSync3, readFileSync as readFileSync2 } from "fs";
1080
- import { join as join3 } from "path";
1351
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "fs";
1352
+ import { join as join4 } from "path";
1081
1353
  import { parse } from "yaml";
1082
1354
  var SEVERITIES = ["critical", "high", "medium", "low"];
1083
1355
  function validateRule(r, file) {
@@ -1089,7 +1361,19 @@ function validateRule(r, file) {
1089
1361
  if (!SEVERITIES.includes(r.severity)) errs.push(`bad severity '${r.severity}'`);
1090
1362
  if (!r.title || typeof r.title !== "string") errs.push("missing title");
1091
1363
  if (!r.component || !r.component.name || !r.component.type) errs.push("missing component.name/type");
1092
- if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
1364
+ if (r.detect?.lang === "config") {
1365
+ if (!Array.isArray(r.detect.filePatterns) || r.detect.filePatterns.length === 0) {
1366
+ errs.push("detect.filePatterns must be a non-empty array when detect.lang is 'config'");
1367
+ } else {
1368
+ for (const pat of r.detect.filePatterns) {
1369
+ try {
1370
+ new RegExp(pat);
1371
+ } catch {
1372
+ errs.push(`detect.filePatterns contains an invalid regex: ${pat}`);
1373
+ }
1374
+ }
1375
+ }
1376
+ } else if (!r.detect || !Array.isArray(r.detect.modules) || typeof r.detect.nameRegex !== "string" || !Array.isArray(r.detect.triggerCalls)) {
1093
1377
  errs.push("detect must have modules[], nameRegex (string), triggerCalls[]");
1094
1378
  } else {
1095
1379
  try {
@@ -1110,7 +1394,7 @@ function loadRules(dir) {
1110
1394
  const files = readdirSync3(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
1111
1395
  const rules2 = [];
1112
1396
  for (const f of files) {
1113
- const raw = parse(readFileSync2(join3(dir, f), "utf8"));
1397
+ const raw = parse(readFileSync4(join4(dir, f), "utf8"));
1114
1398
  validateRule(raw, f);
1115
1399
  rules2.push(raw);
1116
1400
  }
@@ -1119,23 +1403,23 @@ function loadRules(dir) {
1119
1403
 
1120
1404
  // rules/index.ts
1121
1405
  import { existsSync } from "fs";
1122
- import { dirname, join as join4 } from "path";
1406
+ import { dirname, join as join5 } from "path";
1123
1407
  import { fileURLToPath } from "url";
1124
1408
  function bundledRulesDir() {
1125
1409
  const here = dirname(fileURLToPath(import.meta.url));
1126
- if (existsSync(join4(here, "stripe-webhook-raw-body.yaml"))) return here;
1127
- const sub = join4(here, "rules");
1128
- if (existsSync(join4(sub, "stripe-webhook-raw-body.yaml"))) return sub;
1410
+ if (existsSync(join5(here, "stripe-webhook-raw-body.yaml"))) return here;
1411
+ const sub = join5(here, "rules");
1412
+ if (existsSync(join5(sub, "stripe-webhook-raw-body.yaml"))) return sub;
1129
1413
  return here;
1130
1414
  }
1131
1415
  var rules = loadRules(bundledRulesDir());
1132
1416
 
1133
1417
  // src/resolveRules.ts
1134
1418
  import { existsSync as existsSync2 } from "fs";
1135
- import { join as join5 } from "path";
1419
+ import { join as join6 } from "path";
1136
1420
  function resolveRules(targetDir) {
1137
1421
  const all = [...rules];
1138
- const projDir = join5(targetDir, ".agent-research", "rules");
1422
+ const projDir = join6(targetDir, ".agent-research", "rules");
1139
1423
  if (existsSync2(projDir)) {
1140
1424
  const seen = new Set(all.map((r) => r.id));
1141
1425
  for (const r of loadRules(projDir)) {
@@ -1151,19 +1435,19 @@ function resolveRules(targetDir) {
1151
1435
  }
1152
1436
 
1153
1437
  // src/trustGraph/directory.ts
1154
- import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
1438
+ import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
1155
1439
  import { fileURLToPath as fileURLToPath2 } from "url";
1156
- import { join as join6 } from "path";
1440
+ import { join as join7 } from "path";
1157
1441
  import { parse as parse2 } from "yaml";
1158
1442
  var cache = null;
1159
1443
  function bundledPath() {
1160
1444
  const here = fileURLToPath2(new URL(".", import.meta.url));
1161
1445
  const candidates = [
1162
- join6(here, "programs", "directory.yaml"),
1446
+ join7(here, "programs", "directory.yaml"),
1163
1447
  // dist/programs/directory.yaml
1164
- join6(here, "..", "..", "programs", "directory.yaml"),
1448
+ join7(here, "..", "..", "programs", "directory.yaml"),
1165
1449
  // src/../../programs/
1166
- join6(here, "..", "programs", "directory.yaml")
1450
+ join7(here, "..", "programs", "directory.yaml")
1167
1451
  // fallback
1168
1452
  ];
1169
1453
  for (const c of candidates) {
@@ -1173,7 +1457,7 @@ function bundledPath() {
1173
1457
  }
1174
1458
  function loadDirectory(path = bundledPath()) {
1175
1459
  if (cache && path === bundledPath()) return cache;
1176
- const raw = parse2(readFileSync3(path, "utf8"));
1460
+ const raw = parse2(readFileSync5(path, "utf8"));
1177
1461
  if (!raw || !Array.isArray(raw.programs)) {
1178
1462
  throw new Error(`invalid program directory at ${path}: missing 'programs' array`);
1179
1463
  }
@@ -1244,14 +1528,14 @@ function isValidSolanaAddress(s) {
1244
1528
  }
1245
1529
 
1246
1530
  // src/trustGraph/programCache.ts
1247
- import { readFileSync as readFileSync4, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1248
- import { join as join7, dirname as dirname2 } from "path";
1531
+ import { readFileSync as readFileSync6, writeFileSync, mkdirSync, existsSync as existsSync4 } from "fs";
1532
+ import { join as join8, dirname as dirname2 } from "path";
1249
1533
  import { homedir } from "os";
1250
1534
  var DEFAULT_TTL_HOURS = 168;
1251
1535
  var SCHEMA_VERSION = "1.0";
1252
1536
  function defaultCachePath() {
1253
1537
  const envOverride = process.env["BRAINBLAST_CACHE_PATH"];
1254
- return envOverride ?? join7(homedir(), ".brainblast", "program-cache.json");
1538
+ return envOverride ?? join8(homedir(), ".brainblast", "program-cache.json");
1255
1539
  }
1256
1540
  function emptyCache() {
1257
1541
  return { schemaVersion: SCHEMA_VERSION, entries: {} };
@@ -1260,7 +1544,7 @@ function loadProgramCache(cachePath) {
1260
1544
  const path = cachePath ?? defaultCachePath();
1261
1545
  if (!existsSync4(path)) return emptyCache();
1262
1546
  try {
1263
- const raw = JSON.parse(readFileSync4(path, "utf8"));
1547
+ const raw = JSON.parse(readFileSync6(path, "utf8"));
1264
1548
  if (raw?.schemaVersion !== SCHEMA_VERSION) {
1265
1549
  return emptyCache();
1266
1550
  }
@@ -1537,7 +1821,7 @@ function renderTrustGraphMd(g) {
1537
1821
  }
1538
1822
 
1539
1823
  // src/costAnalysis.ts
1540
- import { Project as Project2, SyntaxKind as SyntaxKind9 } from "ts-morph";
1824
+ import { Project as Project2, SyntaxKind as SyntaxKind10 } from "ts-morph";
1541
1825
  var LAMPORTS_PER_BYTE_YEAR = 3480;
1542
1826
  var EXEMPTION_THRESHOLD = 2;
1543
1827
  var OVERHEAD_BYTES = 128;
@@ -1618,11 +1902,11 @@ var KNOWN_FLOWS = [
1618
1902
  }
1619
1903
  ];
1620
1904
  var LOOP_NODE_KINDS = /* @__PURE__ */ new Set([
1621
- SyntaxKind9.ForStatement,
1622
- SyntaxKind9.ForOfStatement,
1623
- SyntaxKind9.ForInStatement,
1624
- SyntaxKind9.WhileStatement,
1625
- SyntaxKind9.DoStatement
1905
+ SyntaxKind10.ForStatement,
1906
+ SyntaxKind10.ForOfStatement,
1907
+ SyntaxKind10.ForInStatement,
1908
+ SyntaxKind10.WhileStatement,
1909
+ SyntaxKind10.DoStatement
1626
1910
  ]);
1627
1911
  var ARRAY_METHOD_LOOPS = /* @__PURE__ */ new Set(["map", "forEach", "flatMap", "reduce", "filter"]);
1628
1912
  function isInsideLoop(node) {
@@ -1630,12 +1914,12 @@ function isInsideLoop(node) {
1630
1914
  while (cur) {
1631
1915
  const k = cur.getKind?.();
1632
1916
  if (k !== void 0 && LOOP_NODE_KINDS.has(k)) {
1633
- return { scalable: true, note: `call is inside a ${SyntaxKind9[k]} \u2014 cost scales with loop iterations` };
1917
+ return { scalable: true, note: `call is inside a ${SyntaxKind10[k]} \u2014 cost scales with loop iterations` };
1634
1918
  }
1635
- if (k === SyntaxKind9.CallExpression) {
1919
+ if (k === SyntaxKind10.CallExpression) {
1636
1920
  const expr = cur.getExpression?.();
1637
- if (expr?.getKind?.() === SyntaxKind9.PropertyAccessExpression) {
1638
- const name = expr.asKind?.(SyntaxKind9.PropertyAccessExpression)?.getName?.();
1921
+ if (expr?.getKind?.() === SyntaxKind10.PropertyAccessExpression) {
1922
+ const name = expr.asKind?.(SyntaxKind10.PropertyAccessExpression)?.getName?.();
1639
1923
  if (name && ARRAY_METHOD_LOOPS.has(name)) {
1640
1924
  return { scalable: true, note: `call is inside .${name}() \u2014 cost scales with array length` };
1641
1925
  }
@@ -1649,7 +1933,7 @@ function detectPriorityFee(targetDir) {
1649
1933
  const project = new Project2({ skipAddingFilesFromTsConfig: true });
1650
1934
  for (const file of walk(targetDir)) {
1651
1935
  const sf = project.addSourceFileAtPath(file);
1652
- const calls = sf.getDescendantsOfKind(SyntaxKind9.CallExpression);
1936
+ const calls = sf.getDescendantsOfKind(SyntaxKind10.CallExpression);
1653
1937
  for (const ce of calls) {
1654
1938
  const expr = ce.getExpression();
1655
1939
  const text = expr.getText();
@@ -1677,13 +1961,13 @@ function detectAccountFlows(targetDir) {
1677
1961
  const importedModules = new Set(
1678
1962
  sf.getImportDeclarations().map((d) => d.getModuleSpecifierValue())
1679
1963
  );
1680
- for (const ce of sf.getDescendantsOfKind(SyntaxKind9.CallExpression)) {
1964
+ for (const ce of sf.getDescendantsOfKind(SyntaxKind10.CallExpression)) {
1681
1965
  const expr = ce.getExpression();
1682
1966
  let callName5 = null;
1683
- if (expr.getKind() === SyntaxKind9.Identifier) {
1967
+ if (expr.getKind() === SyntaxKind10.Identifier) {
1684
1968
  callName5 = expr.getText();
1685
- } else if (expr.getKind() === SyntaxKind9.PropertyAccessExpression) {
1686
- callName5 = expr.asKind(SyntaxKind9.PropertyAccessExpression).getName();
1969
+ } else if (expr.getKind() === SyntaxKind10.PropertyAccessExpression) {
1970
+ callName5 = expr.asKind(SyntaxKind10.PropertyAccessExpression).getName();
1687
1971
  }
1688
1972
  if (!callName5) continue;
1689
1973
  const known = callIndex.get(callName5);
@@ -1779,10 +2063,100 @@ function renderCostReportMd(r) {
1779
2063
  return lines.join("\n");
1780
2064
  }
1781
2065
 
2066
+ // src/watch.ts
2067
+ import { watch as fsWatch } from "fs";
2068
+ function runIncrementalScan(targetDir, rules2, emit) {
2069
+ const start = Date.now();
2070
+ let changedRanges;
2071
+ try {
2072
+ changedRanges = getWorkingTreeChanges(targetDir);
2073
+ } catch (e) {
2074
+ emit({ type: "scan_error", message: e?.message ?? String(e) });
2075
+ return;
2076
+ }
2077
+ if (changedRanges.size === 0) {
2078
+ emit({ type: "scan_complete", filesChanged: 0, findings: 0, durationMs: Date.now() - start });
2079
+ return;
2080
+ }
2081
+ const { checks } = audit(targetDir, rules2, changedRanges);
2082
+ let findings = 0;
2083
+ for (const c of checks) {
2084
+ if (c.result === "pass") continue;
2085
+ findings++;
2086
+ emit({
2087
+ type: "finding",
2088
+ ruleId: c.ruleId,
2089
+ severity: c.severity,
2090
+ result: c.result,
2091
+ file: c.file,
2092
+ line: c.line,
2093
+ detail: c.detail,
2094
+ ...c.fix ? { fix: c.fix } : {}
2095
+ });
2096
+ }
2097
+ emit({ type: "scan_complete", filesChanged: changedRanges.size, findings, durationMs: Date.now() - start });
2098
+ }
2099
+ function startWatch(targetDir, opts = {}) {
2100
+ const debounceMs = opts.debounceMs ?? 300;
2101
+ const emit = opts.emit ?? ((e) => process.stdout.write(JSON.stringify(e) + "\n"));
2102
+ const rules2 = resolveRules(targetDir);
2103
+ let timer;
2104
+ const scheduleScan = () => {
2105
+ if (timer) clearTimeout(timer);
2106
+ timer = setTimeout(() => runIncrementalScan(targetDir, rules2, emit), debounceMs);
2107
+ };
2108
+ const watcher = fsWatch(targetDir, { recursive: true }, (_event, filename) => {
2109
+ if (!filename) return;
2110
+ const parts = filename.split(/[\\/]/);
2111
+ if (parts.some((p) => SKIP_DIRS.has(p))) return;
2112
+ scheduleScan();
2113
+ });
2114
+ emit({ type: "watch_started", targetDir });
2115
+ return {
2116
+ close: () => {
2117
+ if (timer) clearTimeout(timer);
2118
+ watcher.close();
2119
+ }
2120
+ };
2121
+ }
2122
+
2123
+ // src/fixers/applyDiff.ts
2124
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
2125
+ function parseDiff(diff) {
2126
+ const lines = diff.split("\n");
2127
+ const fileLine = lines.find((l) => l.startsWith("+++ b"));
2128
+ if (!fileLine) throw new Error("parseDiff: no '+++ b<path>' line found");
2129
+ const filePath = fileLine.slice("+++ b".length);
2130
+ const hunkLine = lines.find((l) => l.startsWith("@@"));
2131
+ if (!hunkLine) throw new Error("parseDiff: no hunk header found");
2132
+ const m = hunkLine.match(/^@@ -(\d+),(\d+) \+\d+,\d+ @@/);
2133
+ if (!m) throw new Error(`parseDiff: unrecognized hunk header '${hunkLine}'`);
2134
+ const oldStart = Number(m[1]);
2135
+ const oldCount = Number(m[2]);
2136
+ const newLines = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1));
2137
+ return { filePath, oldStart, oldCount, newLines };
2138
+ }
2139
+ function applyDiffToFile(diff) {
2140
+ const { filePath, oldStart, oldCount, newLines } = parseDiff(diff);
2141
+ const content = readFileSync7(filePath, "utf8");
2142
+ const fileLines = content.split("\n");
2143
+ const removedLines = diff.split("\n").filter((l) => l.startsWith("-") && !l.startsWith("---")).map((l) => l.slice(1));
2144
+ const actual = fileLines.slice(oldStart - 1, oldStart - 1 + oldCount);
2145
+ if (JSON.stringify(actual) !== JSON.stringify(removedLines)) return false;
2146
+ fileLines.splice(oldStart - 1, oldCount, ...newLines);
2147
+ writeFileSync2(filePath, fileLines.join("\n"));
2148
+ return true;
2149
+ }
2150
+
1782
2151
  export {
1783
2152
  findCandidates,
2153
+ findConfigCandidates,
1784
2154
  runChecker,
1785
2155
  checkerKinds,
2156
+ getChangedRanges,
2157
+ getWorkingTreeChanges,
2158
+ fileChanged,
2159
+ rangeChanged,
1786
2160
  auditWithRule,
1787
2161
  audit,
1788
2162
  renderTest,
@@ -1808,5 +2182,9 @@ export {
1808
2182
  rentExemptMinimum,
1809
2183
  lamportsToSol,
1810
2184
  analyzeCosts,
1811
- renderCostReportMd
2185
+ renderCostReportMd,
2186
+ runIncrementalScan,
2187
+ startWatch,
2188
+ parseDiff,
2189
+ applyDiffToFile
1812
2190
  };