technical-debt-radar 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +197 -29
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -87,7 +87,9 @@ var require_constants = __commonJS({
87
87
  EMPTY_CATCH_BLOCK: "empty-catch-block",
88
88
  MISSING_ERROR_LOGGING: "missing-error-logging",
89
89
  TRANSACTION_NO_TIMEOUT: "transaction-no-timeout",
90
- MISSING_NULL_GUARD: "missing-null-guard"
90
+ MISSING_NULL_GUARD: "missing-null-guard",
91
+ MISSING_DTO_VALIDATION: "missing-dto-validation",
92
+ UNGUARDED_ROUTE_TODO: "unguarded-route-todo"
91
93
  };
92
94
  exports2.PERFORMANCE_RULES = {
93
95
  UNBOUNDED_FIND_MANY: "unbounded-find-many",
@@ -98,7 +100,8 @@ var require_constants = __commonJS({
98
100
  MISSING_PAGINATION: "missing-pagination-endpoint",
99
101
  UNFILTERED_COUNT: "unfiltered-count-large-table",
100
102
  RAW_SQL_NO_LIMIT: "raw-sql-no-limit",
101
- RAW_SQL_UNSAFE: "raw-sql-unsafe"
103
+ RAW_SQL_UNSAFE: "raw-sql-unsafe",
104
+ DANGEROUS_DELETE_ALL: "dangerous-delete-all"
102
105
  };
103
106
  exports2.MAINTAINABILITY_RULES = {
104
107
  HIGH_COMPLEXITY: "high-complexity",
@@ -11265,7 +11268,7 @@ var require_volume_estimator = __commonJS({
11265
11268
  if (entryName !== "node_modules" && entryName !== "dist" && entryName !== ".git") {
11266
11269
  await scan(fullPath);
11267
11270
  }
11268
- } else if (entryName.endsWith(".model.ts") || entryName.endsWith(".model.js")) {
11271
+ } else if (entryName.endsWith(".model.ts") || entryName.endsWith(".model.js") || entryName.endsWith(".schema.ts") || entryName.endsWith(".schema.js")) {
11269
11272
  results.push(fullPath);
11270
11273
  }
11271
11274
  }
@@ -11313,10 +11316,13 @@ var require_volume_estimator = __commonJS({
11313
11316
  } catch {
11314
11317
  continue;
11315
11318
  }
11316
- if (!/mongoose\.model|new\s+Schema|new\s+mongoose\.Schema|\bmodel\s*\(\s*['"]/.test(content))
11319
+ if (!/mongoose\.model|new\s+Schema|new\s+mongoose\.Schema|\bmodel\s*\(\s*['"]|@Schema\s*\(/.test(content))
11317
11320
  continue;
11318
11321
  const models = parseMongooseModels(content);
11319
- for (const model of models) {
11322
+ const basename2 = path9.basename(filePath).replace(/\.(schema|model)\.(ts|js)$/, "");
11323
+ const filteredModels = models.length > 1 ? models.filter((m) => m.name.toLowerCase() === basename2.toLowerCase()) : models;
11324
+ const finalModels = filteredModels.length > 0 ? filteredModels : models;
11325
+ for (const model of finalModels) {
11320
11326
  let size = estimateModelSize(model.collectionName);
11321
11327
  if (model.hasCreatedAtIndex) {
11322
11328
  if (size === "S" || size === "M") {
@@ -11332,10 +11338,14 @@ var require_volume_estimator = __commonJS({
11332
11338
  }
11333
11339
  function parseMongooseModels(content) {
11334
11340
  const models = [];
11341
+ const seen = /* @__PURE__ */ new Set();
11335
11342
  const modelRegex = /(?:mongoose\.)?model\s*\(\s*['"](\w+)['"]/g;
11336
11343
  let match;
11337
11344
  while ((match = modelRegex.exec(content)) !== null) {
11338
11345
  const name = match[1];
11346
+ if (seen.has(name))
11347
+ continue;
11348
+ seen.add(name);
11339
11349
  const collectionName = defaultTableName(name);
11340
11350
  let hasCreatedAtIndex = false;
11341
11351
  if (/\.index\s*\(\s*\{[^}]*(createdAt|timestamp|created_at)[^}]*\}/i.test(content)) {
@@ -11346,6 +11356,22 @@ var require_volume_estimator = __commonJS({
11346
11356
  }
11347
11357
  models.push({ name, collectionName, hasCreatedAtIndex });
11348
11358
  }
11359
+ const schemaRegex = /@Schema\s*\([^)]*\)\s*(?:export\s+)?class\s+(\w+)/g;
11360
+ while ((match = schemaRegex.exec(content)) !== null) {
11361
+ const className = match[1];
11362
+ const name = className;
11363
+ if (seen.has(name))
11364
+ continue;
11365
+ seen.add(name);
11366
+ const collectionName = defaultTableName(name);
11367
+ let hasCreatedAtIndex = false;
11368
+ if (/@Schema\s*\(\s*\{[^}]*timestamps\s*:\s*true/.test(content)) {
11369
+ }
11370
+ if (/\.index\s*\(\s*\{[^}]*(createdAt|timestamp|created_at)[^}]*\}/i.test(content)) {
11371
+ hasCreatedAtIndex = true;
11372
+ }
11373
+ models.push({ name, collectionName, hasCreatedAtIndex });
11374
+ }
11349
11375
  return models;
11350
11376
  }
11351
11377
  function estimateModelSize(modelName) {
@@ -14007,6 +14033,26 @@ var require_runtime_risk_detector = __commonJS({
14007
14033
  }
14008
14034
  }
14009
14035
  });
14036
+ sourceFile.forEachDescendant((node) => {
14037
+ if (!ts_morph_1.Node.isCallExpression(node))
14038
+ return;
14039
+ const expr = node.getExpression();
14040
+ if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
14041
+ return;
14042
+ if (expr.getName() !== "map")
14043
+ return;
14044
+ const args = node.getArguments();
14045
+ if (args.length === 0)
14046
+ return;
14047
+ const callbackText = args[0].getText();
14048
+ if (!/\(\?[=!]|\.\*|\[.*?\]|\\[bBdDwWsS]/.test(callbackText))
14049
+ return;
14050
+ const fileText = sourceFile.getText();
14051
+ if (!/\$regex/.test(fileText))
14052
+ return;
14053
+ const fn = getEnclosingFunction(node, allFunctions);
14054
+ violations.push(makeViolation(shared_1.RUNTIME_RISK_RULES.REDOS_VULNERABLE_REGEX, filePath, node.getStartLineNumber(), "Dynamic regex construction from user input used with $regex \u2014 ReDoS vulnerability", policy, fn?.name, "Use a safe text search method (MongoDB $text index) instead of $regex with user input. Escape special regex characters."));
14055
+ });
14010
14056
  }
14011
14057
  function isRedosVulnerable(pattern) {
14012
14058
  const nestedQuantifier = /([+*])\)?[+*{]/;
@@ -14441,6 +14487,7 @@ var require_perf_pattern_detector = __commonJS({
14441
14487
  detectMissingPagination(sourceFile, file.path, fns, policy, violations);
14442
14488
  detectUnfilteredCount(sourceFile, file.path, fns, policy, violations);
14443
14489
  detectRawSqlNoLimit(sourceFile, file.path, fns, policy, violations);
14490
+ detectDangerousDeleteAll(sourceFile, file.path, fns, policy, violations);
14444
14491
  project.removeSourceFile(sourceFile);
14445
14492
  }
14446
14493
  return applyExceptions(violations, policy);
@@ -14686,14 +14733,26 @@ var require_perf_pattern_detector = __commonJS({
14686
14733
  if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
14687
14734
  return void 0;
14688
14735
  const obj = expr.getExpression();
14689
- if (!ts_morph_1.Node.isIdentifier(obj))
14690
- return void 0;
14691
- const name = obj.getText();
14692
- if (!/^[A-Z]/.test(name))
14693
- return void 0;
14694
- if (/^(Promise|Array|Object|String|Number|Boolean|Buffer|JSON|Math|Date|Map|Set|Error|RegExp|console|process|Sequelize|DataTypes)$/.test(name))
14695
- return void 0;
14696
- return name;
14736
+ if (ts_morph_1.Node.isIdentifier(obj)) {
14737
+ const name = obj.getText();
14738
+ if (!/^[A-Z]/.test(name))
14739
+ return void 0;
14740
+ if (/^(Promise|Array|Object|String|Number|Boolean|Buffer|JSON|Math|Date|Map|Set|Error|RegExp|console|process|Sequelize|DataTypes)$/.test(name))
14741
+ return void 0;
14742
+ return name;
14743
+ }
14744
+ if (ts_morph_1.Node.isPropertyAccessExpression(obj)) {
14745
+ const propName = obj.getName();
14746
+ const thisExpr = obj.getExpression();
14747
+ if (ts_morph_1.Node.isThisExpression(thisExpr) || ts_morph_1.Node.isIdentifier(thisExpr) && thisExpr.getText() === "this") {
14748
+ const match = propName.match(/^(\w+?)(Model|Repository|Repo|Service|Collection)$/i);
14749
+ if (match)
14750
+ return match[1].charAt(0).toUpperCase() + match[1].slice(1);
14751
+ if (/^[a-z]/.test(propName))
14752
+ return propName.charAt(0).toUpperCase() + propName.slice(1);
14753
+ }
14754
+ }
14755
+ return void 0;
14697
14756
  }
14698
14757
  var SEQUELIZE_FIND_METHODS = /* @__PURE__ */ new Set([
14699
14758
  "findAll",
@@ -16059,6 +16118,25 @@ var require_perf_pattern_detector = __commonJS({
16059
16118
  function escapeRegex(str) {
16060
16119
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16061
16120
  }
16121
+ function detectDangerousDeleteAll(sourceFile, filePath, fns, policy, violations) {
16122
+ const DANGEROUS_DELETE_METHODS = /* @__PURE__ */ new Set(["deleteMany", "remove", "clear", "destroyAll"]);
16123
+ sourceFile.forEachDescendant((node) => {
16124
+ if (!ts_morph_1.Node.isCallExpression(node))
16125
+ return;
16126
+ const expr = node.getExpression();
16127
+ if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
16128
+ return;
16129
+ const methodName = expr.getName();
16130
+ if (!DANGEROUS_DELETE_METHODS.has(methodName))
16131
+ return;
16132
+ const args = node.getArguments();
16133
+ const hasFilter = args.length > 0 && !(ts_morph_1.Node.isObjectLiteralExpression(args[0]) && args[0].getProperties().length === 0);
16134
+ if (hasFilter)
16135
+ return;
16136
+ const fn = getEnclosingFn(node, fns);
16137
+ pushIfNotNull(violations, makeViolation(shared_1.PERFORMANCE_RULES.DANGEROUS_DELETE_ALL, filePath, node.getStartLineNumber(), `${methodName}() with empty or no filter \u2014 may delete all records`, policy, void 0, fn?.name, "Add a filter to deleteMany() or use deleteOne() \u2014 deleting all records is rarely intentional"));
16138
+ });
16139
+ }
16062
16140
  function applyExceptions(violations, policy) {
16063
16141
  return violations.filter((v) => !policy.exceptions.some((ex) => ex.isActive && ex.rule === v.ruleId && v.file.includes(ex.file)));
16064
16142
  }
@@ -16092,6 +16170,8 @@ var require_reliability_detector = __commonJS({
16092
16170
  detectMissingErrorLogging(sourceFile, file.path, fns, policy, violations);
16093
16171
  detectTransactionNoTimeout(sourceFile, file.path, fns, policy, violations);
16094
16172
  detectMissingNullGuard(sourceFile, file.path, fns, policy, violations);
16173
+ detectMissingDtoValidation(sourceFile, file.path, fns, policy, violations);
16174
+ detectUnguardedRouteTodo(sourceFile, file.path, fns, policy, violations);
16095
16175
  project.removeSourceFile(sourceFile);
16096
16176
  }
16097
16177
  return applyExceptions(violations, policy);
@@ -16548,25 +16628,53 @@ var require_reliability_detector = __commonJS({
16548
16628
  }
16549
16629
  return false;
16550
16630
  }
16631
+ var SDK_CLIENTS_NO_TIMEOUT = /* @__PURE__ */ new Set([
16632
+ "Replicate",
16633
+ "OpenAI",
16634
+ "Anthropic",
16635
+ "Stripe",
16636
+ "Twilio",
16637
+ "SendGrid"
16638
+ ]);
16551
16639
  function detectExternalCallNoTimeout(sourceFile, filePath, fns, policy, violations) {
16552
16640
  sourceFile.forEachDescendant((node) => {
16553
- if (!ts_morph_1.Node.isCallExpression(node))
16554
- return;
16555
- let callType = null;
16556
- for (const [name, matcher] of Object.entries(EXTERNAL_CALL_PATTERNS)) {
16557
- if (matcher(node)) {
16558
- callType = name;
16559
- break;
16641
+ if (ts_morph_1.Node.isCallExpression(node)) {
16642
+ let callType = null;
16643
+ for (const [name, matcher] of Object.entries(EXTERNAL_CALL_PATTERNS)) {
16644
+ if (matcher(node)) {
16645
+ callType = name;
16646
+ break;
16647
+ }
16560
16648
  }
16561
- }
16562
- if (!callType)
16563
- return;
16564
- if (hasTimeoutOption(node))
16565
- return;
16566
- if (isInsideRetryLoop(node))
16649
+ if (!callType)
16650
+ return;
16651
+ if (hasTimeoutOption(node))
16652
+ return;
16653
+ if (isInsideRetryLoop(node))
16654
+ return;
16655
+ const fn = getEnclosingFn(node, fns);
16656
+ violations.push(makeViolation(shared_1.RELIABILITY_RULES.EXTERNAL_CALL_NO_TIMEOUT, filePath, node.getStartLineNumber(), `${callType}() call without timeout \u2014 may hang indefinitely`, policy, fn?.name, `Add a timeout option or use AbortController with setTimeout`));
16567
16657
  return;
16568
- const fn = getEnclosingFn(node, fns);
16569
- violations.push(makeViolation(shared_1.RELIABILITY_RULES.EXTERNAL_CALL_NO_TIMEOUT, filePath, node.getStartLineNumber(), `${callType}() call without timeout \u2014 may hang indefinitely`, policy, fn?.name, `Add a timeout option or use AbortController with setTimeout`));
16658
+ }
16659
+ if (ts_morph_1.Node.isNewExpression(node)) {
16660
+ const expr = node.getExpression();
16661
+ if (!ts_morph_1.Node.isIdentifier(expr))
16662
+ return;
16663
+ const className = expr.getText();
16664
+ if (!SDK_CLIENTS_NO_TIMEOUT.has(className))
16665
+ return;
16666
+ const args = node.getArguments();
16667
+ const hasTimeout = args.some((arg) => {
16668
+ if (ts_morph_1.Node.isObjectLiteralExpression(arg)) {
16669
+ return /timeout/i.test(arg.getText());
16670
+ }
16671
+ return false;
16672
+ });
16673
+ if (hasTimeout)
16674
+ return;
16675
+ const fn = getEnclosingFn(node, fns);
16676
+ violations.push(makeViolation(shared_1.RELIABILITY_RULES.EXTERNAL_CALL_NO_TIMEOUT, filePath, node.getStartLineNumber(), `new ${className}() without timeout option \u2014 API calls may hang indefinitely`, policy, fn?.name, `Add a timeout option: new ${className}({ timeout: 30000, ... })`));
16677
+ }
16570
16678
  });
16571
16679
  }
16572
16680
  function isInsideRetryLoop(node) {
@@ -16816,6 +16924,58 @@ var require_reliability_detector = __commonJS({
16816
16924
  violations.push(makeViolation(shared_1.RELIABILITY_RULES.MISSING_NULL_GUARD, filePath, node.getStartLineNumber(), `Array index access '${varName}[${indexArg.getText()}]' without length guard \u2014 may throw on empty results`, policy, fn?.name, `Add a guard: if (${varName}.length === 0) throw new NotFoundException();`));
16817
16925
  });
16818
16926
  }
16927
+ function detectMissingDtoValidation(sourceFile, filePath, fns, policy, violations) {
16928
+ if (!filePath.includes("controller"))
16929
+ return;
16930
+ sourceFile.forEachDescendant((node) => {
16931
+ if (!ts_morph_1.Node.isMethodDeclaration(node))
16932
+ return;
16933
+ for (const param of node.getParameters()) {
16934
+ const decorators = param.getDecorators();
16935
+ const hasBody = decorators.some((d) => d.getName() === "Body");
16936
+ const hasQuery = decorators.some((d) => d.getName() === "Query");
16937
+ if (!hasBody && !hasQuery)
16938
+ continue;
16939
+ const typeNode = param.getTypeNode();
16940
+ if (!typeNode)
16941
+ continue;
16942
+ const typeText = typeNode.getText().trim();
16943
+ if (typeText !== "any")
16944
+ continue;
16945
+ const decName = hasBody ? "@Body()" : "@Query()";
16946
+ const fn = getEnclosingFn(node, fns);
16947
+ violations.push(makeViolation(shared_1.RELIABILITY_RULES.MISSING_DTO_VALIDATION, filePath, node.getStartLineNumber(), `${decName} parameter typed as 'any' \u2014 bypasses class-validator validation`, policy, fn?.name, "Create a DTO class with class-validator decorators and use it instead of any"));
16948
+ }
16949
+ });
16950
+ }
16951
+ var ROUTE_DECORATORS = /* @__PURE__ */ new Set(["Get", "Post", "Put", "Delete", "Patch", "All", "Head", "Options"]);
16952
+ var GUARD_TODO_PATTERN = /TODO|FIXME|HACK|guard|auth|security|protected/i;
16953
+ function detectUnguardedRouteTodo(sourceFile, filePath, fns, policy, violations) {
16954
+ if (!filePath.includes("controller"))
16955
+ return;
16956
+ sourceFile.forEachDescendant((node) => {
16957
+ if (!ts_morph_1.Node.isMethodDeclaration(node))
16958
+ return;
16959
+ const decorators = node.getDecorators();
16960
+ const isRoute = decorators.some((d) => ROUTE_DECORATORS.has(d.getName()));
16961
+ if (!isRoute)
16962
+ return;
16963
+ const fullText = node.getFullText();
16964
+ if (!GUARD_TODO_PATTERN.test(fullText))
16965
+ return;
16966
+ const hasGuard = decorators.some((d) => d.getName() === "UseGuards");
16967
+ if (hasGuard)
16968
+ return;
16969
+ const classNode = node.getParent();
16970
+ if (classNode && ts_morph_1.Node.isClassDeclaration(classNode)) {
16971
+ const classGuard = classNode.getDecorators().some((d) => d.getName() === "UseGuards");
16972
+ if (classGuard)
16973
+ return;
16974
+ }
16975
+ const fn = getEnclosingFn(node, fns);
16976
+ violations.push(makeViolation(shared_1.RELIABILITY_RULES.UNGUARDED_ROUTE_TODO, filePath, node.getStartLineNumber(), `Route handler '${node.getName?.() ?? "anonymous"}' has TODO/FIXME comment and no @UseGuards() \u2014 unprotected endpoint`, policy, fn?.name, "Add @UseGuards(JwtAuthGuard) or appropriate guard \u2014 this route is currently unprotected"));
16977
+ });
16978
+ }
16819
16979
  function escapeRegex(str) {
16820
16980
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16821
16981
  }
@@ -19560,7 +19720,15 @@ async function scanCommand(targetPath, options) {
19560
19720
  commit: getGitCommit(),
19561
19721
  duration: Date.now() - scanStart,
19562
19722
  usedAi: shouldRunAi,
19563
- aiCreditsUsed: 0
19723
+ aiCreditsUsed: 0,
19724
+ // PR metadata from CI environment (set by GitHub Action or GitLab CI)
19725
+ ...process.env.RADAR_PR_NUMBER ? {
19726
+ prNumber: parseInt(process.env.RADAR_PR_NUMBER, 10),
19727
+ prTitle: process.env.RADAR_PR_TITLE,
19728
+ prUrl: process.env.RADAR_PR_URL,
19729
+ platform: process.env.RADAR_PLATFORM ?? "github",
19730
+ mode
19731
+ } : {}
19564
19732
  };
19565
19733
  try {
19566
19734
  await client.reportScan(reportData);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "technical-debt-radar",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Stop Node.js production crashes before merge. 47 detection patterns across 5 categories.",
5
5
  "bin": {
6
6
  "radar": "dist/index.js",