aislop 0.1.3 → 0.2.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/index.js CHANGED
@@ -1,10 +1,10 @@
1
- import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-Bi8pE12U.js";
1
+ import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-DFze-2GQ.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";
5
5
  import path from "node:path";
6
6
  import pc from "picocolors";
7
- import { spawn, spawnSync } from "node:child_process";
7
+ import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { performance } from "node:perf_hooks";
10
10
  import os from "node:os";
@@ -52,8 +52,7 @@ const logger = {
52
52
  //#region src/output/layout.ts
53
53
  const formatElapsed$1 = (elapsedMs) => elapsedMs < 1e3 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1e3).toFixed(1)}s`;
54
54
  const printCommandHeader = (commandName) => {
55
- logger.log(highlighter.bold(`aislop ${commandName}`));
56
- logger.log(highlighter.dim(`v${APP_VERSION}`));
55
+ logger.log(`${highlighter.bold(`aislop ${commandName.toLowerCase()}`)} ${highlighter.dim(`v${APP_VERSION}`)}`);
57
56
  logger.break();
58
57
  };
59
58
  const formatProjectSummary = (project) => `Project ${highlighter.info(project.projectName)} (${highlighter.info(project.languages.join(", "))})`;
@@ -529,6 +528,439 @@ const doctorCommand = async (directory) => {
529
528
  printDoctorConclusion(isAllGood());
530
529
  };
531
530
 
531
+ //#endregion
532
+ //#region src/engines/ai-slop/unused-imports.ts
533
+ const JS_EXTENSIONS$1 = new Set([
534
+ ".ts",
535
+ ".tsx",
536
+ ".js",
537
+ ".jsx",
538
+ ".mjs",
539
+ ".cjs"
540
+ ]);
541
+ const PY_EXTENSIONS = new Set([".py"]);
542
+ const REMOVE_MARKER = "\0__AISLOP_REMOVE__";
543
+ const extractJsImportedSymbols = (lines) => {
544
+ const symbols = [];
545
+ const importLines = /* @__PURE__ */ new Set();
546
+ for (let i = 0; i < lines.length; i++) {
547
+ const trimmed = lines[i].trim();
548
+ if (!trimmed.startsWith("import ")) continue;
549
+ importLines.add(i);
550
+ if (/^import\s+["']/.test(trimmed)) continue;
551
+ if (/^import\s+type\s/.test(trimmed)) continue;
552
+ let fullImport = trimmed;
553
+ let endLine = i;
554
+ while (!fullImport.includes("from") && endLine < lines.length - 1) {
555
+ endLine++;
556
+ fullImport += ` ${lines[endLine].trim()}`;
557
+ importLines.add(endLine);
558
+ }
559
+ const namespaceMatch = fullImport.match(/import\s+\*\s+as\s+(\w+)\s+from/);
560
+ if (namespaceMatch) {
561
+ symbols.push({
562
+ name: namespaceMatch[1],
563
+ line: i + 1,
564
+ isDefault: false,
565
+ isNamespace: true
566
+ });
567
+ continue;
568
+ }
569
+ const defaultMatch = fullImport.match(/import\s+(\w+)\s*(?:,\s*\{[^}]*\})?\s+from/);
570
+ if (defaultMatch && defaultMatch[1] !== "type") symbols.push({
571
+ name: defaultMatch[1],
572
+ line: i + 1,
573
+ isDefault: true,
574
+ isNamespace: false
575
+ });
576
+ const namedMatch = fullImport.match(/\{([^}]+)\}/);
577
+ if (namedMatch) {
578
+ const namedImports = namedMatch[1].split(",");
579
+ for (const ni of namedImports) {
580
+ const parts = ni.trim().split(/\s+as\s+/);
581
+ if (parts.length === 0 || !parts[0]) continue;
582
+ const cleanParts = parts.map((p) => p.trim().replace(/^type\s+/, ""));
583
+ const localName = cleanParts.length > 1 ? cleanParts[1] : cleanParts[0];
584
+ if (localName && /^\w+$/.test(localName)) symbols.push({
585
+ name: localName,
586
+ line: i + 1,
587
+ isDefault: false,
588
+ isNamespace: false
589
+ });
590
+ }
591
+ }
592
+ }
593
+ return {
594
+ symbols,
595
+ importLines
596
+ };
597
+ };
598
+ const extractPyImportedSymbols = (lines) => {
599
+ const symbols = [];
600
+ const importLines = /* @__PURE__ */ new Set();
601
+ for (let i = 0; i < lines.length; i++) {
602
+ const trimmed = lines[i].trim();
603
+ const fromMatch = trimmed.match(/^from\s+[\w.]+\s+import\s+(.+)/);
604
+ if (fromMatch) {
605
+ importLines.add(i);
606
+ const importPart = fromMatch[1].replace(/#.*$/, "").trim();
607
+ if (importPart === "*") continue;
608
+ const cleaned = importPart.replace(/[()]/g, "");
609
+ for (const item of cleaned.split(",")) {
610
+ const parts = item.trim().split(/\s+as\s+/);
611
+ const localName = parts.length > 1 ? parts[1].trim() : parts[0].trim();
612
+ if (localName && /^\w+$/.test(localName)) symbols.push({
613
+ name: localName,
614
+ line: i + 1,
615
+ isDefault: false,
616
+ isNamespace: false
617
+ });
618
+ }
619
+ continue;
620
+ }
621
+ const importMatch = trimmed.match(/^import\s+([\w.]+)(?:\s+as\s+(\w+))?/);
622
+ if (importMatch) {
623
+ importLines.add(i);
624
+ const simpleName = (importMatch[2] ?? importMatch[1]).split(".")[0];
625
+ if (simpleName && /^\w+$/.test(simpleName)) symbols.push({
626
+ name: simpleName,
627
+ line: i + 1,
628
+ isDefault: false,
629
+ isNamespace: true
630
+ });
631
+ }
632
+ }
633
+ return {
634
+ symbols,
635
+ importLines
636
+ };
637
+ };
638
+ const isSymbolUsed = (name, content, importLines, lines) => {
639
+ const pattern = new RegExp(`\\b${name}\\b`, "g");
640
+ let match;
641
+ while ((match = pattern.exec(content)) !== null) {
642
+ const lineIndex = content.slice(0, match.index).split("\n").length - 1;
643
+ if (!importLines.has(lineIndex)) return true;
644
+ }
645
+ for (let i = 0; i < lines.length; i++) {
646
+ if (importLines.has(i)) continue;
647
+ if (lines[i].includes(name)) return true;
648
+ }
649
+ return false;
650
+ };
651
+ const analyzeFile = (filePath) => {
652
+ if (isAutoGenerated(filePath)) return null;
653
+ let content;
654
+ try {
655
+ content = fs.readFileSync(filePath, "utf-8");
656
+ } catch {
657
+ return null;
658
+ }
659
+ const ext = path.extname(filePath);
660
+ const lines = content.split("\n");
661
+ let symbols;
662
+ let importLines;
663
+ if (JS_EXTENSIONS$1.has(ext)) {
664
+ const result = extractJsImportedSymbols(lines);
665
+ symbols = result.symbols;
666
+ importLines = result.importLines;
667
+ } else if (PY_EXTENSIONS.has(ext)) {
668
+ const result = extractPyImportedSymbols(lines);
669
+ symbols = result.symbols;
670
+ importLines = result.importLines;
671
+ } else return null;
672
+ return {
673
+ lines,
674
+ symbols,
675
+ importLines,
676
+ ext
677
+ };
678
+ };
679
+ const getUnusedSymbols = (lines, symbols, importLines) => {
680
+ const content = lines.join("\n");
681
+ return symbols.filter((symbol) => !isSymbolUsed(symbol.name, content, importLines, lines));
682
+ };
683
+ const detectUnusedImports = async (context) => {
684
+ const files = getSourceFiles(context);
685
+ const diagnostics = [];
686
+ for (const filePath of files) {
687
+ const analysis = analyzeFile(filePath);
688
+ if (!analysis) continue;
689
+ const relativePath = path.relative(context.rootDirectory, filePath);
690
+ const unused = getUnusedSymbols(analysis.lines, analysis.symbols, analysis.importLines);
691
+ for (const symbol of unused) diagnostics.push({
692
+ filePath: relativePath,
693
+ engine: "ai-slop",
694
+ rule: "ai-slop/unused-import",
695
+ severity: "warning",
696
+ message: `Imported symbol '${symbol.name}' is never used`,
697
+ help: "Remove unused imports to keep the code clean",
698
+ line: symbol.line,
699
+ column: 0,
700
+ category: "AI Slop",
701
+ fixable: true
702
+ });
703
+ }
704
+ return diagnostics;
705
+ };
706
+
707
+ //#endregion
708
+ //#region src/engines/ai-slop/unused-imports-fix.ts
709
+ const fixUnusedImports = async (context) => {
710
+ const files = getSourceFiles(context);
711
+ for (const filePath of files) {
712
+ const analysis = analyzeFile(filePath);
713
+ if (!analysis) continue;
714
+ const unused = getUnusedSymbols(analysis.lines, analysis.symbols, analysis.importLines);
715
+ if (unused.length === 0) continue;
716
+ const unusedNames = new Set(unused.map((u) => u.name));
717
+ const lines = [...analysis.lines];
718
+ const symbolsByLine = /* @__PURE__ */ new Map();
719
+ for (const sym of analysis.symbols) {
720
+ const arr = symbolsByLine.get(sym.line) ?? [];
721
+ arr.push(sym);
722
+ symbolsByLine.set(sym.line, arr);
723
+ }
724
+ const linesToRemove = /* @__PURE__ */ new Set();
725
+ for (const [lineNo, syms] of symbolsByLine) {
726
+ const lineIdx = lineNo - 1;
727
+ const allUnused = syms.every((s) => unusedNames.has(s.name));
728
+ const importSpan = getImportSpan(lineIdx, analysis.importLines);
729
+ if (allUnused) for (const idx of importSpan) linesToRemove.add(idx);
730
+ else if (JS_EXTENSIONS$1.has(analysis.ext)) rewriteJsImportSpan(lines, importSpan, syms, unusedNames);
731
+ else if (PY_EXTENSIONS.has(analysis.ext)) rewritePyImportLine(lines, lineIdx, syms, unusedNames);
732
+ }
733
+ if (linesToRemove.size === 0 && unused.length === 0) continue;
734
+ const sortedRemove = [...linesToRemove].sort((a, b) => b - a);
735
+ for (const idx of sortedRemove) lines.splice(idx, 1);
736
+ const filtered = lines.filter((l) => l !== REMOVE_MARKER);
737
+ while (filtered.length > 0 && filtered[0].trim() === "") filtered.shift();
738
+ fs.writeFileSync(filePath, filtered.join("\n"));
739
+ }
740
+ };
741
+ const getImportSpan = (startIdx, importLines) => {
742
+ const span = [startIdx];
743
+ let idx = startIdx + 1;
744
+ while (importLines.has(idx)) {
745
+ span.push(idx);
746
+ idx++;
747
+ }
748
+ return span;
749
+ };
750
+ const rewriteJsImportSpan = (lines, span, syms, unusedNames) => {
751
+ const fullImport = span.map((i) => lines[i]).join("\n");
752
+ const namedMatch = fullImport.match(/\{([^}]+)\}/s);
753
+ if (!namedMatch) return;
754
+ const unusedNamed = syms.filter((s) => !s.isDefault && !s.isNamespace && unusedNames.has(s.name));
755
+ if (unusedNamed.length === 0) return;
756
+ const unusedNamedSet = new Set(unusedNamed.map((s) => s.name));
757
+ const keptSpecifiers = namedMatch[1].split(",").map((s) => s.trim()).filter(Boolean).filter((spec) => {
758
+ const parts = spec.split(/\s+as\s+/);
759
+ const localName = parts.length > 1 ? parts[1].trim().replace(/^type\s+/, "") : parts[0].trim().replace(/^type\s+/, "");
760
+ return !unusedNamedSet.has(localName);
761
+ });
762
+ if (keptSpecifiers.length === 0) {
763
+ if (syms.find((s) => s.isDefault && !unusedNames.has(s.name))) {
764
+ const rewritten = fullImport.replace(/,\s*\{[^}]*\}/s, "").replace(/\{[^}]*\}\s*,?\s*/s, "");
765
+ lines[span[0]] = rewritten.replace(/\n/g, " ").replace(/\s+/g, " ");
766
+ for (let i = 1; i < span.length; i++) lines[span[i]] = REMOVE_MARKER;
767
+ }
768
+ return;
769
+ }
770
+ const fromMatch = fullImport.match(/\}\s*(from\s+.+)$/s);
771
+ const fromClause = fromMatch ? fromMatch[1].trim() : "";
772
+ const importPrefix = fullImport.match(/^(import\s+(?:\w+\s*,\s*)?)/);
773
+ const prefix = importPrefix ? importPrefix[1] : "import ";
774
+ const wasMultiLine = span.length > 1;
775
+ let newImport;
776
+ if (wasMultiLine && keptSpecifiers.length > 2) {
777
+ const indentMatch = lines[span[1]]?.match(/^(\s+)/);
778
+ const indent = indentMatch ? indentMatch[1] : " ";
779
+ newImport = `${prefix}{\n${keptSpecifiers.map((s) => `${indent}${s},`).join("\n")}\n} ${fromClause}`;
780
+ } else newImport = `${prefix}{ ${keptSpecifiers.join(", ")} } ${fromClause}`;
781
+ lines[span[0]] = newImport;
782
+ for (let i = 1; i < span.length; i++) lines[span[i]] = REMOVE_MARKER;
783
+ };
784
+ const rewritePyImportLine = (lines, lineIdx, syms, unusedNames) => {
785
+ const fromMatch = lines[lineIdx].match(/^(\s*from\s+[\w.]+\s+import\s+)(.+)$/);
786
+ if (!fromMatch) return;
787
+ const prefix = fromMatch[1];
788
+ const importPart = fromMatch[2].replace(/#.*$/, "").trim();
789
+ const hasParen = importPart.startsWith("(");
790
+ const keptSpecifiers = importPart.replace(/[()]/g, "").split(",").map((s) => s.trim()).filter((spec) => {
791
+ const parts = spec.split(/\s+as\s+/);
792
+ const localName = parts.length > 1 ? parts[1].trim() : parts[0].trim();
793
+ return !unusedNames.has(localName);
794
+ });
795
+ if (keptSpecifiers.length === 0) return;
796
+ const joined = keptSpecifiers.join(", ");
797
+ lines[lineIdx] = hasParen ? `${prefix}(${joined})` : `${prefix}${joined}`;
798
+ };
799
+
800
+ //#endregion
801
+ //#region src/engines/code-quality/knip.ts
802
+ const KNIP_MESSAGE_MAP = {
803
+ files: "Unused file",
804
+ dependencies: "Unused dependency",
805
+ devDependencies: "Unused devDependency",
806
+ unlisted: "Unlisted dependency",
807
+ unresolved: "Unresolved import",
808
+ binaries: "Unlisted binary",
809
+ exports: "Unused export",
810
+ types: "Unused type",
811
+ duplicates: "Duplicate export"
812
+ };
813
+ const DEPENDENCY_TYPES = [
814
+ "dependencies",
815
+ "devDependencies",
816
+ "unlisted",
817
+ "unresolved",
818
+ "binaries"
819
+ ];
820
+ const isDependencyType = (type) => DEPENDENCY_TYPES.includes(type);
821
+ const getIssueItems = (fileIssue, issueType) => {
822
+ const items = fileIssue[issueType];
823
+ return Array.isArray(items) ? items : [];
824
+ };
825
+ const DEPENDENCY_HELP = {
826
+ dependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
827
+ devDependencies: "This package is listed in package.json but not imported anywhere. Remove it with `npm uninstall` or `aislop fix`.",
828
+ unlisted: "This package is imported in code but not declared in package.json. Run `npm install` to add it.",
829
+ unresolved: "This import cannot be resolved. Check for typos or missing packages.",
830
+ binaries: "This binary is used but its package is not in package.json."
831
+ };
832
+ const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
833
+ const diagnostics = [];
834
+ const issues = getIssueItems(fileIssue, issueType);
835
+ const category = isDependencyType(issueType) ? "Dependencies" : "Dead Code";
836
+ const severity = issueType === "unlisted" || issueType === "unresolved" ? "error" : "warning";
837
+ const fixable = issueType === "dependencies" || issueType === "devDependencies";
838
+ const help = DEPENDENCY_HELP[issueType] ?? "";
839
+ for (const issue of issues) {
840
+ const symbol = issue.name ?? issue.symbol ?? "unknown";
841
+ const absolutePath = path.resolve(knipCwd, fileIssue.file);
842
+ diagnostics.push({
843
+ filePath: path.relative(rootDir, absolutePath),
844
+ engine: "code-quality",
845
+ rule: `knip/${issueType}`,
846
+ severity,
847
+ message: `${KNIP_MESSAGE_MAP[issueType]}: ${symbol}`,
848
+ help,
849
+ line: issue.line ?? 0,
850
+ column: issue.col ?? 0,
851
+ category,
852
+ fixable
853
+ });
854
+ }
855
+ return diagnostics;
856
+ };
857
+ const findMonorepoRoot = (directory) => {
858
+ let current = path.dirname(directory);
859
+ while (current !== path.dirname(current)) {
860
+ if (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || (() => {
861
+ const pkgPath = path.join(current, "package.json");
862
+ if (!fs.existsSync(pkgPath)) return false;
863
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
864
+ return Array.isArray(pkg.workspaces) || pkg.workspaces?.packages;
865
+ })()) return current;
866
+ current = path.dirname(current);
867
+ }
868
+ return null;
869
+ };
870
+ const KNIP_RELATIVE_BIN = path.join("node_modules", "knip", "bin", "knip.js");
871
+ const findKnipBin = (rootDirectory, monorepoRoot) => {
872
+ const localPath = path.join(rootDirectory, KNIP_RELATIVE_BIN);
873
+ if (fs.existsSync(localPath)) return {
874
+ binPath: localPath,
875
+ cwd: rootDirectory
876
+ };
877
+ if (monorepoRoot) {
878
+ const monorepoPath = path.join(monorepoRoot, KNIP_RELATIVE_BIN);
879
+ if (fs.existsSync(monorepoPath)) return {
880
+ binPath: monorepoPath,
881
+ cwd: monorepoRoot
882
+ };
883
+ }
884
+ return null;
885
+ };
886
+ const runKnipDependencyCheck = async (rootDirectory) => {
887
+ return (await runKnip(rootDirectory)).filter((d) => d.rule === "knip/dependencies" || d.rule === "knip/devDependencies");
888
+ };
889
+ const fixUnusedDependencies = async (rootDirectory) => {
890
+ const diagnostics = await runKnipDependencyCheck(rootDirectory);
891
+ if (diagnostics.length === 0) return;
892
+ const pkgPath = path.join(rootDirectory, "package.json");
893
+ if (!fs.existsSync(pkgPath)) return;
894
+ const raw = fs.readFileSync(pkgPath, "utf-8");
895
+ const pkg = JSON.parse(raw);
896
+ const unusedDeps = /* @__PURE__ */ new Set();
897
+ const unusedDevDeps = /* @__PURE__ */ new Set();
898
+ for (const d of diagnostics) {
899
+ const pkgName = d.message.replace(/^Unused (dev)?[Dd]ependency: /, "");
900
+ if (d.rule === "knip/dependencies") unusedDeps.add(pkgName);
901
+ if (d.rule === "knip/devDependencies") unusedDevDeps.add(pkgName);
902
+ }
903
+ let changed = false;
904
+ if (pkg.dependencies) {
905
+ for (const name of unusedDeps) if (name in pkg.dependencies) {
906
+ delete pkg.dependencies[name];
907
+ changed = true;
908
+ }
909
+ }
910
+ if (pkg.devDependencies) {
911
+ for (const name of unusedDevDeps) if (name in pkg.devDependencies) {
912
+ delete pkg.devDependencies[name];
913
+ changed = true;
914
+ }
915
+ }
916
+ if (changed) fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
917
+ };
918
+ const runKnip = async (rootDirectory) => {
919
+ const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
920
+ if (!knipRuntime) return [];
921
+ try {
922
+ const args = [
923
+ knipRuntime.binPath,
924
+ "--no-progress",
925
+ "--reporter",
926
+ "json",
927
+ "--no-exit-code"
928
+ ];
929
+ const result = await runSubprocess(process.execPath, args, {
930
+ cwd: knipRuntime.cwd,
931
+ timeout: 2e4,
932
+ env: { FORCE_COLOR: "0" }
933
+ });
934
+ if (!result.stdout) return [];
935
+ const parsed = JSON.parse(result.stdout);
936
+ const diagnostics = [];
937
+ const files = parsed.files ?? [];
938
+ for (const unusedFile of files) diagnostics.push({
939
+ filePath: path.relative(rootDirectory, path.resolve(knipRuntime.cwd, unusedFile)),
940
+ engine: "code-quality",
941
+ rule: "knip/files",
942
+ severity: "warning",
943
+ message: KNIP_MESSAGE_MAP.files,
944
+ help: "This file is not imported by any other file in the project.",
945
+ line: 0,
946
+ column: 0,
947
+ category: "Dead Code",
948
+ fixable: false
949
+ });
950
+ const issues = parsed.issues ?? [];
951
+ const issueTypes = [
952
+ ...DEPENDENCY_TYPES,
953
+ "exports",
954
+ "types",
955
+ "duplicates"
956
+ ];
957
+ for (const fileIssue of issues) for (const type of issueTypes) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
958
+ return diagnostics;
959
+ } catch {
960
+ return [];
961
+ }
962
+ };
963
+
532
964
  //#endregion
533
965
  //#region src/engines/format/biome.ts
534
966
  const esmRequire$1 = createRequire(import.meta.url);
@@ -627,14 +1059,11 @@ const parseBiomeJsonOutput = (output, rootDir) => {
627
1059
  const fixBiomeFormat = async (context) => {
628
1060
  const targets = getBiomeTargets(context);
629
1061
  if (targets.length === 0) return;
630
- const result = await runBiome([
631
- "check",
1062
+ await runBiome([
1063
+ "format",
632
1064
  "--write",
633
- "--formatter-enabled=true",
634
- "--linter-enabled=false",
635
1065
  ...targets
636
1066
  ], context.rootDirectory, 6e4);
637
- if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `Biome exited with code ${result.exitCode}`);
638
1067
  };
639
1068
 
640
1069
  //#endregion
@@ -893,6 +1322,7 @@ const fixOxlint = async (context) => {
893
1322
  "-c",
894
1323
  configPath,
895
1324
  "--fix",
1325
+ "--fix-suggestions",
896
1326
  "."
897
1327
  ];
898
1328
  const result = await runSubprocess(process.execPath, args, {
@@ -947,81 +1377,6 @@ const fixRuffLint = async (rootDirectory) => {
947
1377
  if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `ruff check --fix exited with code ${result.exitCode}`);
948
1378
  };
949
1379
 
950
- //#endregion
951
- //#region src/output/pager.ts
952
- const DEFAULT_COLUMNS = 80;
953
- const DEFAULT_ROWS = 24;
954
- const ANSI_PATTERN = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, "g");
955
- const stripAnsi = (text) => text.replace(ANSI_PATTERN, "");
956
- const resolvePagerCommand = () => {
957
- const pager = process.env.PAGER?.trim();
958
- if (pager) {
959
- const [command, ...args] = pager.split(/\s+/);
960
- if (command) return {
961
- command,
962
- args
963
- };
964
- }
965
- return {
966
- command: "less",
967
- args: [
968
- "-R",
969
- "-F",
970
- "-X"
971
- ]
972
- };
973
- };
974
- const writeToStdout = (text) => {
975
- process.stdout.write(text);
976
- };
977
- const pipeToPager = async (command, args, text) => new Promise((resolve) => {
978
- let settled = false;
979
- const finish = (success) => {
980
- if (settled) return;
981
- settled = true;
982
- resolve(success);
983
- };
984
- try {
985
- const child = spawn(command, args, {
986
- stdio: [
987
- "pipe",
988
- "inherit",
989
- "inherit"
990
- ],
991
- windowsHide: true
992
- });
993
- child.once("error", () => finish(false));
994
- child.once("close", (code) => finish(code === 0));
995
- child.stdin?.on("error", () => void 0);
996
- child.stdin?.end(text);
997
- } catch {
998
- finish(false);
999
- }
1000
- });
1001
- const countRenderedLines = (text, columns = DEFAULT_COLUMNS) => {
1002
- const width = Math.max(1, columns);
1003
- return text.split("\n").reduce((count, line) => {
1004
- const visibleLine = stripAnsi(line).replaceAll(" ", " ");
1005
- return count + Math.max(1, Math.ceil(visibleLine.length / width));
1006
- }, 0);
1007
- };
1008
- const shouldPageOutput = (text, options = {}) => {
1009
- if (text.trim().length === 0) return false;
1010
- const stdinIsTTY = options.stdinIsTTY ?? Boolean(process.stdin.isTTY);
1011
- const stdoutIsTTY = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
1012
- if (!stdinIsTTY || !stdoutIsTTY) return false;
1013
- const rows = Math.max(1, options.rows ?? process.stdout.rows ?? DEFAULT_ROWS);
1014
- return countRenderedLines(text, Math.max(1, options.columns ?? process.stdout.columns ?? DEFAULT_COLUMNS)) > rows - 1;
1015
- };
1016
- const printMaybePaged = async (text) => {
1017
- if (!shouldPageOutput(text)) {
1018
- writeToStdout(text);
1019
- return;
1020
- }
1021
- const pager = resolvePagerCommand();
1022
- if (!await pipeToPager(pager.command, pager.args, text)) writeToStdout(text);
1023
- };
1024
-
1025
1380
  //#endregion
1026
1381
  //#region src/utils/telemetry.ts
1027
1382
  /**
@@ -1078,9 +1433,13 @@ const getAnonymousId = () => {
1078
1433
  for (let i = 0; i < raw.length; i++) hash = hash * 33 ^ raw.charCodeAt(i);
1079
1434
  return `aislop_${(hash >>> 0).toString(36)}`;
1080
1435
  };
1436
+ /** Pending telemetry request — kept alive so Node doesn't exit before it completes. */
1437
+ let pendingRequest = null;
1081
1438
  /**
1082
1439
  * Fire-and-forget telemetry event to PostHog.
1083
- * Never throws, never blocks, never affects CLI output or exit code.
1440
+ * Never throws, never blocks CLI output.
1441
+ * The request is kept alive via `flushTelemetry()` so Node doesn't
1442
+ * exit before it completes.
1084
1443
  */
1085
1444
  const trackEvent = (event) => {
1086
1445
  const payload = {
@@ -1103,12 +1462,12 @@ const trackEvent = (event) => {
1103
1462
  },
1104
1463
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
1105
1464
  };
1106
- fetch(`${POSTHOG_HOST}/capture/`, {
1465
+ pendingRequest = fetch(`${POSTHOG_HOST}/capture/`, {
1107
1466
  method: "POST",
1108
1467
  headers: { "Content-Type": "application/json" },
1109
1468
  body: JSON.stringify(payload),
1110
1469
  signal: AbortSignal.timeout(3e3)
1111
- }).catch(() => {});
1470
+ }).then(() => {}).catch(() => {});
1112
1471
  };
1113
1472
 
1114
1473
  //#endregion
@@ -1165,7 +1524,7 @@ const runFixStep = async (name, detect, applyFix, options) => {
1165
1524
  }
1166
1525
  lines.push(...getFilePreviewLines("Affected", uniqueFiles(before), options.verbose));
1167
1526
  if (after.length > 0) lines.push(...getFilePreviewLines("Remaining", uniqueFiles(after), options.verbose));
1168
- await printMaybePaged(`${lines.join("\n")}\n\n`);
1527
+ process.stdout.write(`${lines.join("\n")}\n\n`);
1169
1528
  return result;
1170
1529
  };
1171
1530
  const createEngineContext = (rootDirectory, projectInfo, config) => ({
@@ -1208,6 +1567,15 @@ const fixCommand = async (directory, config, options = {
1208
1567
  printProjectMetadata(projectInfo);
1209
1568
  const context = createEngineContext(resolvedDir, projectInfo, config);
1210
1569
  const steps = [];
1570
+ if (config.engines["ai-slop"]) steps.push(await runFixStep("Unused imports", () => detectUnusedImports(context), () => fixUnusedImports(context), options));
1571
+ if (config.engines.lint) {
1572
+ if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("JS/TS lint fixes", () => runOxlint(context), () => fixOxlint(context), options));
1573
+ if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python lint fixes", () => runRuffLint(context), () => fixRuffLint(resolvedDir), options));
1574
+ else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python lint fixes.");
1575
+ }
1576
+ if (config.engines["code-quality"]) {
1577
+ if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("Unused dependencies", () => runKnipDependencyCheck(resolvedDir), () => fixUnusedDependencies(resolvedDir), options));
1578
+ }
1211
1579
  if (config.engines.format) {
1212
1580
  if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("JS/TS formatting", () => runBiomeFormat(context), () => fixBiomeFormat(context), options));
1213
1581
  if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python formatting", () => runRuffFormat(context), () => fixRuffFormat(resolvedDir), options));
@@ -1215,11 +1583,6 @@ const fixCommand = async (directory, config, options = {
1215
1583
  if (projectInfo.languages.includes("go") && projectInfo.installedTools.gofmt) steps.push(await runFixStep("Go formatting", () => runGofmt(context), () => fixGofmt(resolvedDir), options));
1216
1584
  else if (projectInfo.languages.includes("go")) logger.warn(" Go detected but gofmt is not installed; skipping Go formatting fixes.");
1217
1585
  }
1218
- if (config.engines.lint) {
1219
- if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("JS/TS lint fixes", () => runOxlint(context), () => fixOxlint(context), options));
1220
- if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python lint fixes", () => runRuffLint(context), () => fixRuffLint(resolvedDir), options));
1221
- else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python lint fixes.");
1222
- }
1223
1586
  if (steps.length === 0) logger.dim(" No applicable auto-fixers found for this project.");
1224
1587
  else {
1225
1588
  logger.break();
@@ -1702,7 +2065,7 @@ const detectTrivialComments = async (context) => {
1702
2065
 
1703
2066
  //#endregion
1704
2067
  //#region src/engines/ai-slop/dead-patterns.ts
1705
- const JS_EXTENSIONS$1 = new Set([
2068
+ const JS_EXTENSIONS = new Set([
1706
2069
  ".ts",
1707
2070
  ".tsx",
1708
2071
  ".js",
@@ -1714,7 +2077,7 @@ const CONSOLE_LOG_PATTERN = /\bconsole\.(?:log|debug|info|trace|dir|table)\s*\(/
1714
2077
  const LOGGER_FILE_PATTERN = /(?:^|\/)(?:logger|logging|log)\.[^/]+$/i;
1715
2078
  const SCRIPT_DIR_PATTERN = /(?:^|\/)(scripts|bin)\//;
1716
2079
  const detectConsoleLeftovers = (content, relativePath, ext) => {
1717
- if (!JS_EXTENSIONS$1.has(ext)) return [];
2080
+ if (!JS_EXTENSIONS.has(ext)) return [];
1718
2081
  if (LOGGER_FILE_PATTERN.test(relativePath)) return [];
1719
2082
  if (SCRIPT_DIR_PATTERN.test(relativePath)) return [];
1720
2083
  const diagnostics = [];
@@ -1774,7 +2137,7 @@ const detectDeadCodePatterns = (content, relativePath, ext) => {
1774
2137
  for (let i = 0; i < lines.length; i++) {
1775
2138
  const trimmed = lines[i].trim();
1776
2139
  const nextLine = i + 1 < lines.length ? lines[i + 1]?.trim() : void 0;
1777
- if (JS_EXTENSIONS$1.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push({
2140
+ if (JS_EXTENSIONS.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push({
1778
2141
  filePath: relativePath,
1779
2142
  engine: "ai-slop",
1780
2143
  rule: "ai-slop/unreachable-code",
@@ -1798,7 +2161,7 @@ const detectDeadCodePatterns = (content, relativePath, ext) => {
1798
2161
  category: "AI Slop",
1799
2162
  fixable: false
1800
2163
  });
1801
- if (JS_EXTENSIONS$1.has(ext) && /(?:function\s+\w+|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push({
2164
+ if (JS_EXTENSIONS.has(ext) && /(?:function\s+\w+|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push({
1802
2165
  filePath: relativePath,
1803
2166
  engine: "ai-slop",
1804
2167
  rule: "ai-slop/empty-function",
@@ -1974,166 +2337,6 @@ const detectSwallowedExceptions = async (context) => {
1974
2337
  return diagnostics;
1975
2338
  };
1976
2339
 
1977
- //#endregion
1978
- //#region src/engines/ai-slop/unused-imports.ts
1979
- const JS_EXTENSIONS = new Set([
1980
- ".ts",
1981
- ".tsx",
1982
- ".js",
1983
- ".jsx",
1984
- ".mjs",
1985
- ".cjs"
1986
- ]);
1987
- const PY_EXTENSIONS = new Set([".py"]);
1988
- const extractJsImportedSymbols = (lines) => {
1989
- const symbols = [];
1990
- const importLines = /* @__PURE__ */ new Set();
1991
- for (let i = 0; i < lines.length; i++) {
1992
- const trimmed = lines[i].trim();
1993
- if (!trimmed.startsWith("import ")) continue;
1994
- importLines.add(i);
1995
- if (/^import\s+["']/.test(trimmed)) continue;
1996
- if (/^import\s+type\s/.test(trimmed)) continue;
1997
- let fullImport = trimmed;
1998
- let endLine = i;
1999
- while (!fullImport.includes("from") && endLine < lines.length - 1) {
2000
- endLine++;
2001
- fullImport += ` ${lines[endLine].trim()}`;
2002
- importLines.add(endLine);
2003
- }
2004
- const namespaceMatch = fullImport.match(/import\s+\*\s+as\s+(\w+)\s+from/);
2005
- if (namespaceMatch) {
2006
- symbols.push({
2007
- name: namespaceMatch[1],
2008
- line: i + 1,
2009
- isDefault: false,
2010
- isNamespace: true
2011
- });
2012
- continue;
2013
- }
2014
- const defaultMatch = fullImport.match(/import\s+(\w+)\s*(?:,\s*\{[^}]*\})?\s+from/);
2015
- if (defaultMatch && defaultMatch[1] !== "type") symbols.push({
2016
- name: defaultMatch[1],
2017
- line: i + 1,
2018
- isDefault: true,
2019
- isNamespace: false
2020
- });
2021
- const namedMatch = fullImport.match(/\{([^}]+)\}/);
2022
- if (namedMatch) {
2023
- const namedImports = namedMatch[1].split(",");
2024
- for (const ni of namedImports) {
2025
- const parts = ni.trim().split(/\s+as\s+/);
2026
- if (parts.length === 0 || !parts[0]) continue;
2027
- const cleanParts = parts.map((p) => p.trim().replace(/^type\s+/, ""));
2028
- const localName = cleanParts.length > 1 ? cleanParts[1] : cleanParts[0];
2029
- if (localName && /^\w+$/.test(localName)) symbols.push({
2030
- name: localName,
2031
- line: i + 1,
2032
- isDefault: false,
2033
- isNamespace: false
2034
- });
2035
- }
2036
- }
2037
- }
2038
- return {
2039
- symbols,
2040
- importLines
2041
- };
2042
- };
2043
- const extractPyImportedSymbols = (lines) => {
2044
- const symbols = [];
2045
- const importLines = /* @__PURE__ */ new Set();
2046
- for (let i = 0; i < lines.length; i++) {
2047
- const trimmed = lines[i].trim();
2048
- const fromMatch = trimmed.match(/^from\s+[\w.]+\s+import\s+(.+)/);
2049
- if (fromMatch) {
2050
- importLines.add(i);
2051
- const importPart = fromMatch[1].replace(/#.*$/, "").trim();
2052
- if (importPart === "*") continue;
2053
- const cleaned = importPart.replace(/[()]/g, "");
2054
- for (const item of cleaned.split(",")) {
2055
- const parts = item.trim().split(/\s+as\s+/);
2056
- const localName = parts.length > 1 ? parts[1].trim() : parts[0].trim();
2057
- if (localName && /^\w+$/.test(localName)) symbols.push({
2058
- name: localName,
2059
- line: i + 1,
2060
- isDefault: false,
2061
- isNamespace: false
2062
- });
2063
- }
2064
- continue;
2065
- }
2066
- const importMatch = trimmed.match(/^import\s+([\w.]+)(?:\s+as\s+(\w+))?/);
2067
- if (importMatch) {
2068
- importLines.add(i);
2069
- const simpleName = (importMatch[2] ?? importMatch[1]).split(".")[0];
2070
- if (simpleName && /^\w+$/.test(simpleName)) symbols.push({
2071
- name: simpleName,
2072
- line: i + 1,
2073
- isDefault: false,
2074
- isNamespace: true
2075
- });
2076
- }
2077
- }
2078
- return {
2079
- symbols,
2080
- importLines
2081
- };
2082
- };
2083
- const isSymbolUsed = (name, content, importLines, lines) => {
2084
- const pattern = new RegExp(`\\b${name}\\b`, "g");
2085
- let match;
2086
- while ((match = pattern.exec(content)) !== null) {
2087
- const lineIndex = content.slice(0, match.index).split("\n").length - 1;
2088
- if (!importLines.has(lineIndex)) return true;
2089
- }
2090
- for (let i = 0; i < lines.length; i++) {
2091
- if (importLines.has(i)) continue;
2092
- if (lines[i].includes(name)) return true;
2093
- }
2094
- return false;
2095
- };
2096
- const detectUnusedImports = async (context) => {
2097
- const files = getSourceFiles(context);
2098
- const diagnostics = [];
2099
- for (const filePath of files) {
2100
- if (isAutoGenerated(filePath)) continue;
2101
- let content;
2102
- try {
2103
- content = fs.readFileSync(filePath, "utf-8");
2104
- } catch {
2105
- continue;
2106
- }
2107
- const ext = path.extname(filePath);
2108
- const relativePath = path.relative(context.rootDirectory, filePath);
2109
- const lines = content.split("\n");
2110
- let symbols;
2111
- let importLinesSet;
2112
- if (JS_EXTENSIONS.has(ext)) {
2113
- const result = extractJsImportedSymbols(lines);
2114
- symbols = result.symbols;
2115
- importLinesSet = result.importLines;
2116
- } else if (PY_EXTENSIONS.has(ext)) {
2117
- const result = extractPyImportedSymbols(lines);
2118
- symbols = result.symbols;
2119
- importLinesSet = result.importLines;
2120
- } else continue;
2121
- for (const symbol of symbols) if (!isSymbolUsed(symbol.name, content, importLinesSet, lines)) diagnostics.push({
2122
- filePath: relativePath,
2123
- engine: "ai-slop",
2124
- rule: "ai-slop/unused-import",
2125
- severity: "warning",
2126
- message: `Imported symbol '${symbol.name}' is never used`,
2127
- help: "Remove unused imports to keep the code clean",
2128
- line: symbol.line,
2129
- column: 0,
2130
- category: "AI Slop",
2131
- fixable: true
2132
- });
2133
- }
2134
- return diagnostics;
2135
- };
2136
-
2137
2340
  //#endregion
2138
2341
  //#region src/engines/ai-slop/index.ts
2139
2342
  const aiSlopEngine = {
@@ -2696,108 +2899,6 @@ const checkDuplication = async (context) => {
2696
2899
  return diagnostics;
2697
2900
  };
2698
2901
 
2699
- //#endregion
2700
- //#region src/engines/code-quality/knip.ts
2701
- const KNIP_MESSAGE_MAP = {
2702
- files: "Unused file",
2703
- exports: "Unused export",
2704
- types: "Unused type",
2705
- duplicates: "Duplicate export"
2706
- };
2707
- const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
2708
- const diagnostics = [];
2709
- const issues = issueType === "exports" ? fileIssue.exports ?? [] : issueType === "types" ? fileIssue.types ?? [] : fileIssue.duplicates ?? [];
2710
- for (const issue of issues) {
2711
- const symbol = issue.name ?? issue.symbol ?? "unknown";
2712
- const absolutePath = path.resolve(knipCwd, fileIssue.file);
2713
- diagnostics.push({
2714
- filePath: path.relative(rootDir, absolutePath),
2715
- engine: "code-quality",
2716
- rule: `knip/${issueType}`,
2717
- severity: "warning",
2718
- message: `${KNIP_MESSAGE_MAP[issueType]}: ${symbol}`,
2719
- help: "",
2720
- line: issue.line ?? 0,
2721
- column: issue.col ?? 0,
2722
- category: "Dead Code",
2723
- fixable: false
2724
- });
2725
- }
2726
- return diagnostics;
2727
- };
2728
- const findMonorepoRoot = (directory) => {
2729
- let current = path.dirname(directory);
2730
- while (current !== path.dirname(current)) {
2731
- if (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || (() => {
2732
- const pkgPath = path.join(current, "package.json");
2733
- if (!fs.existsSync(pkgPath)) return false;
2734
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
2735
- return Array.isArray(pkg.workspaces) || pkg.workspaces?.packages;
2736
- })()) return current;
2737
- current = path.dirname(current);
2738
- }
2739
- return null;
2740
- };
2741
- const KNIP_RELATIVE_BIN = path.join("node_modules", "knip", "bin", "knip.js");
2742
- const findKnipBin = (rootDirectory, monorepoRoot) => {
2743
- const localPath = path.join(rootDirectory, KNIP_RELATIVE_BIN);
2744
- if (fs.existsSync(localPath)) return {
2745
- binPath: localPath,
2746
- cwd: rootDirectory
2747
- };
2748
- if (monorepoRoot) {
2749
- const monorepoPath = path.join(monorepoRoot, KNIP_RELATIVE_BIN);
2750
- if (fs.existsSync(monorepoPath)) return {
2751
- binPath: monorepoPath,
2752
- cwd: monorepoRoot
2753
- };
2754
- }
2755
- return null;
2756
- };
2757
- const runKnip = async (rootDirectory) => {
2758
- const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
2759
- if (!knipRuntime) return [];
2760
- try {
2761
- const args = [
2762
- knipRuntime.binPath,
2763
- "--no-progress",
2764
- "--reporter",
2765
- "json",
2766
- "--no-exit-code"
2767
- ];
2768
- const result = await runSubprocess(process.execPath, args, {
2769
- cwd: knipRuntime.cwd,
2770
- timeout: 2e4,
2771
- env: { FORCE_COLOR: "0" }
2772
- });
2773
- if (!result.stdout) return [];
2774
- const parsed = JSON.parse(result.stdout);
2775
- const diagnostics = [];
2776
- const files = parsed.files ?? [];
2777
- for (const unusedFile of files) diagnostics.push({
2778
- filePath: path.relative(rootDirectory, path.resolve(knipRuntime.cwd, unusedFile)),
2779
- engine: "code-quality",
2780
- rule: "knip/files",
2781
- severity: "warning",
2782
- message: KNIP_MESSAGE_MAP.files,
2783
- help: "This file is not imported by any other file in the project.",
2784
- line: 0,
2785
- column: 0,
2786
- category: "Dead Code",
2787
- fixable: false
2788
- });
2789
- const issues = parsed.issues ?? [];
2790
- for (const fileIssue of issues) for (const type of [
2791
- "exports",
2792
- "types",
2793
- "duplicates"
2794
- ]) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
2795
- return diagnostics;
2796
- } catch {
2797
- return [];
2798
- }
2799
- };
2800
-
2801
2902
  //#endregion
2802
2903
  //#region src/engines/code-quality/index.ts
2803
2904
  const codeQualityEngine = {
@@ -4000,17 +4101,18 @@ const scanCommand = async (directory, config, options) => {
4000
4101
  });
4001
4102
  }
4002
4103
  if (options.json) {
4003
- const { buildJsonOutput } = await import("./json-66-1kHeg.js");
4104
+ const { buildJsonOutput } = await import("./json-Ci_gvHLS.js");
4004
4105
  const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
4005
4106
  console.log(JSON.stringify(jsonOut, null, 2));
4006
4107
  return { exitCode };
4007
4108
  }
4008
- await printMaybePaged([
4109
+ const output = [
4009
4110
  "",
4010
4111
  allDiagnostics.length === 0 ? `${highlighter.success(" ✓ No issues found.")}\n` : renderDiagnostics(allDiagnostics, options.verbose),
4011
4112
  renderSummary(allDiagnostics, scoreResult, elapsedMs, projectInfo.sourceFileCount, config.scoring.thresholds),
4012
4113
  ""
4013
- ].join("\n"));
4114
+ ].join("\n");
4115
+ process.stdout.write(output);
4014
4116
  return { exitCode };
4015
4117
  };
4016
4118