aislop 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,19 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { n as runSubprocess, t as isToolInstalled } from "./subprocess-CCnnN_oQ.js";
3
3
  import { createRequire, isBuiltin } from "node:module";
4
+ import { performance } from "node:perf_hooks";
4
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import path from "node:path";
7
7
  import { spawn, spawnSync } from "node:child_process";
8
+ import path from "node:path";
8
9
  import { z } from "zod";
9
10
  import fs from "node:fs";
10
11
  import YAML from "yaml";
11
12
  import { z as z$1 } from "zod/v4";
12
- import { performance } from "node:perf_hooks";
13
13
  import micromatch from "micromatch";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import os from "node:os";
16
16
  import "typescript";
17
+ import { randomUUID } from "node:crypto";
17
18
 
18
19
  //#region src/config/defaults.ts
19
20
  const DEFAULT_CONFIG = {
@@ -25,6 +26,7 @@ const DEFAULT_CONFIG = {
25
26
  "build",
26
27
  "coverage"
27
28
  ],
29
+ include: [],
28
30
  engines: {
29
31
  format: true,
30
32
  lint: true,
@@ -60,7 +62,7 @@ const DEFAULT_CONFIG = {
60
62
  smoothing: 20
61
63
  },
62
64
  ci: {
63
- failBelow: 0,
65
+ failBelow: 70,
64
66
  format: "json"
65
67
  },
66
68
  telemetry: { enabled: true }
@@ -150,7 +152,7 @@ const ScoringSchema = z$1.object({
150
152
  smoothing: z$1.number().nonnegative().default(20)
151
153
  });
152
154
  const CiSchema = z$1.object({
153
- failBelow: z$1.number().default(0),
155
+ failBelow: z$1.number().default(70),
154
156
  format: z$1.enum(["json"]).default("json")
155
157
  });
156
158
  const TelemetrySchema = z$1.object({ enabled: z$1.boolean().default(true) });
@@ -184,7 +186,7 @@ const AislopConfigSchema = z$1.object({
184
186
  smoothing: 20
185
187
  })),
186
188
  ci: CiSchema.default(() => ({
187
- failBelow: 0,
189
+ failBelow: 70,
188
190
  format: "json"
189
191
  })),
190
192
  telemetry: TelemetrySchema.default(() => ({ enabled: true })),
@@ -194,7 +196,8 @@ const AislopConfigSchema = z$1.object({
194
196
  "dist",
195
197
  "build",
196
198
  "coverage"
197
- ])
199
+ ]),
200
+ include: z$1.array(z$1.string()).default(() => [])
198
201
  });
199
202
  const defaults = AislopConfigSchema.parse({});
200
203
  /**
@@ -274,31 +277,68 @@ const EXCLUDED_DIRS = [
274
277
  "dist",
275
278
  "build",
276
279
  ".git",
280
+ ".agents",
277
281
  "vendor",
282
+ "examples",
283
+ "example",
284
+ "demos",
285
+ "demo",
286
+ "bench",
287
+ "benches",
288
+ "benchmarks",
289
+ "fixtures",
290
+ "fixture",
291
+ "samples",
292
+ "sample",
293
+ "tutorials",
294
+ "tutorial",
295
+ "code_samples",
296
+ "code-samples",
297
+ "notebooks",
278
298
  "tests",
279
299
  "test",
280
300
  "__tests__",
281
301
  "__test__",
282
302
  "spec",
283
303
  "__mocks__",
284
- "fixtures",
285
304
  "test_data",
286
305
  ".next",
287
306
  ".nuxt",
288
307
  "coverage",
289
- ".turbo"
308
+ ".turbo",
309
+ "public"
290
310
  ];
291
311
  const FIND_PRUNE_DIRS = [
292
312
  "node_modules",
293
313
  "dist",
294
314
  "build",
295
315
  ".git",
316
+ ".agents",
296
317
  "vendor",
318
+ "examples",
319
+ "example",
320
+ "demos",
321
+ "demo",
322
+ "bench",
323
+ "benches",
324
+ "benchmarks",
325
+ "fixtures",
326
+ "fixture",
327
+ "samples",
328
+ "sample",
329
+ "tutorials",
330
+ "tutorial",
331
+ "code_samples",
332
+ "code-samples",
333
+ "notebooks",
297
334
  ".next",
298
335
  ".nuxt",
299
336
  "coverage",
300
- ".turbo"
337
+ ".turbo",
338
+ "public"
301
339
  ];
340
+ const BUILD_CACHE_FILE_PATTERNS = [/\.timestamp-\d+-[a-z0-9]+\.[mc]?js$/i];
341
+ const isBuildCacheFile = (filePath) => BUILD_CACHE_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
302
342
  const TEST_FILE_PATTERNS = [
303
343
  /(?:^|\/).*\.test\.[^/]+$/i,
304
344
  /(?:^|\/).*\.spec\.[^/]+$/i,
@@ -323,6 +363,7 @@ const hasAllowedExtension = (filePath, extraExtensions) => {
323
363
  return SOURCE_EXTENSIONS.has(extension) || extraExtensions.has(extension);
324
364
  };
325
365
  const isExcludedPath = (filePath) => EXCLUDED_DIRS.some((dir) => filePath === dir || filePath.startsWith(`${dir}/`) || filePath.includes(`/${dir}/`));
366
+ const isExcludedFromScan = (relativePath) => isExcludedPath(relativePath) || isBuildCacheFile(relativePath);
326
367
  const isTestFile$2 = (filePath) => TEST_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
327
368
  const getIgnoredPaths = (rootDirectory, files) => {
328
369
  if (files.length === 0) return /* @__PURE__ */ new Set();
@@ -381,7 +422,7 @@ const normalizeExcludePatterns = (patterns) => {
381
422
  return [p];
382
423
  });
383
424
  };
384
- const filterProjectFiles = (rootDirectory, files, extraExtensions = [], exclude = []) => {
425
+ const filterProjectFiles = (rootDirectory, files, extraExtensions = [], exclude = [], include = []) => {
385
426
  const extraSet = new Set(extraExtensions);
386
427
  const normalizedFiles = files.map((file) => {
387
428
  const absolutePath = path.isAbsolute(file) ? file : path.resolve(rootDirectory, file);
@@ -396,8 +437,16 @@ const filterProjectFiles = (rootDirectory, files, extraExtensions = [], exclude
396
437
  if (!normalizedExcludePatterns.length) return false;
397
438
  return micromatch.isMatch(relativePath, normalizedExcludePatterns, { dot: true });
398
439
  };
440
+ const hasIncludePatterns = include.length > 0;
441
+ const isUserIncluded = (relativePath) => {
442
+ if (!hasIncludePatterns) return true;
443
+ return micromatch.isMatch(relativePath, include, { dot: true });
444
+ };
399
445
  return normalizedFiles.filter(({ absolutePath, relativePath }) => {
400
- return hasAllowedExtension(relativePath, extraSet) && !isExcludedPath(relativePath) && !isTestFile$2(relativePath) && !ignoredPaths.has(relativePath) && !isUserExcluded(relativePath) && fs.existsSync(absolutePath);
446
+ if (!fs.existsSync(absolutePath) || !isWithinProject(relativePath) || isExcludedPath(relativePath) || isTestFile$2(relativePath) || ignoredPaths.has(relativePath)) return false;
447
+ if (!isUserIncluded(relativePath)) return false;
448
+ if (isUserExcluded(relativePath)) return false;
449
+ return hasAllowedExtension(relativePath, extraSet);
401
450
  }).map(({ absolutePath }) => absolutePath);
402
451
  };
403
452
  const filterExplicitFiles = (rootDirectory, files, extraExtensions = []) => {
@@ -1127,6 +1176,86 @@ const PYTHON_IMPORT_TO_PIP = {
1127
1176
  redis: "redis"
1128
1177
  };
1129
1178
 
1179
+ //#endregion
1180
+ //#region src/engines/ai-slop/python-manifest.ts
1181
+ const addPyDep = (pyDeps, name) => {
1182
+ const normalized = name.toLowerCase().replace(/_/g, "-");
1183
+ pyDeps.add(normalized);
1184
+ };
1185
+ const collectFromRequirementsTxt = (rootDir, pyDeps) => {
1186
+ const reqPath = path.join(rootDir, "requirements.txt");
1187
+ if (!fs.existsSync(reqPath)) return false;
1188
+ try {
1189
+ const content = fs.readFileSync(reqPath, "utf-8");
1190
+ for (const line of content.split("\n")) {
1191
+ const trimmed = line.trim();
1192
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
1193
+ const match = trimmed.match(/^([a-zA-Z0-9_\-.]+)/);
1194
+ if (match) addPyDep(pyDeps, match[1]);
1195
+ }
1196
+ return true;
1197
+ } catch {
1198
+ return false;
1199
+ }
1200
+ };
1201
+ const collectFromPyproject = (rootDir, pyDeps) => {
1202
+ const pyprojPath = path.join(rootDir, "pyproject.toml");
1203
+ if (!fs.existsSync(pyprojPath)) return false;
1204
+ try {
1205
+ const content = fs.readFileSync(pyprojPath, "utf-8");
1206
+ const projectNameMatch = content.match(/\[project\][\s\S]*?^\s*name\s*=\s*["']([^"']+)/m);
1207
+ if (projectNameMatch) addPyDep(pyDeps, projectNameMatch[1]);
1208
+ const poetryNameMatch = content.match(/\[tool\.poetry\][\s\S]*?^\s*name\s*=\s*["']([^"']+)/m);
1209
+ if (poetryNameMatch) addPyDep(pyDeps, poetryNameMatch[1]);
1210
+ const pep621 = content.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m);
1211
+ if (pep621) for (const line of pep621[1].split("\n")) {
1212
+ const m = line.match(/["']\s*([a-zA-Z0-9_\-.]+)/);
1213
+ if (m) addPyDep(pyDeps, m[1]);
1214
+ }
1215
+ const poetryRe = /\[tool\.poetry(?:\.group\.[a-z]+)?\.dependencies\]([\s\S]*?)(?=\n\[|$)/g;
1216
+ let match = poetryRe.exec(content);
1217
+ while (match !== null) {
1218
+ for (const line of match[1].split("\n")) {
1219
+ const m = line.trim().match(/^([a-zA-Z0-9_\-.]+)\s*=/);
1220
+ if (m && m[1] !== "python") addPyDep(pyDeps, m[1]);
1221
+ }
1222
+ match = poetryRe.exec(content);
1223
+ }
1224
+ return true;
1225
+ } catch {
1226
+ return false;
1227
+ }
1228
+ };
1229
+ const collectFromPipfile = (rootDir, pyDeps) => {
1230
+ const pipfilePath = path.join(rootDir, "Pipfile");
1231
+ if (!fs.existsSync(pipfilePath)) return false;
1232
+ try {
1233
+ const content = fs.readFileSync(pipfilePath, "utf-8");
1234
+ const sectionRe = /\[(packages|dev-packages)\]([\s\S]*?)(?=\n\[|$)/g;
1235
+ let match = sectionRe.exec(content);
1236
+ while (match !== null) {
1237
+ for (const line of match[2].split("\n")) {
1238
+ const m = line.trim().match(/^([a-zA-Z0-9_\-.]+)\s*=/);
1239
+ if (m) addPyDep(pyDeps, m[1]);
1240
+ }
1241
+ match = sectionRe.exec(content);
1242
+ }
1243
+ return true;
1244
+ } catch {
1245
+ return false;
1246
+ }
1247
+ };
1248
+ const collectPythonDeps = (rootDir) => {
1249
+ const pyDeps = /* @__PURE__ */ new Set();
1250
+ const hasReq = collectFromRequirementsTxt(rootDir, pyDeps);
1251
+ const hasPyproject = collectFromPyproject(rootDir, pyDeps);
1252
+ const hasPipfile = collectFromPipfile(rootDir, pyDeps);
1253
+ return {
1254
+ pyDeps,
1255
+ hasPyManifest: hasReq || hasPyproject || hasPipfile
1256
+ };
1257
+ };
1258
+
1130
1259
  //#endregion
1131
1260
  //#region src/engines/ai-slop/hallucinated-imports.ts
1132
1261
  const JS_EXTENSIONS$1 = new Set([
@@ -1263,10 +1392,26 @@ const buildAliasMatcher = (key) => {
1263
1392
  };
1264
1393
  const collectAliasMatchersFromConfig = (configPath, matchers) => {
1265
1394
  const opts = readJson(configPath)?.compilerOptions;
1266
- if (!opts || typeof opts !== "object") return;
1395
+ if (!opts) return;
1267
1396
  const paths = opts.paths;
1268
- if (!paths || typeof paths !== "object") return;
1269
- for (const key of Object.keys(paths)) matchers.push(buildAliasMatcher(key));
1397
+ if (paths && typeof paths === "object") for (const key of Object.keys(paths)) matchers.push(buildAliasMatcher(key));
1398
+ const baseUrl = opts.baseUrl;
1399
+ if (typeof baseUrl === "string") {
1400
+ const baseUrlDir = path.resolve(path.dirname(configPath), baseUrl);
1401
+ let entries;
1402
+ try {
1403
+ entries = fs.readdirSync(baseUrlDir);
1404
+ } catch {
1405
+ return;
1406
+ }
1407
+ const baseSpecifiers = /* @__PURE__ */ new Set();
1408
+ for (const entry of entries) {
1409
+ if (entry.startsWith(".") || entry === "node_modules") continue;
1410
+ const base = entry.replace(/\.(?:[jt]sx?|mjs|cjs|d\.ts)$/i, "");
1411
+ if (base.length > 0) baseSpecifiers.add(base);
1412
+ }
1413
+ for (const name of baseSpecifiers) matchers.push((spec) => spec === name || spec.startsWith(`${name}/`));
1414
+ }
1270
1415
  };
1271
1416
  const collectTsPathAliases = (rootDir) => {
1272
1417
  const matchers = [];
@@ -1274,97 +1419,35 @@ const collectTsPathAliases = (rootDir) => {
1274
1419
  for (const dir of dirs) for (const fname of TS_CONFIG_FILES) collectAliasMatchersFromConfig(path.join(dir, fname), matchers);
1275
1420
  return matchers;
1276
1421
  };
1277
- const addPyDep = (pyDeps, name) => {
1278
- const normalized = name.toLowerCase().replace(/_/g, "-");
1279
- pyDeps.add(normalized);
1280
- };
1281
- const collectFromRequirementsTxt = (rootDir, pyDeps) => {
1282
- const reqPath = path.join(rootDir, "requirements.txt");
1283
- if (!fs.existsSync(reqPath)) return false;
1284
- try {
1285
- const content = fs.readFileSync(reqPath, "utf-8");
1286
- for (const line of content.split("\n")) {
1287
- const trimmed = line.trim();
1288
- if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-")) continue;
1289
- const match = trimmed.match(/^([a-zA-Z0-9_\-.]+)/);
1290
- if (match) addPyDep(pyDeps, match[1]);
1291
- }
1292
- return true;
1293
- } catch {
1294
- return false;
1295
- }
1296
- };
1297
- const collectFromPyproject = (rootDir, pyDeps) => {
1298
- const pyprojPath = path.join(rootDir, "pyproject.toml");
1299
- if (!fs.existsSync(pyprojPath)) return false;
1300
- try {
1301
- const content = fs.readFileSync(pyprojPath, "utf-8");
1302
- const projectNameMatch = content.match(/\[project\][\s\S]*?^\s*name\s*=\s*["']([^"']+)/m);
1303
- if (projectNameMatch) addPyDep(pyDeps, projectNameMatch[1]);
1304
- const poetryNameMatch = content.match(/\[tool\.poetry\][\s\S]*?^\s*name\s*=\s*["']([^"']+)/m);
1305
- if (poetryNameMatch) addPyDep(pyDeps, poetryNameMatch[1]);
1306
- const pep621 = content.match(/^\s*dependencies\s*=\s*\[([\s\S]*?)\]/m);
1307
- if (pep621) for (const line of pep621[1].split("\n")) {
1308
- const m = line.match(/["']\s*([a-zA-Z0-9_\-.]+)/);
1309
- if (m) addPyDep(pyDeps, m[1]);
1310
- }
1311
- const poetryRe = /\[tool\.poetry(?:\.group\.[a-z]+)?\.dependencies\]([\s\S]*?)(?=\n\[|$)/g;
1312
- let match = poetryRe.exec(content);
1313
- while (match !== null) {
1314
- for (const line of match[1].split("\n")) {
1315
- const m = line.trim().match(/^([a-zA-Z0-9_\-.]+)\s*=/);
1316
- if (m && m[1] !== "python") addPyDep(pyDeps, m[1]);
1317
- }
1318
- match = poetryRe.exec(content);
1319
- }
1320
- return true;
1321
- } catch {
1322
- return false;
1323
- }
1324
- };
1325
- const collectFromPipfile = (rootDir, pyDeps) => {
1326
- const pipfilePath = path.join(rootDir, "Pipfile");
1327
- if (!fs.existsSync(pipfilePath)) return false;
1328
- try {
1329
- const content = fs.readFileSync(pipfilePath, "utf-8");
1330
- const sectionRe = /\[(packages|dev-packages)\]([\s\S]*?)(?=\n\[|$)/g;
1331
- let match = sectionRe.exec(content);
1332
- while (match !== null) {
1333
- for (const line of match[2].split("\n")) {
1334
- const m = line.trim().match(/^([a-zA-Z0-9_\-.]+)\s*=/);
1335
- if (m) addPyDep(pyDeps, m[1]);
1336
- }
1337
- match = sectionRe.exec(content);
1338
- }
1339
- return true;
1340
- } catch {
1341
- return false;
1342
- }
1343
- };
1344
1422
  const loadManifest = (rootDir) => {
1345
1423
  const jsDeps = /* @__PURE__ */ new Set();
1346
- const pyDeps = /* @__PURE__ */ new Set();
1347
1424
  const hasJsManifest = collectJsDeps(rootDir, jsDeps);
1348
- const hasReq = collectFromRequirementsTxt(rootDir, pyDeps);
1349
- const hasPyproject = collectFromPyproject(rootDir, pyDeps);
1350
- const hasPipfile = collectFromPipfile(rootDir, pyDeps);
1425
+ const { pyDeps, hasPyManifest } = collectPythonDeps(rootDir);
1351
1426
  return {
1352
1427
  jsDeps,
1353
1428
  pyDeps,
1354
1429
  hasJsManifest,
1355
- hasPyManifest: hasReq || hasPyproject || hasPipfile
1430
+ hasPyManifest
1356
1431
  };
1357
1432
  };
1358
1433
  const isJsRelativeOrAbsolute = (spec) => spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("~/");
1434
+ const RUNTIME_BUILTINS = new Set(["bun"]);
1359
1435
  const isJsBuiltin = (spec) => {
1436
+ if (RUNTIME_BUILTINS.has(spec)) return true;
1360
1437
  return isBuiltin(spec.startsWith("node:") ? spec.slice(5) : spec) || isBuiltin(spec);
1361
1438
  };
1362
1439
  const VIRTUAL_MODULE_PREFIXES = [
1363
1440
  "astro:",
1364
1441
  "virtual:",
1365
- "bun:"
1442
+ "bun:",
1443
+ "~icons/"
1366
1444
  ];
1367
1445
  const isJsVirtualModule = (spec) => VIRTUAL_MODULE_PREFIXES.some((p) => spec.startsWith(p));
1446
+ const stripImportQuery = (spec) => {
1447
+ const idx = spec.indexOf("?");
1448
+ return idx === -1 ? spec : spec.slice(0, idx);
1449
+ };
1450
+ const VIRTUAL_ASSET_FILES = { "unfonts.css": "unplugin-fonts" };
1368
1451
  const TEMPLATE_PLACEHOLDER_RE = /\$\{/;
1369
1452
  const isLikelyRealImportSpec = (spec) => {
1370
1453
  if (spec.length === 0) return false;
@@ -1431,10 +1514,14 @@ const extractPyImports = (content) => {
1431
1514
  }
1432
1515
  return results;
1433
1516
  };
1434
- const checkJsImport = (spec, manifest, tsAliasMatchers) => {
1517
+ const checkJsImport = (rawSpec, manifest, tsAliasMatchers) => {
1518
+ const spec = stripImportQuery(rawSpec);
1519
+ if (spec.length === 0) return null;
1435
1520
  if (isJsRelativeOrAbsolute(spec)) return null;
1436
1521
  if (isJsBuiltin(spec)) return null;
1437
1522
  if (isJsVirtualModule(spec)) return null;
1523
+ const virtualOwner = VIRTUAL_ASSET_FILES[spec];
1524
+ if (virtualOwner && manifest.jsDeps.has(virtualOwner)) return null;
1438
1525
  if (tsAliasMatchers.some((m) => m(spec))) return null;
1439
1526
  const pkg = packageNameFromImport(spec);
1440
1527
  if (manifest.jsDeps.has(pkg)) return null;
@@ -2789,64 +2876,88 @@ const analyzeFunctions = (content, ext) => {
2789
2876
  }
2790
2877
  return functions;
2791
2878
  };
2792
- const JSX_FILE_LOC_MULTIPLIER = 1.5;
2879
+ const FILE_LOC_MULTIPLIERS = {
2880
+ ".tsx": 1.5,
2881
+ ".jsx": 1.5,
2882
+ ".rs": 2.5,
2883
+ ".go": 1.5
2884
+ };
2885
+ const DECLARATION_FILE_RE = /\.d\.ts$/i;
2886
+ const fileLocBudget = (ext, relativePath, base) => {
2887
+ if (DECLARATION_FILE_RE.test(relativePath)) return Number.POSITIVE_INFINITY;
2888
+ const multiplier = FILE_LOC_MULTIPLIERS[ext] ?? 1;
2889
+ return Math.ceil(base * multiplier);
2890
+ };
2793
2891
  const checkFileDiagnostics = (relativePath, content, limits) => {
2794
2892
  const results = [];
2795
2893
  const lineCount = content.split("\n").length;
2796
2894
  const ext = path.extname(relativePath).toLowerCase();
2797
2895
  if (isDataFile(content)) return results;
2798
- const configuredMax = ext === ".jsx" || ext === ".tsx" ? Math.ceil(limits.maxFileLoc * JSX_FILE_LOC_MULTIPLIER) : limits.maxFileLoc;
2896
+ const configuredMax = fileLocBudget(ext, relativePath, limits.maxFileLoc);
2897
+ if (!Number.isFinite(configuredMax)) return results;
2799
2898
  if (lineCount > Math.ceil(configuredMax * 1.1)) results.push({
2800
2899
  filePath: relativePath,
2801
2900
  engine: "code-quality",
2802
2901
  rule: "complexity/file-too-large",
2803
2902
  severity: "warning",
2804
- message: `File has ${lineCount} lines (max: ${configuredMax})`,
2903
+ message: `File too large (max: ${configuredMax})`,
2805
2904
  help: "Consider splitting this file into smaller modules",
2806
2905
  line: 0,
2807
2906
  column: 0,
2808
2907
  category: "Complexity",
2809
- fixable: false
2908
+ fixable: false,
2909
+ detail: `${lineCount} lines`
2810
2910
  });
2811
2911
  return results;
2812
2912
  };
2813
- const checkFunctionDiagnostics = (relativePath, fn, limits) => {
2913
+ const JSX_EXTENSIONS = new Set([".tsx", ".jsx"]);
2914
+ const isComponentFunction = (name, ext) => JSX_EXTENSIONS.has(ext) && /^[A-Z]/.test(name);
2915
+ const functionLocBudget = (fn, ext, base) => {
2916
+ if (isComponentFunction(fn.name, ext)) return Math.ceil(base * 2);
2917
+ if (ext === ".rs") return Math.ceil(base * 1.5);
2918
+ return base;
2919
+ };
2920
+ const checkFunctionDiagnostics = (relativePath, fn, limits, ext) => {
2814
2921
  const results = [];
2815
- if (fn.lineCount - fn.templateLines > Math.ceil(limits.maxFunctionLoc * 1.1)) results.push({
2922
+ const fnMax = functionLocBudget(fn, ext, limits.maxFunctionLoc);
2923
+ if (fn.lineCount - fn.templateLines > Math.ceil(fnMax * 1.1)) results.push({
2816
2924
  filePath: relativePath,
2817
2925
  engine: "code-quality",
2818
2926
  rule: "complexity/function-too-long",
2819
2927
  severity: "warning",
2820
- message: `Function '${fn.name}' has ${fn.lineCount} lines (max: ${limits.maxFunctionLoc})`,
2928
+ message: `Function too long (max: ${fnMax})`,
2821
2929
  help: "Consider breaking this function into smaller pieces",
2822
2930
  line: fn.startLine,
2823
2931
  column: 0,
2824
2932
  category: "Complexity",
2825
- fixable: false
2933
+ fixable: false,
2934
+ detail: `${fn.name} · ${fn.lineCount} lines`
2826
2935
  });
2827
2936
  if (fn.maxNesting > limits.maxNesting) results.push({
2828
2937
  filePath: relativePath,
2829
2938
  engine: "code-quality",
2830
2939
  rule: "complexity/deep-nesting",
2831
2940
  severity: "warning",
2832
- message: `Function '${fn.name}' has nesting depth ${fn.maxNesting} (max: ${limits.maxNesting})`,
2941
+ message: `Function nested too deeply (max: ${limits.maxNesting})`,
2833
2942
  help: "Consider using early returns or extracting nested logic",
2834
2943
  line: fn.startLine,
2835
2944
  column: 0,
2836
2945
  category: "Complexity",
2837
- fixable: false
2946
+ fixable: false,
2947
+ detail: `${fn.name} · depth ${fn.maxNesting}`
2838
2948
  });
2839
2949
  if (fn.paramCount > limits.maxParams) results.push({
2840
2950
  filePath: relativePath,
2841
2951
  engine: "code-quality",
2842
2952
  rule: "complexity/too-many-params",
2843
2953
  severity: "warning",
2844
- message: `Function '${fn.name}' has ${fn.paramCount} parameters (max: ${limits.maxParams})`,
2954
+ message: `Function has too many parameters (max: ${limits.maxParams})`,
2845
2955
  help: "Consider using an options object parameter",
2846
2956
  line: fn.startLine,
2847
2957
  column: 0,
2848
2958
  category: "Complexity",
2849
- fixable: false
2959
+ fixable: false,
2960
+ detail: `${fn.name} · ${fn.paramCount} params`
2850
2961
  });
2851
2962
  return results;
2852
2963
  };
@@ -2861,7 +2972,7 @@ const checkFileComplexity = (filePath, rootDirectory, limits) => {
2861
2972
  }
2862
2973
  const ext = path.extname(filePath).toLowerCase();
2863
2974
  const diagnostics = checkFileDiagnostics(relativePath, content, limits);
2864
- for (const fn of analyzeFunctions(content, ext)) diagnostics.push(...checkFunctionDiagnostics(relativePath, fn, limits));
2975
+ for (const fn of analyzeFunctions(content, ext)) diagnostics.push(...checkFunctionDiagnostics(relativePath, fn, limits, ext));
2865
2976
  return diagnostics;
2866
2977
  };
2867
2978
  const checkComplexity = async (context) => {
@@ -2966,17 +3077,19 @@ const findDuplicateBlocks = (content, relativePath) => {
2966
3077
  });
2967
3078
  }
2968
3079
  return reports.map((r) => {
3080
+ const span = r.currentEnd - r.currentStart + 1;
2969
3081
  return {
2970
3082
  filePath: relativePath,
2971
3083
  engine: "code-quality",
2972
3084
  rule: "code-quality/duplicate-block",
2973
3085
  severity: "warning",
2974
- message: `${r.currentEnd - r.currentStart + 1}-line block at line ${r.currentStart} duplicates a block starting at line ${r.priorStart}. Extract a shared helper.`,
3086
+ message: "Duplicate code block extract a shared helper",
2975
3087
  help: `Pull the shared logic into a function both sites can call. Keeps one version of the truth and makes future changes one-shot instead of N-shot.`,
2976
3088
  line: r.currentStart,
2977
3089
  column: 0,
2978
3090
  category: "Complexity",
2979
- fixable: false
3091
+ fixable: false,
3092
+ detail: `${span} lines duplicate block at L${r.priorStart}`
2980
3093
  };
2981
3094
  });
2982
3095
  };
@@ -3518,16 +3631,34 @@ const isToolAvailable = async (toolName) => {
3518
3631
  return isToolInstalled(toolName);
3519
3632
  };
3520
3633
 
3634
+ //#endregion
3635
+ //#region src/engines/python-targets.ts
3636
+ const PYTHON_EXTENSIONS = new Set([".py", ".pyi"]);
3637
+ const normalizeProjectPath = (filePath) => filePath.split(path.sep).join("/");
3638
+ const getPythonTargets = (context) => {
3639
+ const targets = (context.files ?? getSourceFiles(context)).filter((filePath) => PYTHON_EXTENSIONS.has(path.extname(filePath).toLowerCase())).map((filePath) => {
3640
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(context.rootDirectory, filePath);
3641
+ return normalizeProjectPath(path.relative(context.rootDirectory, absolutePath));
3642
+ }).filter((filePath) => filePath.length > 0 && !filePath.startsWith(".."));
3643
+ return [...new Set(targets)];
3644
+ };
3645
+ const getRuffDiagnosticPath = (rootDirectory, filePath) => {
3646
+ const normalizedPath = filePath.replace(/^a\//, "");
3647
+ return normalizeProjectPath(path.isAbsolute(normalizedPath) ? path.relative(rootDirectory, normalizedPath) : normalizedPath);
3648
+ };
3649
+
3521
3650
  //#endregion
3522
3651
  //#region src/engines/format/ruff-format.ts
3523
3652
  const runRuffFormat = async (context) => {
3524
3653
  const ruffBinary = resolveToolBinary("ruff");
3654
+ const targets = getPythonTargets(context);
3655
+ if (targets.length === 0) return [];
3525
3656
  try {
3526
3657
  const result = await runSubprocess(ruffBinary, [
3527
3658
  "format",
3528
3659
  "--check",
3529
3660
  "--diff",
3530
- context.rootDirectory
3661
+ ...targets
3531
3662
  ], {
3532
3663
  cwd: context.rootDirectory,
3533
3664
  timeout: 6e4
@@ -3543,9 +3674,9 @@ const parseRuffFormatOutput = (output, rootDir) => {
3543
3674
  const filePattern = /^--- (.+)$/gm;
3544
3675
  let match;
3545
3676
  while ((match = filePattern.exec(output)) !== null) {
3546
- const filePath = match[1].replace(/^a\//, "");
3677
+ const filePath = getRuffDiagnosticPath(rootDir, match[1]);
3547
3678
  diagnostics.push({
3548
- filePath: path.relative(rootDir, filePath),
3679
+ filePath,
3549
3680
  engine: "format",
3550
3681
  rule: "python-formatting",
3551
3682
  severity: "warning",
@@ -3815,6 +3946,95 @@ const resolveOxlintBinary = () => {
3815
3946
  return "oxlint";
3816
3947
  }
3817
3948
  };
3949
+ const VITE_QUERY_RE = /["'][^"']*\?(worker|sharedworker|worker-url|url|raw|inline|init)\b/;
3950
+ const isViteVirtualImportFalsePositive = (rule, message) => rule.startsWith("import/") && VITE_QUERY_RE.test(message);
3951
+ const AMBIENT_GLOBAL_DEPS = [
3952
+ "unplugin-icons",
3953
+ "@types/bun",
3954
+ "bun-types"
3955
+ ];
3956
+ const SST_PLATFORM_REF_RE = /\/\/\/\s*<reference\s+path=["'][^"']*sst[\\/]+platform[\\/]+config\.d\.ts["']/;
3957
+ const ICON_AUTOIMPORT_RE = /^Icon[A-Z]/;
3958
+ const NO_UNDEF_IDENT_RE = /^['‘"`]([^'’"`]+)['’"`]/;
3959
+ const detectAmbientSources = (rootDir) => {
3960
+ const found = /* @__PURE__ */ new Set();
3961
+ const skipDirs = new Set([
3962
+ "node_modules",
3963
+ ".git",
3964
+ "dist",
3965
+ "build",
3966
+ "out",
3967
+ "target",
3968
+ "coverage",
3969
+ ".next",
3970
+ ".turbo"
3971
+ ]);
3972
+ const walk = (dir, depth) => {
3973
+ if (depth > 4 || found.size === AMBIENT_GLOBAL_DEPS.length) return;
3974
+ let entries;
3975
+ try {
3976
+ entries = fs.readdirSync(dir, { withFileTypes: true });
3977
+ } catch {
3978
+ return;
3979
+ }
3980
+ for (const entry of entries) {
3981
+ if (found.size === AMBIENT_GLOBAL_DEPS.length) return;
3982
+ if (entry.name.startsWith(".") && entry.name !== ".github") continue;
3983
+ if (skipDirs.has(entry.name)) continue;
3984
+ const full = path.join(dir, entry.name);
3985
+ if (entry.isDirectory()) walk(full, depth + 1);
3986
+ else if (entry.name === "package.json") try {
3987
+ const pkg = JSON.parse(fs.readFileSync(full, "utf-8"));
3988
+ const allDeps = {
3989
+ ...pkg.dependencies ?? {},
3990
+ ...pkg.devDependencies ?? {},
3991
+ ...pkg.peerDependencies ?? {}
3992
+ };
3993
+ for (const dep of AMBIENT_GLOBAL_DEPS) if (dep in allDeps) found.add(dep);
3994
+ } catch {}
3995
+ }
3996
+ };
3997
+ walk(rootDir, 0);
3998
+ return found;
3999
+ };
4000
+ const extractNoUndefIdentifier = (message) => {
4001
+ return NO_UNDEF_IDENT_RE.exec(message)?.[1] ?? null;
4002
+ };
4003
+ const isAmbientFalsePositive = (rule, message, sources) => {
4004
+ if (rule !== "eslint/no-undef") return false;
4005
+ const ident = extractNoUndefIdentifier(message);
4006
+ if (!ident) return false;
4007
+ if (sources.has("unplugin-icons") && ICON_AUTOIMPORT_RE.test(ident)) return true;
4008
+ if ((sources.has("@types/bun") || sources.has("bun-types")) && ident === "Bun") return true;
4009
+ return false;
4010
+ };
4011
+ const sstReferencedFiles = /* @__PURE__ */ new Map();
4012
+ const fileReferencesSstPlatform = (rootDir, relativeFilePath) => {
4013
+ const cached = sstReferencedFiles.get(relativeFilePath);
4014
+ if (cached !== void 0) return cached;
4015
+ const absolute = path.isAbsolute(relativeFilePath) ? relativeFilePath : path.join(rootDir, relativeFilePath);
4016
+ let referenced = false;
4017
+ try {
4018
+ const fd = fs.openSync(absolute, "r");
4019
+ try {
4020
+ const buf = Buffer.alloc(512);
4021
+ const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
4022
+ referenced = SST_PLATFORM_REF_RE.test(buf.toString("utf-8", 0, bytesRead));
4023
+ } finally {
4024
+ fs.closeSync(fd);
4025
+ }
4026
+ } catch {
4027
+ referenced = false;
4028
+ }
4029
+ sstReferencedFiles.set(relativeFilePath, referenced);
4030
+ return referenced;
4031
+ };
4032
+ const UNUSED_VAR_IDENT_RE = /(?:Variable|Parameter|Catch parameter) '([^']+)' (?:is declared but never used|is caught but never used)/;
4033
+ const isUnderscoreUnusedVar = (rule, message) => {
4034
+ if (rule !== "eslint/no-unused-vars") return false;
4035
+ const match = UNUSED_VAR_IDENT_RE.exec(message);
4036
+ return match ? match[1].startsWith("_") : false;
4037
+ };
3818
4038
  const parseRuleCode = (code) => {
3819
4039
  if (!code) return {
3820
4040
  plugin: "eslint",
@@ -3853,6 +4073,8 @@ const runOxlint = async (context) => {
3853
4073
  framework: context.frameworks.find((f) => f !== "none"),
3854
4074
  testFramework: detectTestFramework(context.rootDirectory)
3855
4075
  });
4076
+ const ambientSources = detectAmbientSources(context.rootDirectory);
4077
+ sstReferencedFiles.clear();
3856
4078
  try {
3857
4079
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
3858
4080
  const args = [
@@ -3892,6 +4114,11 @@ const runOxlint = async (context) => {
3892
4114
  fixable: false
3893
4115
  };
3894
4116
  }).filter((d) => {
4117
+ if (isExcludedFromScan(path.isAbsolute(d.filePath) ? path.relative(context.rootDirectory, d.filePath) : d.filePath)) return false;
4118
+ if (isViteVirtualImportFalsePositive(d.rule, d.message)) return false;
4119
+ if (isAmbientFalsePositive(d.rule, d.message, ambientSources)) return false;
4120
+ if (isUnderscoreUnusedVar(d.rule, d.message)) return false;
4121
+ if (d.rule === "eslint/no-undef" && fileReferencesSstPlatform(context.rootDirectory, d.filePath)) return false;
3895
4122
  const key = `${d.filePath}:${d.line}:${d.rule}:${d.message}`;
3896
4123
  if (seen.has(key)) return false;
3897
4124
  seen.add(key);
@@ -3906,18 +4133,20 @@ const runOxlint = async (context) => {
3906
4133
  //#region src/engines/lint/ruff.ts
3907
4134
  const runRuffLint = async (context) => {
3908
4135
  const ruffBinary = resolveToolBinary("ruff");
4136
+ const targets = getPythonTargets(context);
4137
+ if (targets.length === 0) return [];
3909
4138
  try {
3910
4139
  const output = (await runSubprocess(ruffBinary, [
3911
4140
  "check",
3912
4141
  "--output-format=json",
3913
- context.rootDirectory
4142
+ ...targets
3914
4143
  ], {
3915
4144
  cwd: context.rootDirectory,
3916
4145
  timeout: 6e4
3917
4146
  })).stdout;
3918
4147
  if (!output) return [];
3919
4148
  return JSON.parse(output).map((d) => ({
3920
- filePath: path.relative(context.rootDirectory, d.filename),
4149
+ filePath: getRuffDiagnosticPath(context.rootDirectory, d.filename),
3921
4150
  engine: "lint",
3922
4151
  rule: `ruff/${d.code}`,
3923
4152
  severity: d.code.startsWith("E") || d.code.startsWith("F") ? "error" : "warning",
@@ -4012,56 +4241,94 @@ const runPnpmAuditWithFallback = async (rootDir, timeout) => {
4012
4241
  return [];
4013
4242
  }
4014
4243
  };
4244
+ const SEVERITY_RANK = {
4245
+ critical: 4,
4246
+ high: 3,
4247
+ moderate: 2,
4248
+ low: 1
4249
+ };
4015
4250
  const toSeverity = (value) => value === "critical" || value === "high" ? "error" : "warning";
4016
- const defaultAuditFixCommand = (source) => source === "pnpm audit" ? "pnpm audit --fix" : "npm audit fix";
4251
+ const upsertVuln = (bucket, packageName, severity, recommendation) => {
4252
+ const existing = bucket.get(packageName);
4253
+ if (existing) {
4254
+ existing.advisories++;
4255
+ if ((SEVERITY_RANK[severity] ?? 0) > (SEVERITY_RANK[existing.worstSeverity] ?? 0)) existing.worstSeverity = severity;
4256
+ if (recommendation) existing.recommendations.add(recommendation);
4257
+ } else bucket.set(packageName, {
4258
+ packageName,
4259
+ worstSeverity: severity,
4260
+ advisories: 1,
4261
+ recommendations: recommendation ? new Set([recommendation]) : /* @__PURE__ */ new Set()
4262
+ });
4263
+ };
4264
+ const SEMVER_RE = /(\d+)\.(\d+)\.(\d+)/;
4265
+ const cmpSemver = (a, b) => {
4266
+ const [, a1, a2, a3] = SEMVER_RE.exec(a) ?? [
4267
+ "",
4268
+ "0",
4269
+ "0",
4270
+ "0"
4271
+ ];
4272
+ const [, b1, b2, b3] = SEMVER_RE.exec(b) ?? [
4273
+ "",
4274
+ "0",
4275
+ "0",
4276
+ "0"
4277
+ ];
4278
+ if (Number(a1) !== Number(b1)) return Number(a1) - Number(b1);
4279
+ if (Number(a2) !== Number(b2)) return Number(a2) - Number(b2);
4280
+ return Number(a3) - Number(b3);
4281
+ };
4282
+ const pickBestRecommendation = (recs) => {
4283
+ if (recs.length <= 1) return recs[0] ?? "";
4284
+ const versioned = recs.filter((r) => SEMVER_RE.test(r));
4285
+ if (versioned.length === 0) return recs[0];
4286
+ return versioned.reduce((best, r) => cmpSemver(r, best) > 0 ? r : best);
4287
+ };
4288
+ const cleanRecommendation = (raw) => {
4289
+ const t = raw.trim();
4290
+ if (!t || t.toLowerCase() === "none") return "no fix available";
4291
+ return t;
4292
+ };
4293
+ const aggregateToDiagnostic = (agg, source) => {
4294
+ const best = cleanRecommendation(pickBestRecommendation([...agg.recommendations]));
4295
+ const countLabel = agg.advisories > 1 ? ` (${agg.advisories} advisories)` : "";
4296
+ const recLabel = best ? ` — ${best}` : "";
4297
+ return {
4298
+ filePath: "package.json",
4299
+ engine: "security",
4300
+ rule: "security/vulnerable-dependency",
4301
+ severity: toSeverity(agg.worstSeverity),
4302
+ message: `${agg.packageName} (${agg.worstSeverity})${recLabel}${countLabel}`,
4303
+ help: "",
4304
+ line: 0,
4305
+ column: 0,
4306
+ category: "Security",
4307
+ fixable: false,
4308
+ detail: source === "npm audit" ? "npm" : "pnpm"
4309
+ };
4310
+ };
4017
4311
  const parseLegacyAdvisories = (advisories, source) => {
4018
- const diagnostics = [];
4019
- for (const [key, advisory] of Object.entries(advisories)) {
4020
- const packageName = advisory.module_name ?? advisory.name ?? advisory.package ?? key;
4021
- const severity = (advisory.severity ?? "moderate").toLowerCase();
4022
- const recommendation = advisory.recommendation ?? advisory.title ?? `Run \`${defaultAuditFixCommand(source)}\` to resolve`;
4023
- diagnostics.push({
4024
- filePath: "package.json",
4025
- engine: "security",
4026
- rule: "security/vulnerable-dependency",
4027
- severity: toSeverity(severity),
4028
- message: `Vulnerable dependency (${source}): ${packageName} (${severity})`,
4029
- help: withFixHint(recommendation),
4030
- line: 0,
4031
- column: 0,
4032
- category: "Security",
4033
- fixable: false
4034
- });
4035
- }
4036
- return diagnostics;
4312
+ const bucket = /* @__PURE__ */ new Map();
4313
+ for (const [key, advisory] of Object.entries(advisories)) upsertVuln(bucket, advisory.module_name ?? advisory.name ?? advisory.package ?? key, (advisory.severity ?? "moderate").toLowerCase(), advisory.recommendation ?? advisory.title ?? "");
4314
+ return [...bucket.values()].map((agg) => aggregateToDiagnostic(agg, source));
4037
4315
  };
4038
4316
  const parseModernVulnerabilities = (vulnerabilities, source) => {
4039
- const diagnostics = [];
4317
+ const bucket = /* @__PURE__ */ new Map();
4040
4318
  for (const [packageName, vulnerability] of Object.entries(vulnerabilities)) {
4041
4319
  const severity = (vulnerability.severity ?? "moderate").toLowerCase();
4042
4320
  const fixAvailable = vulnerability.fixAvailable;
4043
4321
  const isDirect = vulnerability.isDirect === true;
4044
- let recommendation = `Run \`${defaultAuditFixCommand(source)}\` to resolve`;
4045
- if (fixAvailable === false) recommendation = isDirect ? "No automatic fix — check for a newer major version" : "Transitive with no fix add an override or upgrade the parent";
4046
- else if (!isDirect && fixAvailable === true) recommendation = "Transitive dep — may need an override or parent upgrade";
4322
+ let recommendation = "";
4323
+ if (fixAvailable === false) recommendation = isDirect ? "no automatic fix" : "transitiveneeds override or parent upgrade";
4324
+ else if (!isDirect && fixAvailable === true) recommendation = "transitive — may need override or parent upgrade";
4047
4325
  else if (fixAvailable && typeof fixAvailable === "object" && "name" in fixAvailable && "version" in fixAvailable) {
4048
4326
  const target = fixAvailable;
4049
- if (target.name && target.version) recommendation = `Upgrade to ${target.name}@${target.version}.`;
4327
+ if (target.name && target.version) recommendation = `upgrade to ${target.name}@${target.version}`;
4050
4328
  }
4051
- diagnostics.push({
4052
- filePath: "package.json",
4053
- engine: "security",
4054
- rule: "security/vulnerable-dependency",
4055
- severity: toSeverity(severity),
4056
- message: `Vulnerable dependency (${source}): ${packageName} (${severity})`,
4057
- help: withFixHint(recommendation),
4058
- line: 0,
4059
- column: 0,
4060
- category: "Security",
4061
- fixable: false
4062
- });
4329
+ upsertVuln(bucket, packageName, severity, recommendation);
4063
4330
  }
4064
- return diagnostics;
4331
+ return [...bucket.values()].map((agg) => aggregateToDiagnostic(agg, source));
4065
4332
  };
4066
4333
  const parseJsAudit = (output, source) => {
4067
4334
  if (!output) return [];
@@ -4977,10 +5244,17 @@ const runScan = async (cwd) => {
4977
5244
  const config = loadConfig(cwd);
4978
5245
  const diagnostics = (await runEngines(buildEngineContext(project.rootDirectory, project, config), enabledEnginesFromConfig(config))).flatMap((r) => r.diagnostics);
4979
5246
  const { score } = calculateScore(diagnostics, config.scoring.weights, config.scoring.thresholds, project.sourceFileCount, config.scoring.smoothing);
5247
+ const errorCount = diagnostics.filter((d) => d.severity === "error").length;
5248
+ const failBelow = config.ci.failBelow;
4980
5249
  return {
4981
5250
  project,
4982
5251
  diagnostics,
4983
- score
5252
+ score,
5253
+ qualityGate: {
5254
+ failBelow,
5255
+ passed: errorCount === 0 && score >= failBelow,
5256
+ errorCount
5257
+ }
4984
5258
  };
4985
5259
  };
4986
5260
  const aislopScanInputSchema = z.object({ path: z.string().optional().describe("Project directory to scan. Defaults to the MCP server's cwd.") });
@@ -4990,10 +5264,11 @@ const aislopScanTool = {
4990
5264
  inputSchema: aislopScanInputSchema
4991
5265
  };
4992
5266
  const handleAislopScan = async (input) => {
4993
- const { project, diagnostics, score } = await runScan(resolveCwd(input.path));
5267
+ const { project, diagnostics, score, qualityGate } = await runScan(resolveCwd(input.path));
4994
5268
  const summary = summariseDiagnostics(diagnostics, project.rootDirectory);
4995
5269
  return {
4996
5270
  score,
5271
+ qualityGate,
4997
5272
  fileCount: project.sourceFileCount,
4998
5273
  languages: project.languages,
4999
5274
  frameworks: project.frameworks,
@@ -5094,7 +5369,225 @@ const handleAislopBaseline = (input) => {
5094
5369
 
5095
5370
  //#endregion
5096
5371
  //#region src/version.ts
5097
- const APP_VERSION = "0.8.3";
5372
+ const APP_VERSION = "0.9.1";
5373
+
5374
+ //#endregion
5375
+ //#region src/telemetry/env.ts
5376
+ const detectPackageManager = (env = process.env) => {
5377
+ const execPath = env.npm_execpath ?? "";
5378
+ if (execPath.includes("npx")) return "npx";
5379
+ const userAgent = env.npm_config_user_agent ?? "";
5380
+ if (userAgent.startsWith("pnpm/")) return "pnpm";
5381
+ if (userAgent.startsWith("yarn/")) return "yarn";
5382
+ if (userAgent.startsWith("bun/")) return "bun";
5383
+ if (userAgent.startsWith("npm/")) return "npm";
5384
+ if (execPath.includes("pnpm")) return "pnpm";
5385
+ if (execPath.includes("yarn")) return "yarn";
5386
+ if (execPath.includes("bun")) return "bun";
5387
+ if (execPath.includes("npm")) return "npm";
5388
+ return "unknown";
5389
+ };
5390
+ const CI_ENV_KEYS = [
5391
+ "CI",
5392
+ "GITHUB_ACTIONS",
5393
+ "GITLAB_CI",
5394
+ "CIRCLECI",
5395
+ "TRAVIS",
5396
+ "BUILDKITE",
5397
+ "DRONE",
5398
+ "TEAMCITY_VERSION",
5399
+ "TF_BUILD"
5400
+ ];
5401
+ const isCiEnv = (env = process.env) => CI_ENV_KEYS.some((k) => {
5402
+ const v = env[k];
5403
+ return v === "true" || v === "1" || v != null && v.length > 0 && k !== "CI";
5404
+ }) || env.CI === "true" || env.CI === "1";
5405
+
5406
+ //#endregion
5407
+ //#region src/telemetry/identity.ts
5408
+ const FILE_BASENAME = "install_id";
5409
+ const resolveInstallIdPath = (homedir = os.homedir(), env = process.env) => {
5410
+ if (process.platform === "linux" && env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "aislop", FILE_BASENAME);
5411
+ return path.join(homedir, ".aislop", FILE_BASENAME);
5412
+ };
5413
+ const ensureInstallId = (idPath = resolveInstallIdPath()) => {
5414
+ if (fs.existsSync(idPath)) {
5415
+ const existing = fs.readFileSync(idPath, "utf-8").trim();
5416
+ if (existing.length > 0) return {
5417
+ installId: existing,
5418
+ created: false
5419
+ };
5420
+ }
5421
+ const dir = path.dirname(idPath);
5422
+ fs.mkdirSync(dir, { recursive: true });
5423
+ const installId = randomUUID();
5424
+ const tmpPath = `${idPath}.${process.pid}.tmp`;
5425
+ fs.writeFileSync(tmpPath, `${installId}\n`, { mode: 384 });
5426
+ try {
5427
+ fs.renameSync(tmpPath, idPath);
5428
+ return {
5429
+ installId,
5430
+ created: true
5431
+ };
5432
+ } catch {
5433
+ fs.rmSync(tmpPath, { force: true });
5434
+ return {
5435
+ installId: fs.readFileSync(idPath, "utf-8").trim(),
5436
+ created: false
5437
+ };
5438
+ }
5439
+ };
5440
+
5441
+ //#endregion
5442
+ //#region src/telemetry/redaction.ts
5443
+ const SAFE_PROPERTY_NAMES = new Set([
5444
+ "aislop_version",
5445
+ "node_version",
5446
+ "os",
5447
+ "arch",
5448
+ "schema_version",
5449
+ "anonymous_install_id",
5450
+ "package_manager",
5451
+ "is_ci",
5452
+ "command",
5453
+ "language_summary",
5454
+ "lang_typescript",
5455
+ "lang_javascript",
5456
+ "lang_python",
5457
+ "lang_java",
5458
+ "file_count_bucket",
5459
+ "exit_code",
5460
+ "duration_ms",
5461
+ "error_kind",
5462
+ "score",
5463
+ "score_bucket",
5464
+ "finding_count",
5465
+ "error_count",
5466
+ "warning_count",
5467
+ "fixable_count",
5468
+ "fix_steps",
5469
+ "fix_resolved",
5470
+ "fix_score_delta",
5471
+ "engine_format_issues",
5472
+ "engine_format_ms",
5473
+ "engine_lint_issues",
5474
+ "engine_lint_ms",
5475
+ "engine_code_quality_issues",
5476
+ "engine_code_quality_ms",
5477
+ "engine_ai_slop_issues",
5478
+ "engine_ai_slop_ms",
5479
+ "engine_architecture_issues",
5480
+ "engine_architecture_ms",
5481
+ "engine_security_issues",
5482
+ "engine_security_ms",
5483
+ "tool",
5484
+ "ok",
5485
+ "agent",
5486
+ "score_delta"
5487
+ ]);
5488
+ const redactProperties = (props) => {
5489
+ const clean = {};
5490
+ const dropped = [];
5491
+ for (const [key, value] of Object.entries(props)) {
5492
+ if (value === void 0) continue;
5493
+ if (SAFE_PROPERTY_NAMES.has(key)) clean[key] = value;
5494
+ else dropped.push(key);
5495
+ }
5496
+ return {
5497
+ clean,
5498
+ dropped
5499
+ };
5500
+ };
5501
+
5502
+ //#endregion
5503
+ //#region src/telemetry/client.ts
5504
+ const POSTHOG_HOST = "https://eu.i.posthog.com";
5505
+ const POSTHOG_KEY = "phc_eY2cOMFva9q24GrWeOuvuVIOhCIdjOALxeAR3ItrqbJ";
5506
+ const SCHEMA_VERSION = "v2";
5507
+ const REQUEST_TIMEOUT_MS = 3e3;
5508
+ const isTelemetryDisabled = (config) => {
5509
+ const env = process.env;
5510
+ if (env.AISLOP_NO_TELEMETRY === "1" || env.DO_NOT_TRACK === "1") return true;
5511
+ if (config?.enabled === false) return true;
5512
+ if (config?.enabled === true) return false;
5513
+ if (env.CI === "true" || env.CI === "1") return true;
5514
+ return false;
5515
+ };
5516
+ const isDebug = () => process.env.AISLOP_TELEMETRY_DEBUG === "1";
5517
+ const pendingRequests = /* @__PURE__ */ new Set();
5518
+ let cachedInstallId = null;
5519
+ let installCreated = false;
5520
+ const baseProperties = (installId) => ({
5521
+ aislop_version: APP_VERSION,
5522
+ node_version: process.version,
5523
+ os: os.platform(),
5524
+ arch: os.arch(),
5525
+ schema_version: SCHEMA_VERSION,
5526
+ anonymous_install_id: installId,
5527
+ package_manager: detectPackageManager(),
5528
+ is_ci: isCiEnv()
5529
+ });
5530
+ const track = (input) => {
5531
+ if (isTelemetryDisabled(input.config)) return { installCreated: false };
5532
+ if (cachedInstallId == null) {
5533
+ const ensured = ensureInstallId(resolveInstallIdPath());
5534
+ cachedInstallId = ensured.installId;
5535
+ installCreated = ensured.created;
5536
+ }
5537
+ const { clean, dropped } = redactProperties({
5538
+ ...baseProperties(cachedInstallId),
5539
+ ...input.properties
5540
+ });
5541
+ if (isDebug()) {
5542
+ const compact = JSON.stringify({
5543
+ event: input.event,
5544
+ properties: clean
5545
+ });
5546
+ process.stderr.write(`[telemetry] ${compact}\n`);
5547
+ if (dropped.length > 0) for (const key of dropped) process.stderr.write(`[telemetry] dropped non-allowlisted property: ${key}\n`);
5548
+ }
5549
+ if (process.env.AISLOP_TELEMETRY_DRY_RUN === "1") return { installCreated };
5550
+ const payload = {
5551
+ api_key: POSTHOG_KEY,
5552
+ event: input.event,
5553
+ distinct_id: cachedInstallId,
5554
+ properties: clean,
5555
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
5556
+ };
5557
+ const request = fetch(`${POSTHOG_HOST}/capture/`, {
5558
+ method: "POST",
5559
+ headers: { "Content-Type": "application/json" },
5560
+ body: JSON.stringify(payload),
5561
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
5562
+ }).then(() => {}).catch(() => {}).finally(() => {
5563
+ pendingRequests.delete(request);
5564
+ });
5565
+ pendingRequests.add(request);
5566
+ return { installCreated };
5567
+ };
5568
+ const flushTelemetry = async () => {
5569
+ if (pendingRequests.size === 0) return;
5570
+ await Promise.all(pendingRequests);
5571
+ };
5572
+
5573
+ //#endregion
5574
+ //#region src/telemetry/events.ts
5575
+ const buildMcpToolCalledProps = (input) => {
5576
+ const props = {
5577
+ tool: input.tool,
5578
+ duration_ms: Math.round(input.durationMs),
5579
+ ok: input.ok
5580
+ };
5581
+ if (input.errorKind) props.error_kind = input.errorKind;
5582
+ return props;
5583
+ };
5584
+ const errorKindFromException = (error) => {
5585
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
5586
+ if (message.includes("timeout") || message.includes("timed out")) return "timeout";
5587
+ if (message.includes("invalid config") || message.includes("config_invalid")) return "config_invalid";
5588
+ if (message.includes("engine") && message.includes("crash")) return "engine_crash";
5589
+ return "unknown";
5590
+ };
5098
5591
 
5099
5592
  //#endregion
5100
5593
  //#region src/mcp.ts
@@ -5109,10 +5602,29 @@ const err = (message) => ({
5109
5602
  }],
5110
5603
  isError: true
5111
5604
  });
5112
- const tryHandle = async (fn) => {
5605
+ const instrument = async (tool, fn) => {
5606
+ const startedAt = performance.now();
5113
5607
  try {
5114
- return ok(await fn());
5608
+ const value = await fn();
5609
+ track({
5610
+ event: "mcp_tool_called",
5611
+ properties: buildMcpToolCalledProps({
5612
+ tool,
5613
+ durationMs: performance.now() - startedAt,
5614
+ ok: true
5615
+ })
5616
+ });
5617
+ return ok(value);
5115
5618
  } catch (e) {
5619
+ track({
5620
+ event: "mcp_tool_called",
5621
+ properties: buildMcpToolCalledProps({
5622
+ tool,
5623
+ durationMs: performance.now() - startedAt,
5624
+ ok: false,
5625
+ errorKind: errorKindFromException(e)
5626
+ })
5627
+ });
5116
5628
  return err(e instanceof Error ? e.message : String(e));
5117
5629
  }
5118
5630
  };
@@ -5124,25 +5636,27 @@ const buildServer = () => {
5124
5636
  server.registerTool(aislopScanTool.name, {
5125
5637
  description: aislopScanTool.description,
5126
5638
  inputSchema: aislopScanInputSchema.shape
5127
- }, (input) => tryHandle(() => handleAislopScan(input)));
5639
+ }, (input) => instrument("aislop_scan", () => handleAislopScan(input)));
5128
5640
  server.registerTool(aislopFixTool.name, {
5129
5641
  description: aislopFixTool.description,
5130
5642
  inputSchema: aislopFixInputSchema.shape
5131
- }, (input) => tryHandle(() => handleAislopFix(input)));
5643
+ }, (input) => instrument("aislop_fix", () => handleAislopFix(input)));
5132
5644
  server.registerTool(aislopWhyTool.name, {
5133
5645
  description: aislopWhyTool.description,
5134
5646
  inputSchema: aislopWhyInputSchema.shape
5135
- }, (input) => tryHandle(() => handleAislopWhy(input)));
5647
+ }, (input) => instrument("aislop_why", () => handleAislopWhy(input)));
5136
5648
  server.registerTool(aislopBaselineTool.name, {
5137
5649
  description: aislopBaselineTool.description,
5138
5650
  inputSchema: aislopBaselineInputSchema.shape
5139
- }, (input) => tryHandle(() => handleAislopBaseline(input)));
5651
+ }, (input) => instrument("aislop_baseline", () => handleAislopBaseline(input)));
5140
5652
  return server;
5141
5653
  };
5142
5654
  const main = async () => {
5143
5655
  const server = buildServer();
5144
5656
  const transport = new StdioServerTransport();
5145
5657
  await server.connect(transport);
5658
+ track({ event: "mcp_server_started" });
5659
+ await flushTelemetry();
5146
5660
  };
5147
5661
  main().catch((e) => {
5148
5662
  process.stderr.write(`aislop-mcp failed to start: ${e instanceof Error ? e.message : String(e)}\n`);