aislop 0.1.0 → 0.1.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.
package/README.md CHANGED
@@ -326,6 +326,26 @@ npx aislop scan --staged
326
326
 
327
327
  ---
328
328
 
329
+ ## Telemetry
330
+
331
+ `aislop` collects anonymous usage analytics to help us understand how the tool is used and prioritize improvements. **No code, file paths, project names, or secrets are ever collected.**
332
+
333
+ What we collect: command run (scan/fix/ci), languages detected, score bucket, issue counts per engine, engine timing, OS, Node version, and aislop version.
334
+
335
+ Telemetry is **off in CI** by default. To opt out anywhere:
336
+
337
+ ```bash
338
+ # Environment variable (any of these)
339
+ AISLOP_NO_TELEMETRY=1 aislop scan
340
+ DO_NOT_TRACK=1 aislop scan
341
+
342
+ # Or in .aislop/config.yml
343
+ telemetry:
344
+ enabled: false
345
+ ```
346
+
347
+ ---
348
+
329
349
  ## Contributing
330
350
 
331
351
  See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project architecture, and how to add new rules or engines.
package/dist/cli.js CHANGED
@@ -51,7 +51,8 @@ const DEFAULT_CONFIG = {
51
51
  ci: {
52
52
  failBelow: 0,
53
53
  format: "json"
54
- }
54
+ },
55
+ telemetry: { enabled: true }
55
56
  };
56
57
  const DEFAULT_CONFIG_YAML = `version: 1
57
58
 
@@ -88,6 +89,9 @@ scoring:
88
89
  ci:
89
90
  failBelow: 0
90
91
  format: json
92
+
93
+ # telemetry:
94
+ # enabled: true # set to false to disable anonymous usage analytics
91
95
  `;
92
96
  const DEFAULT_RULES_YAML = `# Architecture rules (BYO)
93
97
  # Uncomment and customize to enforce your project's conventions.
@@ -148,6 +152,7 @@ const CiSchema = z.object({
148
152
  failBelow: z.number().default(0),
149
153
  format: z.enum(["json"]).default("json")
150
154
  });
155
+ const TelemetrySchema = z.object({ enabled: z.boolean().default(true) });
151
156
  const AislopConfigSchema = z.object({
152
157
  version: z.number().default(1),
153
158
  engines: EnginesSchema.default(() => ({
@@ -178,7 +183,8 @@ const AislopConfigSchema = z.object({
178
183
  ci: CiSchema.default(() => ({
179
184
  failBelow: 0,
180
185
  format: "json"
181
- }))
186
+ })),
187
+ telemetry: TelemetrySchema.default(() => ({ enabled: true }))
182
188
  });
183
189
  const defaults = AislopConfigSchema.parse({});
184
190
  /**
@@ -1380,17 +1386,21 @@ const isDataFile = (content) => {
1380
1386
  const dataLinePattern = /^\s*[{}[\]"']/;
1381
1387
  return nonEmpty.filter((l) => dataLinePattern.test(l)).length / nonEmpty.length > .8;
1382
1388
  };
1389
+ const ARROW_BLOCK_RE = /* @__PURE__ */ new RegExp("=>\\s*\\{");
1390
+ const ARROW_END_RE = /* @__PURE__ */ new RegExp("=>\\s*$");
1391
+ const BRACE_START_RE = /* @__PURE__ */ new RegExp("^\\s*\\{");
1392
+ const NEW_STATEMENT_RE = /* @__PURE__ */ new RegExp("^(?:export\\s+)?(?:const|let|var|function|class)\\s");
1383
1393
  const isBlockArrow = (lines, startIndex) => {
1384
- if (/=>\s*\{/.test(lines[startIndex])) return true;
1385
- if (/=>\s*$/.test(lines[startIndex])) {
1394
+ if (ARROW_BLOCK_RE.test(lines[startIndex])) return true;
1395
+ if (ARROW_END_RE.test(lines[startIndex])) {
1386
1396
  const next = lines[startIndex + 1];
1387
- if (next && /^\s*\{/.test(next)) return true;
1397
+ if (next && BRACE_START_RE.test(next)) return true;
1388
1398
  }
1389
1399
  for (let j = startIndex + 1; j < Math.min(startIndex + 3, lines.length); j++) {
1390
1400
  const l = lines[j];
1391
- if (l.trim() === "" || /^(?:export\s+)?(?:const|let|var|function|class)\s/.test(l.trim())) break;
1392
- if (/=>\s*\{/.test(l)) return true;
1393
- if (/^\s*\{/.test(l)) return true;
1401
+ if (l.trim() === "" || NEW_STATEMENT_RE.test(l.trim())) break;
1402
+ if (ARROW_BLOCK_RE.test(l)) return true;
1403
+ if (BRACE_START_RE.test(l)) return true;
1394
1404
  }
1395
1405
  return false;
1396
1406
  };
@@ -2750,7 +2760,7 @@ const RISKY_PATTERNS = [
2750
2760
  help: "Avoid dynamic code execution — refactor to use static code paths"
2751
2761
  },
2752
2762
  {
2753
- pattern: /\.innerHTML\s*=/g,
2763
+ pattern: new RegExp(`\\.innerHTML\\s*=`, "g"),
2754
2764
  extensions: [
2755
2765
  ".ts",
2756
2766
  ".tsx",
@@ -2847,6 +2857,10 @@ const detectRiskyConstructs = async (context) => {
2847
2857
  let match;
2848
2858
  while ((match = regex.exec(content)) !== null) {
2849
2859
  const line = content.slice(0, match.index).split("\n").length;
2860
+ if (name === "innerhtml") {
2861
+ const beforeMatch = content.slice(Math.max(0, match.index - 200), match.index);
2862
+ if (/(?:template|tmpl|tpl)$/i.test(beforeMatch.trimEnd()) || /createElement\s*\(\s*['"]template['"]\s*\)$/.test(beforeMatch.trimEnd())) continue;
2863
+ }
2850
2864
  if (name === "sql-injection") {
2851
2865
  const afterMatch = content.slice(match.index + match[0].length, match.index + match[0].length + 100);
2852
2866
  if (/^(?:\w+\.join\s*\(|[A-Z_]+\}|tableName\}|table\})/.test(afterMatch)) continue;
@@ -3104,7 +3118,7 @@ const logger = {
3104
3118
  * Application version — injected at build time by tsdown from package.json.
3105
3119
  * The fallback should always match the "version" field in package.json.
3106
3120
  */
3107
- const APP_VERSION = "0.1.0";
3121
+ const APP_VERSION = "0.1.2";
3108
3122
 
3109
3123
  //#endregion
3110
3124
  //#region src/output/layout.ts
@@ -3613,6 +3627,95 @@ const spinner = (text) => ({ start() {
3613
3627
  };
3614
3628
  } });
3615
3629
 
3630
+ //#endregion
3631
+ //#region src/utils/telemetry.ts
3632
+ /**
3633
+ * Anonymous, opt-out telemetry for aislop.
3634
+ *
3635
+ * What we collect:
3636
+ * - Command run (scan, fix, ci)
3637
+ * - Languages detected in the project
3638
+ * - Score bucket (0-25, 25-50, 50-75, 75-100)
3639
+ * - Issue counts per engine (not file paths or code)
3640
+ * - Engine timing (milliseconds)
3641
+ * - OS, architecture, and Node version
3642
+ * - aislop version
3643
+ *
3644
+ * What we never collect:
3645
+ * - File paths, file contents, or code snippets
3646
+ * - Project names or directory paths
3647
+ * - Git remotes, branch names, or commit hashes
3648
+ * - Environment variables or secrets
3649
+ * - IP addresses are not stored (PostHog configured to discard)
3650
+ *
3651
+ * How to opt out (any one of these):
3652
+ * - Set AISLOP_NO_TELEMETRY=1
3653
+ * - Set DO_NOT_TRACK=1 (https://consoledonottrack.com)
3654
+ * - Set CI=true (telemetry is off in CI by default)
3655
+ * - Set telemetry.enabled: false in .aislop/config.yml
3656
+ */
3657
+ const POSTHOG_HOST = "https://eu.i.posthog.com";
3658
+ const POSTHOG_KEY = "phc_eY2cOMFva9q24GrWeOuvuVIOhCIdjOALxeAR3ItrqbJ";
3659
+ /**
3660
+ * Returns true if telemetry should be disabled.
3661
+ * Telemetry is opt-out: it runs unless explicitly disabled.
3662
+ */
3663
+ const isTelemetryDisabled = (configEnabled) => {
3664
+ if (process.env.AISLOP_NO_TELEMETRY === "1" || process.env.DO_NOT_TRACK === "1") return true;
3665
+ if (process.env.CI === "true" || process.env.CI === "1") return true;
3666
+ if (configEnabled === false) return true;
3667
+ return false;
3668
+ };
3669
+ const getScoreBucket = (score) => {
3670
+ if (score >= 75) return "75-100";
3671
+ if (score >= 50) return "50-75";
3672
+ if (score >= 25) return "25-50";
3673
+ return "0-25";
3674
+ };
3675
+ /**
3676
+ * Returns a stable anonymous device ID derived from hostname + OS.
3677
+ * This is NOT personally identifiable — it's a hash used only to
3678
+ * count unique devices, not to identify users.
3679
+ */
3680
+ const getAnonymousId = () => {
3681
+ const raw = `${os.hostname()}-${os.platform()}-${os.arch()}`;
3682
+ let hash = 5381;
3683
+ for (let i = 0; i < raw.length; i++) hash = hash * 33 ^ raw.charCodeAt(i);
3684
+ return `aislop_${(hash >>> 0).toString(36)}`;
3685
+ };
3686
+ /**
3687
+ * Fire-and-forget telemetry event to PostHog.
3688
+ * Never throws, never blocks, never affects CLI output or exit code.
3689
+ */
3690
+ const trackEvent = (event) => {
3691
+ const payload = {
3692
+ api_key: POSTHOG_KEY,
3693
+ event: `cli_${event.command}`,
3694
+ distinct_id: getAnonymousId(),
3695
+ properties: {
3696
+ version: APP_VERSION,
3697
+ node_version: process.version,
3698
+ os: os.platform(),
3699
+ arch: os.arch(),
3700
+ languages: event.languages,
3701
+ score_bucket: event.scoreBucket,
3702
+ engine_issues: event.engineIssues,
3703
+ engine_timings: event.engineTimings,
3704
+ elapsed_ms: event.elapsedMs,
3705
+ file_count: event.fileCount,
3706
+ fix_steps: event.fixSteps,
3707
+ fix_resolved: event.fixResolved
3708
+ },
3709
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3710
+ };
3711
+ fetch(`${POSTHOG_HOST}/capture/`, {
3712
+ method: "POST",
3713
+ headers: { "Content-Type": "application/json" },
3714
+ body: JSON.stringify(payload),
3715
+ signal: AbortSignal.timeout(3e3)
3716
+ }).catch(() => {});
3717
+ };
3718
+
3616
3719
  //#endregion
3617
3720
  //#region src/commands/scan.ts
3618
3721
  const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
@@ -3669,7 +3772,24 @@ const scanCommand = async (directory, config, options) => {
3669
3772
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
3670
3773
  const elapsedMs = performance.now() - startTime;
3671
3774
  const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds);
3672
- const exitCode = scoreResult.score < config.ci.failBelow ? 1 : 0;
3775
+ const exitCode = allDiagnostics.some((d) => d.severity === "error") || scoreResult.score < config.ci.failBelow ? 1 : 0;
3776
+ if (!isTelemetryDisabled(config.telemetry?.enabled)) {
3777
+ const engineIssues = {};
3778
+ const engineTimings = {};
3779
+ for (const r of results) {
3780
+ engineIssues[r.engine] = r.diagnostics.length;
3781
+ engineTimings[r.engine] = Math.round(r.elapsed);
3782
+ }
3783
+ trackEvent({
3784
+ command: options.command ?? "scan",
3785
+ languages: projectInfo.languages,
3786
+ scoreBucket: getScoreBucket(scoreResult.score),
3787
+ engineIssues,
3788
+ engineTimings,
3789
+ elapsedMs: Math.round(elapsedMs),
3790
+ fileCount: projectInfo.sourceFileCount
3791
+ });
3792
+ }
3673
3793
  if (options.json) {
3674
3794
  const { buildJsonOutput } = await import("./json-L5x3hQdy.js");
3675
3795
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
@@ -3692,7 +3812,8 @@ const ciCommand = async (directory, config) => {
3692
3812
  changes: false,
3693
3813
  staged: false,
3694
3814
  verbose: false,
3695
- json: true
3815
+ json: true,
3816
+ command: "ci"
3696
3817
  });
3697
3818
  };
3698
3819
 
@@ -3944,6 +4065,15 @@ const fixCommand = async (directory, config, options = {
3944
4065
  logger.break();
3945
4066
  summarizeFixRun(steps);
3946
4067
  }
4068
+ if (!isTelemetryDisabled(config.telemetry?.enabled)) {
4069
+ const totalResolved = steps.reduce((sum, s) => sum + s.resolvedIssues, 0);
4070
+ trackEvent({
4071
+ command: "fix",
4072
+ languages: projectInfo.languages,
4073
+ fixSteps: steps.length,
4074
+ fixResolved: totalResolved
4075
+ });
4076
+ }
3947
4077
  logger.break();
3948
4078
  logger.success(" ✓ Done. Run `aislop scan` to verify.");
3949
4079
  logger.break();
@@ -3,7 +3,7 @@
3
3
  * Application version — injected at build time by tsdown from package.json.
4
4
  * The fallback should always match the "version" field in package.json.
5
5
  */
6
- const APP_VERSION = "0.1.0";
6
+ const APP_VERSION = "0.1.2";
7
7
 
8
8
  //#endregion
9
9
  //#region src/output/engine-info.ts
package/dist/index.d.ts CHANGED
@@ -37,6 +37,9 @@ declare const AislopConfigSchema: z.ZodObject<{
37
37
  json: "json";
38
38
  }>>;
39
39
  }, z.core.$strip>>;
40
+ telemetry: z.ZodDefault<z.ZodObject<{
41
+ enabled: z.ZodDefault<z.ZodBoolean>;
42
+ }, z.core.$strip>>;
40
43
  }, z.core.$strip>;
41
44
  type AislopConfig = z.infer<typeof AislopConfigSchema>;
42
45
  //#endregion
@@ -60,6 +63,8 @@ interface ScanOptions {
60
63
  verbose: boolean;
61
64
  json: boolean;
62
65
  showHeader?: boolean;
66
+ /** Used for telemetry to distinguish scan vs ci invocation */
67
+ command?: "scan" | "ci";
63
68
  }
64
69
  declare const scanCommand: (directory: string, config: AislopConfig, options: ScanOptions) => Promise<{
65
70
  exitCode: number;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-DBG3uXLc.js";
1
+ import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-B4Eq4giL.js";
2
2
  import { n as runSubprocess, t as isToolInstalled } from "./subprocess-99puEEGl.js";
3
3
  import { createRequire } from "node:module";
4
4
  import fs from "node:fs";
@@ -1022,6 +1022,95 @@ const printMaybePaged = async (text) => {
1022
1022
  if (!await pipeToPager(pager.command, pager.args, text)) writeToStdout(text);
1023
1023
  };
1024
1024
 
1025
+ //#endregion
1026
+ //#region src/utils/telemetry.ts
1027
+ /**
1028
+ * Anonymous, opt-out telemetry for aislop.
1029
+ *
1030
+ * What we collect:
1031
+ * - Command run (scan, fix, ci)
1032
+ * - Languages detected in the project
1033
+ * - Score bucket (0-25, 25-50, 50-75, 75-100)
1034
+ * - Issue counts per engine (not file paths or code)
1035
+ * - Engine timing (milliseconds)
1036
+ * - OS, architecture, and Node version
1037
+ * - aislop version
1038
+ *
1039
+ * What we never collect:
1040
+ * - File paths, file contents, or code snippets
1041
+ * - Project names or directory paths
1042
+ * - Git remotes, branch names, or commit hashes
1043
+ * - Environment variables or secrets
1044
+ * - IP addresses are not stored (PostHog configured to discard)
1045
+ *
1046
+ * How to opt out (any one of these):
1047
+ * - Set AISLOP_NO_TELEMETRY=1
1048
+ * - Set DO_NOT_TRACK=1 (https://consoledonottrack.com)
1049
+ * - Set CI=true (telemetry is off in CI by default)
1050
+ * - Set telemetry.enabled: false in .aislop/config.yml
1051
+ */
1052
+ const POSTHOG_HOST = "https://eu.i.posthog.com";
1053
+ const POSTHOG_KEY = "phc_eY2cOMFva9q24GrWeOuvuVIOhCIdjOALxeAR3ItrqbJ";
1054
+ /**
1055
+ * Returns true if telemetry should be disabled.
1056
+ * Telemetry is opt-out: it runs unless explicitly disabled.
1057
+ */
1058
+ const isTelemetryDisabled = (configEnabled) => {
1059
+ if (process.env.AISLOP_NO_TELEMETRY === "1" || process.env.DO_NOT_TRACK === "1") return true;
1060
+ if (process.env.CI === "true" || process.env.CI === "1") return true;
1061
+ if (configEnabled === false) return true;
1062
+ return false;
1063
+ };
1064
+ const getScoreBucket = (score) => {
1065
+ if (score >= 75) return "75-100";
1066
+ if (score >= 50) return "50-75";
1067
+ if (score >= 25) return "25-50";
1068
+ return "0-25";
1069
+ };
1070
+ /**
1071
+ * Returns a stable anonymous device ID derived from hostname + OS.
1072
+ * This is NOT personally identifiable — it's a hash used only to
1073
+ * count unique devices, not to identify users.
1074
+ */
1075
+ const getAnonymousId = () => {
1076
+ const raw = `${os.hostname()}-${os.platform()}-${os.arch()}`;
1077
+ let hash = 5381;
1078
+ for (let i = 0; i < raw.length; i++) hash = hash * 33 ^ raw.charCodeAt(i);
1079
+ return `aislop_${(hash >>> 0).toString(36)}`;
1080
+ };
1081
+ /**
1082
+ * Fire-and-forget telemetry event to PostHog.
1083
+ * Never throws, never blocks, never affects CLI output or exit code.
1084
+ */
1085
+ const trackEvent = (event) => {
1086
+ const payload = {
1087
+ api_key: POSTHOG_KEY,
1088
+ event: `cli_${event.command}`,
1089
+ distinct_id: getAnonymousId(),
1090
+ properties: {
1091
+ version: APP_VERSION,
1092
+ node_version: process.version,
1093
+ os: os.platform(),
1094
+ arch: os.arch(),
1095
+ languages: event.languages,
1096
+ score_bucket: event.scoreBucket,
1097
+ engine_issues: event.engineIssues,
1098
+ engine_timings: event.engineTimings,
1099
+ elapsed_ms: event.elapsedMs,
1100
+ file_count: event.fileCount,
1101
+ fix_steps: event.fixSteps,
1102
+ fix_resolved: event.fixResolved
1103
+ },
1104
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1105
+ };
1106
+ fetch(`${POSTHOG_HOST}/capture/`, {
1107
+ method: "POST",
1108
+ headers: { "Content-Type": "application/json" },
1109
+ body: JSON.stringify(payload),
1110
+ signal: AbortSignal.timeout(3e3)
1111
+ }).catch(() => {});
1112
+ };
1113
+
1025
1114
  //#endregion
1026
1115
  //#region src/commands/fix.ts
1027
1116
  const uniqueFiles = (diagnostics) => [...new Set(diagnostics.map((d) => d.filePath))];
@@ -1136,6 +1225,15 @@ const fixCommand = async (directory, config, options = {
1136
1225
  logger.break();
1137
1226
  summarizeFixRun(steps);
1138
1227
  }
1228
+ if (!isTelemetryDisabled(config.telemetry?.enabled)) {
1229
+ const totalResolved = steps.reduce((sum, s) => sum + s.resolvedIssues, 0);
1230
+ trackEvent({
1231
+ command: "fix",
1232
+ languages: projectInfo.languages,
1233
+ fixSteps: steps.length,
1234
+ fixResolved: totalResolved
1235
+ });
1236
+ }
1139
1237
  logger.break();
1140
1238
  logger.success(" ✓ Done. Run `aislop scan` to verify.");
1141
1239
  logger.break();
@@ -1180,7 +1278,8 @@ const DEFAULT_CONFIG = {
1180
1278
  ci: {
1181
1279
  failBelow: 0,
1182
1280
  format: "json"
1183
- }
1281
+ },
1282
+ telemetry: { enabled: true }
1184
1283
  };
1185
1284
  const DEFAULT_CONFIG_YAML = `version: 1
1186
1285
 
@@ -1217,6 +1316,9 @@ scoring:
1217
1316
  ci:
1218
1317
  failBelow: 0
1219
1318
  format: json
1319
+
1320
+ # telemetry:
1321
+ # enabled: true # set to false to disable anonymous usage analytics
1220
1322
  `;
1221
1323
  const DEFAULT_RULES_YAML = `# Architecture rules (BYO)
1222
1324
  # Uncomment and customize to enforce your project's conventions.
@@ -1277,6 +1379,7 @@ const CiSchema = z.object({
1277
1379
  failBelow: z.number().default(0),
1278
1380
  format: z.enum(["json"]).default("json")
1279
1381
  });
1382
+ const TelemetrySchema = z.object({ enabled: z.boolean().default(true) });
1280
1383
  const AislopConfigSchema = z.object({
1281
1384
  version: z.number().default(1),
1282
1385
  engines: EnginesSchema.default(() => ({
@@ -1307,7 +1410,8 @@ const AislopConfigSchema = z.object({
1307
1410
  ci: CiSchema.default(() => ({
1308
1411
  failBelow: 0,
1309
1412
  format: "json"
1310
- }))
1413
+ })),
1414
+ telemetry: TelemetrySchema.default(() => ({ enabled: true }))
1311
1415
  });
1312
1416
  const defaults = AislopConfigSchema.parse({});
1313
1417
  /**
@@ -2395,17 +2499,21 @@ const isDataFile = (content) => {
2395
2499
  const dataLinePattern = /^\s*[{}[\]"']/;
2396
2500
  return nonEmpty.filter((l) => dataLinePattern.test(l)).length / nonEmpty.length > .8;
2397
2501
  };
2502
+ const ARROW_BLOCK_RE = /* @__PURE__ */ new RegExp("=>\\s*\\{");
2503
+ const ARROW_END_RE = /* @__PURE__ */ new RegExp("=>\\s*$");
2504
+ const BRACE_START_RE = /* @__PURE__ */ new RegExp("^\\s*\\{");
2505
+ const NEW_STATEMENT_RE = /* @__PURE__ */ new RegExp("^(?:export\\s+)?(?:const|let|var|function|class)\\s");
2398
2506
  const isBlockArrow = (lines, startIndex) => {
2399
- if (/=>\s*\{/.test(lines[startIndex])) return true;
2400
- if (/=>\s*$/.test(lines[startIndex])) {
2507
+ if (ARROW_BLOCK_RE.test(lines[startIndex])) return true;
2508
+ if (ARROW_END_RE.test(lines[startIndex])) {
2401
2509
  const next = lines[startIndex + 1];
2402
- if (next && /^\s*\{/.test(next)) return true;
2510
+ if (next && BRACE_START_RE.test(next)) return true;
2403
2511
  }
2404
2512
  for (let j = startIndex + 1; j < Math.min(startIndex + 3, lines.length); j++) {
2405
2513
  const l = lines[j];
2406
- if (l.trim() === "" || /^(?:export\s+)?(?:const|let|var|function|class)\s/.test(l.trim())) break;
2407
- if (/=>\s*\{/.test(l)) return true;
2408
- if (/^\s*\{/.test(l)) return true;
2514
+ if (l.trim() === "" || NEW_STATEMENT_RE.test(l.trim())) break;
2515
+ if (ARROW_BLOCK_RE.test(l)) return true;
2516
+ if (BRACE_START_RE.test(l)) return true;
2409
2517
  }
2410
2518
  return false;
2411
2519
  };
@@ -3250,7 +3358,7 @@ const RISKY_PATTERNS = [
3250
3358
  help: "Avoid dynamic code execution — refactor to use static code paths"
3251
3359
  },
3252
3360
  {
3253
- pattern: /\.innerHTML\s*=/g,
3361
+ pattern: new RegExp(`\\.innerHTML\\s*=`, "g"),
3254
3362
  extensions: [
3255
3363
  ".ts",
3256
3364
  ".tsx",
@@ -3347,6 +3455,10 @@ const detectRiskyConstructs = async (context) => {
3347
3455
  let match;
3348
3456
  while ((match = regex.exec(content)) !== null) {
3349
3457
  const line = content.slice(0, match.index).split("\n").length;
3458
+ if (name === "innerhtml") {
3459
+ const beforeMatch = content.slice(Math.max(0, match.index - 200), match.index);
3460
+ if (/(?:template|tmpl|tpl)$/i.test(beforeMatch.trimEnd()) || /createElement\s*\(\s*['"]template['"]\s*\)$/.test(beforeMatch.trimEnd())) continue;
3461
+ }
3350
3462
  if (name === "sql-injection") {
3351
3463
  const afterMatch = content.slice(match.index + match[0].length, match.index + match[0].length + 100);
3352
3464
  if (/^(?:\w+\.join\s*\(|[A-Z_]+\}|tableName\}|table\})/.test(afterMatch)) continue;
@@ -3860,9 +3972,26 @@ const scanCommand = async (directory, config, options) => {
3860
3972
  const allDiagnostics = results.flatMap((r) => r.diagnostics);
3861
3973
  const elapsedMs = performance.now() - startTime;
3862
3974
  const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds);
3863
- const exitCode = scoreResult.score < config.ci.failBelow ? 1 : 0;
3975
+ const exitCode = allDiagnostics.some((d) => d.severity === "error") || scoreResult.score < config.ci.failBelow ? 1 : 0;
3976
+ if (!isTelemetryDisabled(config.telemetry?.enabled)) {
3977
+ const engineIssues = {};
3978
+ const engineTimings = {};
3979
+ for (const r of results) {
3980
+ engineIssues[r.engine] = r.diagnostics.length;
3981
+ engineTimings[r.engine] = Math.round(r.elapsed);
3982
+ }
3983
+ trackEvent({
3984
+ command: options.command ?? "scan",
3985
+ languages: projectInfo.languages,
3986
+ scoreBucket: getScoreBucket(scoreResult.score),
3987
+ engineIssues,
3988
+ engineTimings,
3989
+ elapsedMs: Math.round(elapsedMs),
3990
+ fileCount: projectInfo.sourceFileCount
3991
+ });
3992
+ }
3864
3993
  if (options.json) {
3865
- const { buildJsonOutput } = await import("./json-DkpW9UQj.js");
3994
+ const { buildJsonOutput } = await import("./json-BMSa_G7o.js");
3866
3995
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
3867
3996
  console.log(JSON.stringify(jsonOut, null, 2));
3868
3997
  return { exitCode };
@@ -1,4 +1,4 @@
1
- import { r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-DBG3uXLc.js";
1
+ import { r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-B4Eq4giL.js";
2
2
 
3
3
  //#region src/output/json.ts
4
4
  const buildJsonOutput = (results, scoreResult, fileCount, elapsedMs) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aislop",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Stop AI slop from shipping. A unified code quality CLI that catches the lazy patterns AI coding tools leave behind.",
5
5
  "type": "module",
6
6
  "bin": {