• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tudasc / TypeART / 24399715246

14 Apr 2026 12:46PM UTC coverage: 90.246% (+1.3%) from 88.924%
24399715246

push

github

web-flow
Merge PR #187 from tudasc/devel

880 of 935 new or added lines in 31 files covered. (94.12%)

18 existing lines in 4 files now uncovered.

4885 of 5413 relevant lines covered (90.25%)

38924.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.45
/lib/passes/analysis/MemInstFinder.cpp
1
// TypeART library
2
//
3
// Copyright (c) 2017-2026 TypeART Authors
4
// Distributed under the BSD 3-Clause license.
5
// (See accompanying file LICENSE.txt or copy at
6
// https://opensource.org/licenses/BSD-3-Clause)
7
//
8
// Project home: https://github.com/tudasc/TypeART
9
//
10
// SPDX-License-Identifier: BSD-3-Clause
11
//
12

13
#include "MemInstFinder.h"
14

15
#include "MemOpVisitor.h"
16
#include "TypeARTConfiguration.h"
17
#include "analysis/MemOpData.h"
18
#include "configuration/Configuration.h"
19
#include "configuration/TypeARTOptions.h"
20
#include "filter/CGForwardFilter.h"
21
#include "filter/CGInterface.h"
22
#include "filter/Filter.h"
23
#include "filter/Matcher.h"
24
#include "filter/StdForwardFilter.h"
25
#include "support/ConfigurationBase.h"
26
#include "support/Logger.h"
27
#include "support/Table.h"
28
#include "support/TypeUtil.h"
29
#include "support/Util.h"
30

31
#include "llvm/ADT/STLExtras.h"
32
#include "llvm/ADT/Statistic.h"
33
#include "llvm/ADT/StringRef.h"
34
#include "llvm/IR/BasicBlock.h"
35
#include "llvm/IR/DerivedTypes.h"
36
#include "llvm/IR/Function.h"
37
#include "llvm/IR/GlobalValue.h"
38
#include "llvm/IR/Instructions.h"
39
#include "llvm/IR/Module.h"
40
#include "llvm/IR/Type.h"
41
#include "llvm/Support/Casting.h"
42
#include "llvm/Support/raw_ostream.h"
43

44
#include <algorithm>
45
#include <cstdlib>
46
#include <llvm/ADT/ScopeExit.h>
47
#include <sstream>
48
#include <string>
49
#include <utility>
50

51
using namespace llvm;
52

53
#define DEBUG_TYPE "MemInstFinder"
54
ALWAYS_ENABLED_STATISTIC(NumDetectedHeap, "Number of detected heap allocs");
55
ALWAYS_ENABLED_STATISTIC(NumFilteredDetectedHeap, "Number of filtered heap allocs");
56
ALWAYS_ENABLED_STATISTIC(NumDetectedAllocs, "Number of detected allocs");
57
ALWAYS_ENABLED_STATISTIC(NumFilteredPointerAllocs, "Number of filtered pointer allocs");
58
ALWAYS_ENABLED_STATISTIC(NumCallFilteredAllocs, "Number of call filtered allocs");
59
ALWAYS_ENABLED_STATISTIC(NumFilteredMallocAllocs, "Number of  filtered  malloc-related allocs");
60
ALWAYS_ENABLED_STATISTIC(NumFilteredNonArrayAllocs, "Number of filtered non-array allocs");
61
ALWAYS_ENABLED_STATISTIC(NumDetectedGlobals, "Number of detected globals");
62
ALWAYS_ENABLED_STATISTIC(NumFilteredGlobals, "Number of filtered globals");
63
ALWAYS_ENABLED_STATISTIC(NumCallFilteredGlobals, "Number of filtered globals");
64

65
namespace typeart::analysis {
66

67
using MemInstFinderConfig = config::Configuration;
68

69
namespace filter {
70
class CallFilter {
71
  std::unique_ptr<typeart::filter::Filter> fImpl;
72

73
 public:
74
  explicit CallFilter(const MemInstFinderConfig& config);
75
  CallFilter(const CallFilter&) = delete;
76
  CallFilter(CallFilter&&)      = default;
77
  bool operator()(llvm::AllocaInst*);
78
  bool operator()(llvm::GlobalValue*);
79
  CallFilter& operator=(CallFilter&&) noexcept;
80
  CallFilter& operator=(const CallFilter&) = delete;
81
  virtual ~CallFilter();
82
};
83

84
}  // namespace filter
85

86
namespace filter {
87

88
namespace detail {
89
static std::unique_ptr<typeart::filter::Filter> make_filter(const MemInstFinderConfig& config) {
5,730✔
90
  using namespace typeart::filter;
91
  const bool filter                    = config[config::ConfigStdArgs::filter];
5,730✔
92
  const FilterImplementation filter_id = config[config::ConfigStdArgs::filter_impl];
5,730✔
93
  const std::string glob               = config[config::ConfigStdArgs::filter_glob];
5,730✔
94

95
  if (filter_id == FilterImplementation::none || !filter) {
5,730✔
96
    LOG_DEBUG("Return no-op filter")
97
    return std::make_unique<NoOpFilter>();
5,104✔
98
  } else if (filter_id == FilterImplementation::cg) {
626✔
99
    const std::string cg_file = config[config::ConfigStdArgs::filter_cg_file];
58✔
100
    if (cg_file.empty()) {
58✔
101
      LOG_FATAL("CG File not set!");
×
102
      std::exit(1);
×
103
    }
104
    LOG_DEBUG("Return CG filter with CG file @ " << cg_file)
105
    auto json_cg = JSONCG::getJSON(cg_file);
58✔
106
    auto matcher = std::make_unique<DefaultStringMatcher>(util::glob2regex(glob));
58✔
107
    return std::make_unique<CGForwardFilter>(glob, std::move(json_cg), std::move(matcher));
58✔
108
  } else {
58✔
109
    LOG_DEBUG("Return default filter")
110
    auto matcher         = std::make_unique<DefaultStringMatcher>(util::glob2regex(glob));
568✔
111
    const auto deep_glob = config[config::ConfigStdArgs::filter_glob_deep];
568✔
112
    auto deep_matcher    = std::make_unique<DefaultStringMatcher>(util::glob2regex(deep_glob));
568✔
113
    return std::make_unique<StandardForwardFilter>(std::move(matcher), std::move(deep_matcher));
568✔
114
  }
568✔
115
}
5,730✔
116
}  // namespace detail
117

118
CallFilter::CallFilter(const MemInstFinderConfig& config) : fImpl{detail::make_filter(config)} {
5,730✔
119
}
5,730✔
120

121
bool CallFilter::operator()(AllocaInst* allocation) {
4,353✔
122
  LOG_DEBUG("Analyzing value: " << util::dump(*allocation));
123
  fImpl->setMode(/*search mallocs = */ false);
4,353✔
124
  fImpl->setStartingFunction(allocation->getParent()->getParent());
4,353✔
125
  const auto filter_ = fImpl->filter(allocation);
4,353✔
126
  if (filter_) {
4,353✔
127
    LOG_DEBUG("Filtering value: " << util::dump(*allocation) << "\n");
128
  } else {
3,711✔
129
    LOG_DEBUG("Keeping value: " << util::dump(*allocation) << "\n");
130
  }
131
  return filter_;
4,353✔
132
}
133

134
bool CallFilter::operator()(GlobalValue* global_value) {
23,274✔
135
  LOG_DEBUG("Analyzing value: " << util::dump(*global_value));
136
  fImpl->setMode(/*search mallocs = */ false);
23,274✔
137
  fImpl->setStartingFunction(nullptr);
23,274✔
138
  const auto filter_ = fImpl->filter(global_value);
23,274✔
139
  if (filter_) {
23,274✔
140
    LOG_DEBUG("Filtering value: " << util::dump(*global_value) << "\n");
141
  } else {
573✔
142
    LOG_DEBUG("Keeping value: " << util::dump(*global_value) << "\n");
143
  }
144
  return filter_;
23,274✔
145
}
146

147
CallFilter& CallFilter::operator=(CallFilter&&) noexcept = default;
×
148

149
CallFilter::~CallFilter() = default;
5,730✔
150

151
}  // namespace filter
152

153
class MemInstFinderPass : public MemInstFinder {
154
 private:
155
  MemOpVisitor mOpsCollector;
156
  filter::CallFilter filter;
157
  llvm::DenseMap<const llvm::Function*, FunctionData> functionMap;
158
  const MemInstFinderConfig& config;
159

160
 public:
161
  explicit MemInstFinderPass(const MemInstFinderConfig&);
162
  bool runOnModule(llvm::Module&) override;
163
  [[nodiscard]] bool hasFunctionData(const llvm::Function&) const override;
164
  [[nodiscard]] const FunctionData& getFunctionData(const llvm::Function&) const override;
165
  const GlobalDataList& getModuleGlobals() const override;
166
  void printStats(llvm::raw_ostream&) const override;
167
  // void configure(MemInstFinderConfig&) override;
168
  ~MemInstFinderPass() override = default;
11,460✔
169

170
 private:
171
  bool runOnFunction(llvm::Function&);
172
};
173

174
MemInstFinderPass::MemInstFinderPass(const MemInstFinderConfig& conf_)
13,148✔
175
    : mOpsCollector(conf_), filter(conf_), config(conf_) {
13,148✔
176
}
5,730✔
177

178
bool MemInstFinderPass::runOnModule(Module& module) {
5,730✔
179
  mOpsCollector.collectGlobals(module);
5,730✔
180
  auto& globals = mOpsCollector.globals;
5,730✔
181
  NumDetectedGlobals += globals.size();
5,730✔
182
  if (config[config::ConfigStdArgs::analysis_filter_global]) {
5,730✔
183
    globals.erase(
11,460✔
184
        llvm::remove_if(
5,730✔
185
            globals,
5,730✔
186
            [&](const auto gdata) {  // NOLINT
207,445✔
187
              GlobalVariable* global = gdata.global;
207,445✔
188
              const auto name        = global->getName();
207,445✔
189

190
              LOG_DEBUG("Analyzing global: " << name);
191

192
              if (name.empty()) {
207,445✔
193
                return true;
151,289✔
194
              }
195

196
              if (util::starts_with_any_of(name, "llvm.", "__llvm_gcov", "__llvm_gcda", "__profn", "___asan", "__msan",
56,156✔
197
                                           "__tsan", "__typeart", "_typeart", "__tysan", "__dfsan", "__profc")) {
198
                LOG_DEBUG("Prefixed matched on " << name)
199
                return true;
31,088✔
200
              }
201

202
              if (global->hasInitializer()) {
25,068✔
203
                auto* ini            = global->getInitializer();
23,286✔
204
                std::string ini_name = util::dump(*ini);
23,286✔
205

206
                if (llvm::StringRef(ini_name).contains("std::ios_base::Init")) {
23,286✔
207
                  LOG_DEBUG("std::ios");
NEW
208
                  return true;
×
209
                }
210
              }
23,286✔
211

212
              if (global->hasSection()) {
25,068✔
213
                // for instance, filters:
214
                //   a) (Coverage) -fprofile-instr-generate -fcoverage-mapping
215
                //   b) (PGO) -fprofile-instr-generate
216
                StringRef Section = global->getSection();
12✔
217
                // Globals from llvm.metadata aren't emitted, do not instrument them.
218
                if (Section == "llvm.metadata") {
12✔
219
                  LOG_DEBUG("metadata");
NEW
220
                  return true;
×
221
                }
222
                // Do not instrument globals from special LLVM sections.
223
                if (Section.find("__llvm") != StringRef::npos || Section.find("__LLVM") != StringRef::npos) {
12✔
224
                  LOG_DEBUG("llvm section");
225
                  return true;
12✔
226
                }
NEW
227
              }
×
228

229
              if ((global->getLinkage() == GlobalValue::ExternalLinkage && global->isDeclaration())) {
25,056✔
230
                LOG_DEBUG("Linkage: External");
231
                return true;
1,782✔
232
              }
233

234
              Type* global_type = global->getValueType();
23,274✔
235
              if (!global_type->isSized()) {
23,274✔
236
                LOG_DEBUG("not sized");
NEW
237
                return true;
×
238
              }
239

240
              if (global_type->isArrayTy()) {
23,274✔
241
                global_type = global_type->getArrayElementType();
21,473✔
242
              }
21,473✔
243
              if (auto structType = dyn_cast<StructType>(global_type)) {
23,274✔
244
                if (structType->isOpaque()) {
1,375✔
245
                  LOG_DEBUG("Encountered opaque struct " << global_type->getStructName() << " - skipping...");
NEW
246
                  return true;
×
247
                }
248
              }
1,375✔
249
              return false;
23,274✔
250
            }),
207,445✔
251
        globals.end());
5,730✔
252

253
    const auto beforeCallFilter = globals.size();
5,730✔
254
    NumFilteredGlobals          = NumDetectedGlobals - beforeCallFilter;
5,730✔
255

256
    globals.erase(llvm::remove_if(globals, [&](const auto global) { return filter(global.global); }), globals.end());
29,004✔
257

258
    NumCallFilteredGlobals = beforeCallFilter - globals.size();
5,730✔
259
    NumFilteredGlobals += NumCallFilteredGlobals;
5,730✔
260
  }
5,730✔
261

262
  return llvm::count_if(module.functions(), [&](auto& function) { return runOnFunction(function); }) > 0;
168,905✔
263
}  // namespace typeart
×
264

265
bool MemInstFinderPass::runOnFunction(llvm::Function& function) {
163,175✔
266
  if (function.isDeclaration() || util::starts_with_any_of(function.getName(), "__typeart")) {
163,175✔
267
    return false;
126,988✔
268
  }
269

270
  LOG_DEBUG("Running on function: " << function.getName())
271

272
  mOpsCollector.collect(function);
36,187✔
273

274
#if LLVM_VERSION_MAJOR < 15
275
  const auto checkAmbigiousMalloc = [&function](const MallocData& mallocData) {
12,187✔
276
    using namespace typeart::util::type;
277
    auto primaryBitcast = mallocData.primary;
929✔
278
    if (primaryBitcast != nullptr) {
929✔
279
      const auto& bitcasts = mallocData.bitcasts;
815✔
280
      std::for_each(bitcasts.begin(), bitcasts.end(), [&](auto bitcastInst) {
1,699✔
281
        auto dest = bitcastInst->getDestTy();
884✔
282
        if (bitcastInst != primaryBitcast &&
896✔
283
            (!isVoidPtr(dest) && !isi64Ptr(dest) &&
69✔
284
             primaryBitcast->getDestTy() != dest)) {  // void* and i64* are used by LLVM
12✔
285
          // Second non-void* bitcast detected - semantics unclear
286
          LOG_WARNING("Encountered ambiguous pointer type in function: " << util::try_demangle(function));
12✔
287
          LOG_WARNING("  Allocation" << util::dump(*(mallocData.call)));
12✔
288
          LOG_WARNING("  Primary cast: " << util::dump(*primaryBitcast));
12✔
289
          LOG_WARNING("  Secondary cast: " << util::dump(*bitcastInst));
12✔
290
        }
12✔
291
      });
884✔
292
    }
815✔
293
  };
929✔
294
#endif
295

296
  NumDetectedAllocs += mOpsCollector.allocas.size();
36,187✔
297

298
  if (config[config::ConfigStdArgs::analysis_filter_alloca_non_array]) {
36,187✔
299
    auto& allocs = mOpsCollector.allocas;
672✔
300
    allocs.erase(llvm::remove_if(allocs,
1,344✔
301
                                 [&](const auto& data) {
1,911✔
302
                                   if (!data.alloca->getAllocatedType()->isArrayTy() && data.array_size == 1) {
1,911✔
303
                                     ++NumFilteredNonArrayAllocs;
1,848✔
304
                                     return true;
1,848✔
305
                                   }
306
                                   return false;
63✔
307
                                 }),
1,911✔
308
                 allocs.end());
672✔
309
  }
672✔
310

311
  if (config[config::ConfigStdArgs::analysis_filter_heap_alloc]) {
36,187✔
312
    auto& allocs  = mOpsCollector.allocas;
45✔
313
    auto& mallocs = mOpsCollector.mallocs;
45✔
314

315
    const auto filterMallocAllocPairing = [&mallocs](const auto alloc) {
75✔
316
      // Only look for the direct users of the alloc:
317
      // TODO is a deeper analysis required?
318
      for (auto inst : alloc->users()) {
108✔
319
        if (StoreInst* store = dyn_cast<StoreInst>(inst)) {
84✔
320
          const auto source = store->getValueOperand();
30✔
321
          if (isa<BitCastInst>(source)) {
30✔
322
            for (auto& mdata : mallocs) {
18✔
323
              // is it a bitcast we already collected? if yes, we can filter the alloc
324
              return std::any_of(mdata.bitcasts.begin(), mdata.bitcasts.end(),
12✔
325
                                 [&source](const auto bcast) { return bcast == source; });
12✔
326
            }
327
          } else if (isa<CallInst>(source)) {
24✔
328
            return std::any_of(mallocs.begin(), mallocs.end(),
×
329
                               [&source](const auto& mdata) { return mdata.call == source; });
×
330
          }
331
        }
24✔
332
      }
333
      return false;
24✔
334
    };
30✔
335

336
    allocs.erase(llvm::remove_if(allocs,
135✔
337
                                 [&](const auto& data) {
75✔
338
                                   if (filterMallocAllocPairing(data.alloca)) {
30✔
339
                                     ++NumFilteredMallocAllocs;
6✔
340
                                     return true;
6✔
341
                                   }
342
                                   return false;
24✔
343
                                 }),
30✔
344
                 allocs.end());
45✔
345
  }
45✔
346

347
  if (config[config::ConfigStdArgs::analysis_filter_pointer_alloc]) {
36,187✔
348
    auto& allocs = mOpsCollector.allocas;
35,176✔
349
    allocs.erase(llvm::remove_if(allocs,
70,352✔
350
                                 [&](const auto& data) {
31,866✔
351
                                   auto alloca = data.alloca;
31,866✔
352
                                   if (!data.is_vla && isa<llvm::PointerType>(alloca->getAllocatedType())) {
31,866✔
353
                                     ++NumFilteredPointerAllocs;
13,619✔
354
                                     return true;
13,619✔
355
                                   }
356
                                   return false;
18,247✔
357
                                 }),
31,866✔
358
                 allocs.end());
35,176✔
359
  }
35,176✔
360

361
  // if (config.filter.useCallFilter) {
362
  if (config[config::ConfigStdArgs::filter]) {
36,187✔
363
    auto& allocs = mOpsCollector.allocas;
2,235✔
364
    allocs.erase(llvm::remove_if(allocs,
6,705✔
365
                                 [&](const auto& data) {
6,588✔
366
                                   if (filter(data.alloca)) {
4,353✔
367
                                     ++NumCallFilteredAllocs;
3,711✔
368
                                     return true;
3,711✔
369
                                   }
370
                                   return false;
642✔
371
                                 }),
4,353✔
372
                 allocs.end());
2,235✔
373
    //    LOG_DEBUG(allocs.size() << " allocas to instrument : " << util::dump(allocs));
374
  }
2,235✔
375

376
  auto& mallocs = mOpsCollector.mallocs;
36,187✔
377
  NumDetectedHeap += mallocs.size();
36,187✔
378

379
#if LLVM_VERSION_MAJOR < 15
380
  for (const auto& mallocData : mallocs) {
12,187✔
381
    checkAmbigiousMalloc(mallocData);
929✔
382
  }
383
#endif
384

385
  FunctionData data{mOpsCollector.mallocs, mOpsCollector.frees, mOpsCollector.allocas};
36,187✔
386
  functionMap[&function] = data;
36,187✔
387

388
  mOpsCollector.clear();
36,187✔
389

390
  return true;
36,187✔
391
}  // namespace typeart
163,175✔
392

393
void MemInstFinderPass::printStats(llvm::raw_ostream& out) const {
5,667✔
394
#if LLVM_VERSION_MAJOR < 22
395
  const auto scope_exit_cleanup_counter = llvm::make_scope_exit([&]() {
9,740✔
396
#else
397
  llvm::scope_exit scope_exit_cleanup_counter([&]() {
1,594✔
398
#endif
399
    NumDetectedAllocs         = 0;
5,667✔
400
    NumFilteredNonArrayAllocs = 0;
5,667✔
401
    NumFilteredMallocAllocs   = 0;
5,667✔
402
    NumCallFilteredAllocs     = 0;
5,667✔
403
    NumFilteredPointerAllocs  = 0;
5,667✔
404
    NumDetectedHeap           = 0;
5,667✔
405
    NumFilteredGlobals        = 0;
5,667✔
406
    NumDetectedGlobals        = 0;
5,667✔
407
  });
5,667✔
408
  auto all_stack            = double(NumDetectedAllocs);
5,667✔
409
  auto nonarray_stack       = double(NumFilteredNonArrayAllocs);
5,667✔
410
  auto malloc_alloc_stack   = double(NumFilteredMallocAllocs);
5,667✔
411
  auto call_filter_stack    = double(NumCallFilteredAllocs);
5,667✔
412
  auto filter_pointer_stack = double(NumFilteredPointerAllocs);
5,667✔
413

414
  const auto call_filter_stack_p =
5,667✔
415
      (call_filter_stack /
5,667✔
416
       std::max<double>(1.0, all_stack - nonarray_stack - malloc_alloc_stack - filter_pointer_stack)) *
5,667✔
417
      100.0;
418

419
  const auto call_filter_heap_p =
5,667✔
420
      (double(NumFilteredDetectedHeap) / std::max<double>(1.0, double(NumDetectedHeap))) * 100.0;
5,667✔
421

422
  const auto call_filter_global_p =
5,667✔
423
      (double(NumCallFilteredGlobals) / std::max(1.0, double(NumDetectedGlobals))) * 100.0;
5,667✔
424

425
  const auto call_filter_global_nocallfilter_p =
5,667✔
426
      (double(NumFilteredGlobals) / std::max(1.0, double(NumDetectedGlobals))) * 100.0;
5,667✔
427

428
  Table stats("MemInstFinderPass");
5,667✔
429
  stats.wrap_header_ = true;
5,667✔
430
  stats.wrap_length_ = true;
5,667✔
431
  std::string glob   = config[config::ConfigStdArgs::filter_glob];
5,667✔
432
  stats.put(Row::make("Filter string", glob));
5,667✔
433
  stats.put(Row::make_row("> Heap Memory"));
5,667✔
434
  stats.put(Row::make("Heap alloc", NumDetectedHeap.getValue()));
5,667✔
435
  stats.put(Row::make("Heap call filtered %", call_filter_heap_p));
5,667✔
436
  stats.put(Row::make_row("> Stack Memory"));
5,667✔
437
  stats.put(Row::make("Alloca", all_stack));
5,667✔
438
  stats.put(Row::make("Stack call filtered %", call_filter_stack_p));
5,667✔
439
  stats.put(Row::make("Alloca of pointer discarded", filter_pointer_stack));
5,667✔
440
  stats.put(Row::make_row("> Global Memory"));
5,667✔
441
  stats.put(Row::make("Global", NumDetectedGlobals.getValue()));
5,667✔
442
  stats.put(Row::make("Global filter total", NumFilteredGlobals.getValue()));
5,667✔
443
  stats.put(Row::make("Global call filtered %", call_filter_global_p));
5,667✔
444
  stats.put(Row::make("Global filtered %", call_filter_global_nocallfilter_p));
5,667✔
445

446
  std::ostringstream stream;
5,667✔
447
  stats.print(stream);
5,667✔
448
  out << stream.str();
5,667✔
449
}
5,667✔
450

451
bool MemInstFinderPass::hasFunctionData(const Function& function) const {
36,175✔
452
  auto iter = functionMap.find(&function);
36,175✔
453
  return iter != functionMap.end();
36,175✔
454
}
455

456
const FunctionData& MemInstFinderPass::getFunctionData(const Function& function) const {
36,175✔
457
  auto iter = functionMap.find(&function);
36,175✔
458
  return iter->second;
36,175✔
459
}
460

461
const GlobalDataList& MemInstFinderPass::getModuleGlobals() const {
2,753✔
462
  return mOpsCollector.globals;
2,753✔
463
}
464

465
std::unique_ptr<MemInstFinder> create_finder(const config::Configuration& config) {
5,730✔
466
  LOG_DEBUG("Constructing MemInstFinder")
467
  // const auto meminst_conf = config::helper::config_to_options(config);
468
  return std::make_unique<MemInstFinderPass>(config);
5,730✔
469
}
470

471
}  // namespace typeart::analysis
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc