nanos-lint 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,67 @@ import process$1 from "node:process";
6
6
  import childProcess, { execFile, execFileSync } from "node:child_process";
7
7
  import { promisify, stripVTControlCharacters } from "node:util";
8
8
  import { EventEmitter } from "node:events";
9
+ //#region src/logger.ts
10
+ const LOG_LEVELS = [
11
+ "silent",
12
+ "error",
13
+ "warn",
14
+ "info",
15
+ "debug"
16
+ ];
17
+ const LOG_LEVEL_PRIORITY = {
18
+ silent: 0,
19
+ error: 1,
20
+ warn: 2,
21
+ info: 3,
22
+ debug: 4
23
+ };
24
+ const DEFAULT_LOG_LEVEL = "warn";
25
+ function isValidLogLevel(level) {
26
+ return level in LOG_LEVEL_PRIORITY;
27
+ }
28
+ function parseLogLevel(raw) {
29
+ if (!raw) return DEFAULT_LOG_LEVEL;
30
+ const lower = raw.trim().toLowerCase();
31
+ return isValidLogLevel(lower) ? lower : DEFAULT_LOG_LEVEL;
32
+ }
33
+ var Logger = class {
34
+ level = DEFAULT_LOG_LEVEL;
35
+ constructor(initialLevel) {
36
+ if (initialLevel && isValidLogLevel(initialLevel)) this.level = initialLevel;
37
+ else {
38
+ const envLevel = process.env.NANOS_LOG_LEVEL;
39
+ if (envLevel && isValidLogLevel(envLevel.trim().toLowerCase())) this.level = envLevel.trim().toLowerCase();
40
+ }
41
+ }
42
+ setLevel(level) {
43
+ if (isValidLogLevel(level)) this.level = level;
44
+ }
45
+ getLevel() {
46
+ return this.level;
47
+ }
48
+ isEnabledFor(level) {
49
+ return LOG_LEVEL_PRIORITY[this.level] >= LOG_LEVEL_PRIORITY[level];
50
+ }
51
+ /** Whether command results should be written; only `silent` disables them. */
52
+ isOutputEnabled() {
53
+ return this.level !== "silent";
54
+ }
55
+ error(...args) {
56
+ if (this.isEnabledFor("error")) console.error(...args);
57
+ }
58
+ warn(...args) {
59
+ if (this.isEnabledFor("warn")) console.warn(...args);
60
+ }
61
+ info(...args) {
62
+ if (this.isEnabledFor("info")) console.log(...args);
63
+ }
64
+ debug(...args) {
65
+ if (this.isEnabledFor("debug")) console.debug(...args);
66
+ }
67
+ };
68
+ const logger = new Logger();
69
+ //#endregion
9
70
  //#region src/types.ts
10
71
  /**
11
72
  * Converts a URI (e.g. `file:///path/to/file` or `file:///C:/path/to/file` or `file:///c%3A/path` or `file://server/share/file`)
@@ -18,11 +79,14 @@ function fileUriToPath(uri) {
18
79
  if (/^\/[a-zA-Z]:/.test(res)) res = res.slice(1);
19
80
  if (/^[a-zA-Z]:/.test(res)) res = res.charAt(0).toUpperCase() + res.slice(1);
20
81
  return res;
21
- } catch {
82
+ } catch (err) {
83
+ logger.debug(`fileURLToPath fallback for URI "${uri}": ${err instanceof Error ? err.message : String(err)}`);
22
84
  let decoded = uri.slice(7);
23
85
  try {
24
86
  decoded = decodeURIComponent(decoded);
25
- } catch {}
87
+ } catch (decodeErr) {
88
+ logger.warn(`decodeURIComponent failed for path "${decoded}": ${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)}`);
89
+ }
26
90
  if (decoded.startsWith("//")) return decoded;
27
91
  if (!decoded.startsWith("/") && uri.startsWith("file://") && !uri.startsWith("file:///")) return `//${decoded}`;
28
92
  if (/^\/[a-zA-Z]:/.test(decoded)) decoded = decoded.slice(1);
@@ -865,6 +929,77 @@ function cleanCache(targetDir = systemPaths.cache) {
865
929
  }
866
930
  return null;
867
931
  }
932
+ /**
933
+ * Recursively calculates total disk usage of a file or directory in bytes.
934
+ * Handles missing paths and inaccessible files gracefully.
935
+ */
936
+ function getDirectorySize(targetPath) {
937
+ if (!fs.existsSync(targetPath)) return 0;
938
+ let total = 0;
939
+ try {
940
+ const stat = fs.statSync(targetPath);
941
+ if (!stat.isDirectory()) return stat.size;
942
+ const entries = fs.readdirSync(targetPath, { withFileTypes: true });
943
+ for (const entry of entries) {
944
+ const fullPath = `${targetPath}/${entry.name}`;
945
+ try {
946
+ if (entry.isDirectory()) total += getDirectorySize(fullPath);
947
+ else if (entry.isFile()) total += fs.statSync(fullPath).size;
948
+ } catch (err) {
949
+ logger.debug(`[paths] Failed to stat entry ${fullPath}: ${err instanceof Error ? err.message : String(err)}`);
950
+ }
951
+ }
952
+ } catch (err) {
953
+ logger.debug(`[paths] Failed to calculate directory size for ${targetPath}: ${err instanceof Error ? err.message : String(err)}`);
954
+ return 0;
955
+ }
956
+ return total;
957
+ }
958
+ /**
959
+ * Formats byte values into human-readable strings (e.g. "86.4 MB", "1.42 MB", "42.5 MB").
960
+ */
961
+ function formatBytes(bytes) {
962
+ if (bytes <= 0 || !Number.isFinite(bytes)) return "0 B";
963
+ if (bytes < 1) return `${Math.round(bytes)} B`;
964
+ const units = [
965
+ "B",
966
+ "KB",
967
+ "MB",
968
+ "GB",
969
+ "TB"
970
+ ];
971
+ let i = Math.max(0, Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1));
972
+ let val = bytes / Math.pow(1024, i);
973
+ if (Math.round(val) >= 1024 && i < units.length - 1) {
974
+ i++;
975
+ val /= 1024;
976
+ }
977
+ return `${i === 0 ? val.toFixed(0) : val < 10 ? val.toFixed(2) : val < 100 ? val.toFixed(1) : val.toFixed(0)} ${units[i]}`;
978
+ }
979
+ //#endregion
980
+ //#region src/errors.ts
981
+ var NanosLintError = class extends Error {
982
+ code;
983
+ remedy;
984
+ name = "NanosLintError";
985
+ constructor(message, code, remedy, options) {
986
+ super(message, options);
987
+ this.code = code;
988
+ this.remedy = remedy;
989
+ }
990
+ };
991
+ var ConfigError = class extends NanosLintError {
992
+ name = "ConfigError";
993
+ };
994
+ var LuaLSError = class extends NanosLintError {
995
+ name = "LuaLSError";
996
+ };
997
+ var AnnotationsError = class extends NanosLintError {
998
+ name = "AnnotationsError";
999
+ };
1000
+ var CacheError = class extends NanosLintError {
1001
+ name = "CacheError";
1002
+ };
868
1003
  //#endregion
869
1004
  //#region src/config.ts
870
1005
  const __filename = fileURLToPath(import.meta.url);
@@ -916,8 +1051,14 @@ function parseJsonc(text) {
916
1051
  return result;
917
1052
  }
918
1053
  function loadConfigFile(filePath) {
919
- if (!fs.existsSync(filePath)) throw new Error(`Configuration file not found: ${filePath}`);
920
- return parseJsonc(fs.readFileSync(filePath, "utf-8"));
1054
+ if (!fs.existsSync(filePath)) throw new ConfigError(`Configuration file not found: ${filePath}`, "ERR_CONFIG_NOT_FOUND", "Ensure the configuration file path is correct or run 'nanos-lint init' to generate a default config.");
1055
+ const content = fs.readFileSync(filePath, "utf-8");
1056
+ try {
1057
+ return parseJsonc(content);
1058
+ } catch (err) {
1059
+ if (err instanceof ConfigError) throw err;
1060
+ throw new ConfigError(`Failed to parse configuration file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, "ERR_CONFIG_PARSE", "Check your .luarc.json syntax or run 'nanos-lint init --force' to scaffold a clean template.", { cause: err });
1061
+ }
921
1062
  }
922
1063
  /**
923
1064
  * Removes trailing `/` characters from a path-like string.
@@ -933,6 +1074,76 @@ function stripTrailingSlashes(value) {
933
1074
  return end === value.length ? value : value.slice(0, end);
934
1075
  }
935
1076
  /**
1077
+ * Set of recognized diagnostic codes supported by Lua Language Server (LuaLS).
1078
+ * LuaLS discards the entire `diagnostics.severity` object if it contains a single
1079
+ * unrecognized key (such as obsolete 'syntax-error'). nanos-lint filters severity
1080
+ * and neededFileStatus maps against this allow-list to ensure valid configuration.
1081
+ */
1082
+ const VALID_LUALS_DIAGNOSTIC_CODES = /* @__PURE__ */ new Set([
1083
+ "ambiguity-1",
1084
+ "assign-type-mismatch",
1085
+ "await-in-sync",
1086
+ "cast-local-type",
1087
+ "cast-type-mismatch",
1088
+ "circle-doc-class",
1089
+ "close-non-object",
1090
+ "code-after-break",
1091
+ "codestyle-check",
1092
+ "count-down-loop",
1093
+ "deprecated",
1094
+ "different-requires",
1095
+ "discard-returns",
1096
+ "doc-field-no-class",
1097
+ "duplicate-doc-alias",
1098
+ "duplicate-doc-field",
1099
+ "duplicate-doc-param",
1100
+ "duplicate-index",
1101
+ "duplicate-set-field",
1102
+ "empty-block",
1103
+ "global-element",
1104
+ "global-in-nil-env",
1105
+ "incomplete-signature-doc",
1106
+ "inject-field",
1107
+ "invisible",
1108
+ "lowercase-global",
1109
+ "missing-fields",
1110
+ "missing-global-doc",
1111
+ "missing-local-export-doc",
1112
+ "missing-parameter",
1113
+ "missing-return",
1114
+ "missing-return-value",
1115
+ "name-style-check",
1116
+ "need-check-nil",
1117
+ "newfield-call",
1118
+ "newline-call",
1119
+ "no-unknown",
1120
+ "not-yieldable",
1121
+ "param-type-mismatch",
1122
+ "redefined-local",
1123
+ "redundant-parameter",
1124
+ "redundant-return",
1125
+ "redundant-return-value",
1126
+ "redundant-value",
1127
+ "return-type-mismatch",
1128
+ "spell-check",
1129
+ "trailing-space",
1130
+ "unbalanced-assignments",
1131
+ "undefined-doc-class",
1132
+ "undefined-doc-name",
1133
+ "undefined-doc-param",
1134
+ "undefined-env-child",
1135
+ "undefined-field",
1136
+ "undefined-global",
1137
+ "unknown-cast-variable",
1138
+ "unknown-diag-code",
1139
+ "unknown-operator",
1140
+ "unreachable-code",
1141
+ "unused-function",
1142
+ "unused-label",
1143
+ "unused-local",
1144
+ "unused-vararg"
1145
+ ]);
1146
+ /**
936
1147
  * Merges a base nanos configuration with a workspace override configuration.
937
1148
  * Guarantees that nanos API annotations are included in workspace.library,
938
1149
  * and standardizes paths for LuaLS.
@@ -949,7 +1160,9 @@ function mergeConfigs(base, override = {}, definitionsPath = getDefaultAnnotatio
949
1160
  const candidate = path.join(definitionsPath, "annotations.lua");
950
1161
  if (fs.existsSync(candidate)) resolvedPath = candidate;
951
1162
  }
952
- } catch {}
1163
+ } catch (err) {
1164
+ logger.debug(`Error checking annotations definitionsPath directory (${definitionsPath}): ${err instanceof Error ? err.message : String(err)}`);
1165
+ }
953
1166
  const normalizedDefPath = resolvedPath.split(path.sep).join("/");
954
1167
  const baseLibraries = base.workspace?.library ?? [];
955
1168
  const overrideLibraries = override.workspace?.library ?? [];
@@ -961,10 +1174,20 @@ function mergeConfigs(base, override = {}, definitionsPath = getDefaultAnnotatio
961
1174
  const baseGlobals = base.diagnostics?.globals ?? [];
962
1175
  const overrideGlobals = override.diagnostics?.globals ?? [];
963
1176
  const globalsSet = /* @__PURE__ */ new Set([...baseGlobals, ...overrideGlobals]);
964
- const mergedSeverity = {
1177
+ const rawSeverity = {
965
1178
  ...base.diagnostics?.severity ?? {},
966
1179
  ...override.diagnostics?.severity ?? {}
967
1180
  };
1181
+ const mergedSeverity = {};
1182
+ for (const [code, level] of Object.entries(rawSeverity)) if (VALID_LUALS_DIAGNOSTIC_CODES.has(code)) mergedSeverity[code] = level;
1183
+ else logger.warn(`[config] Unrecognized diagnostic code "${code}" in diagnostics.severity was dropped to prevent LuaLS from discarding the severity configuration.`);
1184
+ const rawNeededFileStatus = {
1185
+ ...base.diagnostics?.neededFileStatus ?? {},
1186
+ ...override.diagnostics?.neededFileStatus ?? {}
1187
+ };
1188
+ const mergedNeededFileStatus = {};
1189
+ for (const [code, status] of Object.entries(rawNeededFileStatus)) if (VALID_LUALS_DIAGNOSTIC_CODES.has(code)) mergedNeededFileStatus[code] = status;
1190
+ else logger.warn(`[config] Unrecognized diagnostic code "${code}" in diagnostics.neededFileStatus was dropped.`);
968
1191
  const hasCliIgnore = Boolean(options?.cliIgnore && options.cliIgnore.length > 0);
969
1192
  let mergedIgnoreDir;
970
1193
  let mergedFilesExclude;
@@ -1037,7 +1260,8 @@ function mergeConfigs(base, override = {}, definitionsPath = getDefaultAnnotatio
1037
1260
  ...base.diagnostics ?? {},
1038
1261
  ...override.diagnostics ?? {},
1039
1262
  globals: Array.from(globalsSet),
1040
- severity: mergedSeverity
1263
+ severity: mergedSeverity,
1264
+ ...Object.keys(mergedNeededFileStatus).length > 0 ? { neededFileStatus: mergedNeededFileStatus } : {}
1041
1265
  }
1042
1266
  };
1043
1267
  }
@@ -1099,10 +1323,10 @@ function resolveWorkspaceConfig(workspacePath, customConfigPath, options) {
1099
1323
  */
1100
1324
  function initWorkspace(workspacePath, options) {
1101
1325
  const targetFile = path.join(workspacePath, ".luarc.json");
1102
- if (fs.existsSync(targetFile) && !options?.force) throw new Error(`.luarc.json already exists at ${targetFile}. Use --force to overwrite.`);
1326
+ if (fs.existsSync(targetFile) && !options?.force) throw new ConfigError(`.luarc.json already exists at ${targetFile}. Use --force to overwrite.`, "ERR_CONFIG_EXISTS", "Pass --force to overwrite the existing .luarc.json file.");
1103
1327
  const template = loadConfigFile(getDefaultTemplatePath());
1104
1328
  const sourceAnnotations = options?.annotationsPath || getDefaultAnnotationsPath();
1105
- if (!fs.existsSync(sourceAnnotations)) throw new Error(`Definitions file not found at ${sourceAnnotations}. Make sure annotations have been downloaded or pass a valid file with --annotations.`);
1329
+ if (!fs.existsSync(sourceAnnotations)) throw new ConfigError(`Definitions file not found at ${sourceAnnotations}. Make sure annotations have been downloaded or pass a valid file with --annotations.`, "ERR_ANNOTATIONS_NOT_FOUND", "Run 'nanos-lint warmup' to download annotations or provide --annotations <path>.");
1106
1330
  const targetNanosDir = path.join(workspacePath, ".nanos-lint");
1107
1331
  fs.mkdirSync(targetNanosDir, { recursive: true });
1108
1332
  const targetAnnotations = path.join(targetNanosDir, "annotations.lua");
@@ -1118,8 +1342,7 @@ function initWorkspace(workspacePath, options) {
1118
1342
  return targetFile;
1119
1343
  }
1120
1344
  //#endregion
1121
- //#region src/luals.ts
1122
- const execFileAsync = promisify(execFile);
1345
+ //#region src/luals/version.ts
1123
1346
  const FALLBACK_LUALS_VERSION = "3.19.1";
1124
1347
  const DEFAULT_LUALS_VERSION = "latest";
1125
1348
  /**
@@ -1156,15 +1379,10 @@ function sanitizeLuaLSVersion(raw) {
1156
1379
  return first >= 48 && first <= 57 || first >= 65 && first <= 90 || first >= 97 && first <= 122 ? version : null;
1157
1380
  }
1158
1381
  /**
1159
- * Escapes single quotes for safe PowerShell single-quoted string interpolation.
1382
+ * Fetches the latest available LuaLS release tag from the GitHub API.
1383
+ * Returns null if the request fails, times out, or receives an invalid tag.
1160
1384
  */
1161
- function escapePowerShellSingleQuote(str) {
1162
- return str.replace(/'/g, "''");
1163
- }
1164
- /**
1165
- * Resolves the latest available LuaLS release tag from the GitHub API.
1166
- */
1167
- async function resolveLatestLuaLSVersion() {
1385
+ async function fetchLatestLuaLSVersionFromGitHub() {
1168
1386
  try {
1169
1387
  const headers = { "User-Agent": "nanos-lint" };
1170
1388
  if (process.env.GITHUB_TOKEN) headers["Authorization"] = `token ${process.env.GITHUB_TOKEN}`;
@@ -1177,8 +1395,17 @@ async function resolveLatestLuaLSVersion() {
1177
1395
  const version = typeof data.tag_name === "string" ? sanitizeLuaLSVersion(data.tag_name) : null;
1178
1396
  if (version) return version;
1179
1397
  }
1180
- } catch {}
1181
- return FALLBACK_LUALS_VERSION;
1398
+ } catch (err) {
1399
+ logger.debug(`[luals] Failed to resolve latest LuaLS version from GitHub API: ${err instanceof Error ? err.message : String(err)}`);
1400
+ }
1401
+ return null;
1402
+ }
1403
+ /**
1404
+ * Resolves the latest available LuaLS release tag from the GitHub API,
1405
+ * falling back to FALLBACK_LUALS_VERSION if offline or unreachable.
1406
+ */
1407
+ async function resolveLatestLuaLSVersion() {
1408
+ return await fetchLatestLuaLSVersionFromGitHub() || "3.19.1";
1182
1409
  }
1183
1410
  /**
1184
1411
  * Resolves a version string ("latest" -> actual tag).
@@ -1188,9 +1415,11 @@ async function resolveLatestLuaLSVersion() {
1188
1415
  async function resolveLuaLSVersion(version) {
1189
1416
  if (!version || version === "latest") return await resolveLatestLuaLSVersion();
1190
1417
  const sanitized = sanitizeLuaLSVersion(version);
1191
- if (!sanitized) throw new Error(`Invalid LuaLS version: "${version}". Expected a release tag such as "3.19.1", or "latest".`);
1418
+ if (!sanitized) throw new LuaLSError(`Invalid LuaLS version: "${version}". Expected a release tag such as "3.19.1", or "latest".`, "ERR_LUALS_INVALID_VERSION", "Provide a valid release tag like '3.19.1' or use 'latest'.");
1192
1419
  return sanitized;
1193
1420
  }
1421
+ //#endregion
1422
+ //#region src/luals/platform.ts
1194
1423
  function getPlatformInfo(version = FALLBACK_LUALS_VERSION) {
1195
1424
  const platform = process.platform;
1196
1425
  const arch = process.arch;
@@ -1201,7 +1430,7 @@ function getPlatformInfo(version = FALLBACK_LUALS_VERSION) {
1201
1430
  assetName: `lua-language-server-${version}-win32-x64.zip`,
1202
1431
  binaryRelativePath: path.join("bin", "lua-language-server.exe")
1203
1432
  };
1204
- throw new Error(`Unsupported Windows architecture: ${arch}. Supported: x64`);
1433
+ throw new LuaLSError(`Unsupported Windows architecture: ${arch}. Supported: x64`, "ERR_LUALS_UNSUPPORTED_ARCH", "LuaLS provides pre-built binaries for Windows x64. Use a compatible system or set LUALS_BIN to a custom binary.");
1205
1434
  }
1206
1435
  if (platform === "linux") {
1207
1436
  if (arch === "x64") return {
@@ -1216,7 +1445,7 @@ function getPlatformInfo(version = FALLBACK_LUALS_VERSION) {
1216
1445
  assetName: `lua-language-server-${version}-linux-arm64.tar.gz`,
1217
1446
  binaryRelativePath: path.join("bin", "lua-language-server")
1218
1447
  };
1219
- throw new Error(`Unsupported Linux architecture: ${arch}. Supported: x64, arm64`);
1448
+ throw new LuaLSError(`Unsupported Linux architecture: ${arch}. Supported: x64, arm64`, "ERR_LUALS_UNSUPPORTED_ARCH", "LuaLS provides pre-built binaries for Linux x64 and arm64. Use LUALS_BIN to specify a custom build.");
1220
1449
  }
1221
1450
  if (platform === "darwin") return {
1222
1451
  platform: "darwin",
@@ -1224,18 +1453,10 @@ function getPlatformInfo(version = FALLBACK_LUALS_VERSION) {
1224
1453
  assetName: `lua-language-server-${version}-darwin-${arch === "arm64" ? "arm64" : "x64"}.tar.gz`,
1225
1454
  binaryRelativePath: path.join("bin", "lua-language-server")
1226
1455
  };
1227
- throw new Error(`Unsupported platform: ${platform}`);
1228
- }
1229
- function getCacheDir(version = FALLBACK_LUALS_VERSION) {
1230
- return path.join(systemPaths.cache, "luals", version);
1231
- }
1232
- /**
1233
- * Returns the legacy cache directory used in nanos-lint <= 2.2.1.
1234
- */
1235
- function getLegacyCacheDir(version = FALLBACK_LUALS_VERSION) {
1236
- const base = process.platform === "win32" ? process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
1237
- return path.join(base, "nanos-lint", "luals", version);
1456
+ throw new LuaLSError(`Unsupported platform: ${platform}`, "ERR_LUALS_UNSUPPORTED_PLATFORM", "LuaLS provides pre-built binaries for Windows, Linux, and macOS. Use LUALS_BIN to specify a custom build.");
1238
1457
  }
1458
+ //#endregion
1459
+ //#region src/luals/validation.ts
1239
1460
  /**
1240
1461
  * Verifies that a LuaLS binary exists, has non-trivial size, and is executable.
1241
1462
  */
@@ -1250,10 +1471,198 @@ function isBinaryValid(binaryPath) {
1250
1471
  encoding: "utf-8"
1251
1472
  });
1252
1473
  return /^\d+\.\d+\.\d+/.test(output.trim());
1253
- } catch {
1474
+ } catch (err) {
1475
+ logger.debug(`[luals] Binary validation check failed for ${binaryPath}: ${err instanceof Error ? err.message : String(err)}`);
1254
1476
  return false;
1255
1477
  }
1256
1478
  }
1479
+ //#endregion
1480
+ //#region src/luals/cache.ts
1481
+ function getBaseLuaLSCacheDir() {
1482
+ return path.join(systemPaths.cache, "luals");
1483
+ }
1484
+ /**
1485
+ * Cache directory of a LuaLS version. `baseCacheDir` defaults to the system
1486
+ * cache and can be overridden to keep a caller isolated from it.
1487
+ */
1488
+ function getCacheDir(version = FALLBACK_LUALS_VERSION, baseCacheDir = getBaseLuaLSCacheDir()) {
1489
+ return path.join(baseCacheDir, version);
1490
+ }
1491
+ /**
1492
+ * Returns the legacy cache directory used in nanos-lint <= 2.2.1.
1493
+ */
1494
+ function getLegacyCacheDir(version = FALLBACK_LUALS_VERSION) {
1495
+ const base = process.platform === "win32" ? process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local") : process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
1496
+ return path.join(base, "nanos-lint", "luals", version);
1497
+ }
1498
+ const LUALS_METADATA_FILENAME = "metadata.json";
1499
+ /**
1500
+ * Calculates the ISO 8601 week string for a given date in the format 'YYYY-Www' (e.g. '2026-W39').
1501
+ */
1502
+ function getIsoWeek(d = /* @__PURE__ */ new Date()) {
1503
+ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
1504
+ const dayNum = date.getUTCDay() || 7;
1505
+ date.setUTCDate(date.getUTCDate() + 4 - dayNum);
1506
+ const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
1507
+ const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
1508
+ return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
1509
+ }
1510
+ function getLuaLSMetadataPath(baseCacheDir = getBaseLuaLSCacheDir()) {
1511
+ return path.join(baseCacheDir, LUALS_METADATA_FILENAME);
1512
+ }
1513
+ function readLuaLSMetadata(baseCacheDir = getBaseLuaLSCacheDir()) {
1514
+ const metaPath = getLuaLSMetadataPath(baseCacheDir);
1515
+ if (!fs.existsSync(metaPath)) return null;
1516
+ try {
1517
+ const content = fs.readFileSync(metaPath, "utf-8");
1518
+ const parsed = JSON.parse(content);
1519
+ if (typeof parsed?.lastCheckedWeek === "string" && typeof parsed?.latestVersion === "string") return parsed;
1520
+ try {
1521
+ fs.unlinkSync(metaPath);
1522
+ logger.warn(`[luals] Stale or invalid LuaLS metadata at ${metaPath} purged.`);
1523
+ } catch (unlinkErr) {
1524
+ logger.debug(`[luals] Failed to unlink invalid metadata: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`);
1525
+ }
1526
+ } catch (err) {
1527
+ logger.debug(`[luals] Failed to parse LuaLS metadata: ${err instanceof Error ? err.message : String(err)}`);
1528
+ try {
1529
+ fs.unlinkSync(metaPath);
1530
+ logger.warn(`[luals] Corrupted LuaLS metadata at ${metaPath} purged.`);
1531
+ } catch (unlinkErr) {
1532
+ logger.debug(`[luals] Failed to unlink corrupted metadata: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`);
1533
+ }
1534
+ }
1535
+ return null;
1536
+ }
1537
+ function writeLuaLSMetadata(metadata, baseCacheDir = getBaseLuaLSCacheDir()) {
1538
+ try {
1539
+ fs.mkdirSync(baseCacheDir, { recursive: true });
1540
+ const metaPath = getLuaLSMetadataPath(baseCacheDir);
1541
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
1542
+ } catch (err) {
1543
+ logger.warn(`[luals] Failed to write LuaLS metadata: ${err instanceof Error ? err.message : String(err)}`);
1544
+ }
1545
+ }
1546
+ /**
1547
+ * Lists all candidate LuaLS version directories under baseCacheDir without running
1548
+ * execution smoke tests on the binaries.
1549
+ */
1550
+ function listCachedLuaLSVersionDirs(baseCacheDir = getBaseLuaLSCacheDir()) {
1551
+ if (!fs.existsSync(baseCacheDir)) return [];
1552
+ try {
1553
+ const entries = fs.readdirSync(baseCacheDir, { withFileTypes: true });
1554
+ const versions = [];
1555
+ for (const entry of entries) {
1556
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1557
+ const version = sanitizeLuaLSVersion(entry.name);
1558
+ if (version) versions.push(version);
1559
+ }
1560
+ return versions;
1561
+ } catch (err) {
1562
+ logger.debug(`[luals] Failed to list cached LuaLS version directories: ${err instanceof Error ? err.message : String(err)}`);
1563
+ return [];
1564
+ }
1565
+ }
1566
+ /**
1567
+ * Lists all cached LuaLS versions in baseCacheDir that have a valid .complete marker
1568
+ * and a functional executable binary.
1569
+ */
1570
+ function listCachedLuaLSVersions(baseCacheDir = getBaseLuaLSCacheDir()) {
1571
+ if (!fs.existsSync(baseCacheDir)) return [];
1572
+ try {
1573
+ const entries = fs.readdirSync(baseCacheDir, { withFileTypes: true });
1574
+ const versions = [];
1575
+ for (const entry of entries) {
1576
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1577
+ const version = sanitizeLuaLSVersion(entry.name);
1578
+ if (!version) continue;
1579
+ const dirPath = path.join(baseCacheDir, entry.name);
1580
+ const marker = path.join(dirPath, ".complete");
1581
+ const info = getPlatformInfo(version);
1582
+ const bin = path.join(dirPath, info.binaryRelativePath);
1583
+ if (fs.existsSync(marker) && fs.existsSync(bin)) try {
1584
+ if (fs.readFileSync(marker, "utf-8").trim() === version && isBinaryValid(bin)) versions.push(version);
1585
+ } catch (err) {
1586
+ logger.debug(`[luals] Error validating cached version ${version}: ${err instanceof Error ? err.message : String(err)}`);
1587
+ }
1588
+ }
1589
+ return versions;
1590
+ } catch (err) {
1591
+ logger.debug(`[luals] Failed to list cached LuaLS versions: ${err instanceof Error ? err.message : String(err)}`);
1592
+ return [];
1593
+ }
1594
+ }
1595
+ /**
1596
+ * Removes older cached LuaLS version directories, preserving keepVersion,
1597
+ * hidden/temporary directories, and metadata files.
1598
+ */
1599
+ function cleanupOldCachedLuaLSVersions(keepVersion, baseCacheDir = getBaseLuaLSCacheDir()) {
1600
+ if (!fs.existsSync(baseCacheDir)) return [];
1601
+ const removed = [];
1602
+ try {
1603
+ const entries = fs.readdirSync(baseCacheDir, { withFileTypes: true });
1604
+ for (const entry of entries) {
1605
+ if (!entry.isDirectory()) continue;
1606
+ if (entry.name.startsWith(".")) continue;
1607
+ if (entry.name !== keepVersion) {
1608
+ const dirPath = path.join(baseCacheDir, entry.name);
1609
+ try {
1610
+ fs.rmSync(dirPath, {
1611
+ recursive: true,
1612
+ force: true,
1613
+ maxRetries: 5,
1614
+ retryDelay: 100
1615
+ });
1616
+ removed.push(entry.name);
1617
+ logger.info(`[luals] Cleaned up older cached LuaLS version: ${entry.name}`);
1618
+ } catch (err) {
1619
+ logger.warn(`[luals] Failed to remove older cached LuaLS version at ${dirPath}: ${err instanceof Error ? err.message : String(err)}`);
1620
+ }
1621
+ }
1622
+ }
1623
+ } catch (err) {
1624
+ logger.debug(`[luals] Failed to clean up old LuaLS versions: ${err instanceof Error ? err.message : String(err)}`);
1625
+ }
1626
+ return removed;
1627
+ }
1628
+ /**
1629
+ * Locates an existing, valid LuaLS directory for the specified version in the
1630
+ * primary cache (`baseCacheDir`), the legacy cache, or the package root.
1631
+ */
1632
+ function findExistingLuaLSDir(version, baseCacheDir = getBaseLuaLSCacheDir()) {
1633
+ const info = getPlatformInfo(version);
1634
+ const primaryCache = getCacheDir(version, baseCacheDir);
1635
+ const primaryBin = path.join(primaryCache, info.binaryRelativePath);
1636
+ const primaryMarker = path.join(primaryCache, ".complete");
1637
+ if (fs.existsSync(primaryMarker)) try {
1638
+ if (fs.readFileSync(primaryMarker, "utf-8").trim() === version && isBinaryValid(primaryBin)) return primaryCache;
1639
+ } catch (err) {
1640
+ logger.debug(`[luals] Error checking primary LuaLS cache marker at ${primaryMarker}: ${err instanceof Error ? err.message : String(err)}`);
1641
+ }
1642
+ const legacyCache = getLegacyCacheDir(version);
1643
+ if (path.resolve(legacyCache) !== path.resolve(primaryCache)) {
1644
+ const legacyBin = path.join(legacyCache, info.binaryRelativePath);
1645
+ const legacyMarker = path.join(legacyCache, ".complete");
1646
+ if (fs.existsSync(legacyMarker)) try {
1647
+ if (fs.readFileSync(legacyMarker, "utf-8").trim() === version && isBinaryValid(legacyBin)) return legacyCache;
1648
+ } catch (err) {
1649
+ logger.debug(`[luals] Error checking legacy LuaLS cache marker at ${legacyMarker}: ${err instanceof Error ? err.message : String(err)}`);
1650
+ }
1651
+ }
1652
+ const pkgRoot = getPackageRoot();
1653
+ const pkgBin = path.join(pkgRoot, info.binaryRelativePath);
1654
+ if (fs.existsSync(pkgBin) && isBinaryValid(pkgBin)) return pkgRoot;
1655
+ return null;
1656
+ }
1657
+ //#endregion
1658
+ //#region src/luals/download.ts
1659
+ const execFileAsync$1 = promisify(execFile);
1660
+ /**
1661
+ * Escapes single quotes for safe PowerShell single-quoted string interpolation.
1662
+ */
1663
+ function escapePowerShellSingleQuote(str) {
1664
+ return str.replace(/'/g, "''");
1665
+ }
1257
1666
  async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDir, options) {
1258
1667
  const resolvedVersion = await resolveLuaLSVersion(version);
1259
1668
  const info = getPlatformInfo(resolvedVersion);
@@ -1261,76 +1670,86 @@ async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDi
1261
1670
  const binaryPath = path.join(destDir, info.binaryRelativePath);
1262
1671
  const completeMarker = path.join(destDir, ".complete");
1263
1672
  if (fs.existsSync(destDir)) {
1264
- if (fs.existsSync(binaryPath) && fs.existsSync(completeMarker)) try {
1673
+ if (options?.reuseExisting !== false && fs.existsSync(binaryPath) && fs.existsSync(completeMarker)) try {
1265
1674
  if (fs.readFileSync(completeMarker, "utf-8").trim() === resolvedVersion && isBinaryValid(binaryPath)) return binaryPath;
1266
- } catch {}
1675
+ } catch (err) {
1676
+ logger.debug(`[luals] Failed to read complete marker at ${completeMarker}: ${err instanceof Error ? err.message : String(err)}`);
1677
+ }
1267
1678
  try {
1268
1679
  fs.rmSync(destDir, {
1269
1680
  recursive: true,
1270
1681
  force: true
1271
1682
  });
1272
- } catch {}
1683
+ } catch (err) {
1684
+ logger.warn(`[luals] Failed to remove stale or invalid cache dir ${destDir}: ${err instanceof Error ? err.message : String(err)}`);
1685
+ }
1273
1686
  }
1274
1687
  const parentDir = path.dirname(destDir);
1275
1688
  fs.mkdirSync(parentDir, { recursive: true });
1276
1689
  const tempDir = path.join(parentDir, `.${path.basename(destDir)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
1277
1690
  fs.mkdirSync(tempDir, { recursive: true });
1691
+ const existingSourceDir = options?.reuseExisting !== false ? findExistingLuaLSDir(resolvedVersion, options?.cacheDir ?? getBaseLuaLSCacheDir()) : null;
1692
+ const shouldCopyFromExisting = existingSourceDir !== null && path.resolve(existingSourceDir) !== path.resolve(destDir);
1278
1693
  const url = `https://github.com/LuaLS/lua-language-server/releases/download/${resolvedVersion}/${info.assetName}`;
1279
1694
  const archivePath = path.join(tempDir, info.assetName);
1280
- if (!options?.quiet) console.log(`[luals] Downloading LuaLS ${resolvedVersion} from ${url}...`);
1281
- let response = null;
1282
- let lastErr = null;
1283
- for (let attempt = 1; attempt <= 3; attempt++) {
1284
- try {
1285
- const res = await fetch(url);
1286
- if (res.ok && res.body) {
1287
- response = res;
1288
- break;
1289
- }
1290
- await res.body?.cancel();
1291
- lastErr = /* @__PURE__ */ new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
1292
- } catch (err) {
1293
- lastErr = err;
1294
- }
1295
- if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 1e3));
1296
- }
1297
- if (!response || !response.body) {
1298
- try {
1299
- fs.rmSync(tempDir, {
1300
- recursive: true,
1301
- force: true
1302
- });
1303
- } catch {}
1304
- throw lastErr || /* @__PURE__ */ new Error(`Failed to download ${url}`);
1305
- }
1306
1695
  try {
1307
- const arrayBuffer = await response.arrayBuffer();
1308
- fs.writeFileSync(archivePath, Buffer.from(arrayBuffer));
1309
- if (!options?.quiet) console.log(`[luals] Extracting to ${destDir}...`);
1310
- try {
1311
- await execFileAsync("tar", [
1312
- "-xf",
1313
- archivePath,
1314
- "-C",
1315
- tempDir
1316
- ]);
1317
- } catch (tarErr) {
1318
- if (process.platform === "win32" && info.assetName.endsWith(".zip")) await execFileAsync("powershell.exe", [
1319
- "-NoProfile",
1320
- "-Command",
1321
- `Expand-Archive -Path '${escapePowerShellSingleQuote(archivePath)}' -DestinationPath '${escapePowerShellSingleQuote(tempDir)}' -Force`
1322
- ]);
1323
- else throw tarErr;
1696
+ if (shouldCopyFromExisting) {
1697
+ if (!options?.quiet) logger.info(`[luals] Reusing existing LuaLS ${resolvedVersion} installation from ${existingSourceDir}...`);
1698
+ fs.cpSync(existingSourceDir, tempDir, { recursive: true });
1699
+ } else {
1700
+ if (!options?.quiet) logger.info(`[luals] Downloading LuaLS ${resolvedVersion} from ${url}...`);
1701
+ let response = null;
1702
+ let lastErr = null;
1703
+ for (let attempt = 1; attempt <= 3; attempt++) {
1704
+ try {
1705
+ const res = await fetch(url);
1706
+ if (res.ok && res.body) {
1707
+ response = res;
1708
+ break;
1709
+ }
1710
+ await res.body?.cancel();
1711
+ lastErr = /* @__PURE__ */ new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`);
1712
+ } catch (err) {
1713
+ lastErr = err;
1714
+ }
1715
+ if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, attempt * 1e3));
1716
+ }
1717
+ if (!response || !response.body) {
1718
+ if (lastErr instanceof LuaLSError) throw lastErr;
1719
+ throw new LuaLSError(`Failed to download LuaLS from ${url}${lastErr ? `: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}` : ""}`, "ERR_LUALS_DOWNLOAD", "Check your network connection or specify a custom binary with LUALS_BIN.", { cause: lastErr });
1720
+ }
1721
+ const arrayBuffer = await response.arrayBuffer();
1722
+ fs.writeFileSync(archivePath, Buffer.from(arrayBuffer));
1723
+ if (!options?.quiet) logger.info(`[luals] Extracting to ${destDir}...`);
1724
+ try {
1725
+ await execFileAsync$1("tar", [
1726
+ "-xf",
1727
+ archivePath,
1728
+ "-C",
1729
+ tempDir
1730
+ ]);
1731
+ } catch (tarErr) {
1732
+ if (process.platform === "win32" && info.assetName.endsWith(".zip")) await execFileAsync$1("powershell.exe", [
1733
+ "-NoProfile",
1734
+ "-Command",
1735
+ `Expand-Archive -Path '${escapePowerShellSingleQuote(archivePath)}' -DestinationPath '${escapePowerShellSingleQuote(tempDir)}' -Force`
1736
+ ]);
1737
+ else throw tarErr;
1738
+ }
1739
+ try {
1740
+ fs.unlinkSync(archivePath);
1741
+ } catch (err) {
1742
+ logger.debug(`[luals] Failed to delete temporary archive ${archivePath}: ${err instanceof Error ? err.message : String(err)}`);
1743
+ }
1324
1744
  }
1325
- try {
1326
- fs.unlinkSync(archivePath);
1327
- } catch {}
1328
1745
  const tempBinaryPath = path.join(tempDir, info.binaryRelativePath);
1329
1746
  if (process.platform !== "win32") try {
1330
1747
  fs.chmodSync(tempBinaryPath, 493);
1331
- } catch {}
1332
- if (!fs.existsSync(tempBinaryPath) || fs.statSync(tempBinaryPath).size < 1e5) throw new Error(`Failed to extract valid LuaLS binary to expected path: ${tempBinaryPath}`);
1333
- if (!isBinaryValid(tempBinaryPath)) throw new Error(`Extracted LuaLS binary at ${tempBinaryPath} is invalid or non-functional.`);
1748
+ } catch (err) {
1749
+ logger.warn(`[luals] Failed to chmod binary at ${tempBinaryPath}: ${err instanceof Error ? err.message : String(err)}`);
1750
+ }
1751
+ if (!fs.existsSync(tempBinaryPath) || fs.statSync(tempBinaryPath).size < 1e5) throw new LuaLSError(`Failed to extract valid LuaLS binary to expected path: ${tempBinaryPath}`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and ensure there is sufficient disk space.");
1752
+ if (!isBinaryValid(tempBinaryPath)) throw new LuaLSError(`Extracted LuaLS binary at ${tempBinaryPath} is invalid or non-functional.`, "ERR_LUALS_INVALID_BINARY", "Run 'nanos-lint clean-cache' to remove corrupted downloads or set LUALS_BIN to a custom binary.");
1334
1753
  fs.writeFileSync(path.join(tempDir, ".complete"), resolvedVersion, "utf-8");
1335
1754
  for (let attempt = 0; attempt < 5; attempt++) try {
1336
1755
  fs.renameSync(tempDir, destDir);
@@ -1342,8 +1761,10 @@ async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDi
1342
1761
  recursive: true,
1343
1762
  force: true
1344
1763
  });
1345
- } catch {}
1346
- if (!options?.quiet) console.log(`[luals] Ready: ${binaryPath}`);
1764
+ } catch (err) {
1765
+ logger.debug(`[luals] Failed to remove temp directory after concurrent promotion: ${err instanceof Error ? err.message : String(err)}`);
1766
+ }
1767
+ if (!options?.quiet) logger.info(`[luals] Ready: ${binaryPath}`);
1347
1768
  return binaryPath;
1348
1769
  }
1349
1770
  if (attempt < 4) await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
@@ -1353,11 +1774,13 @@ async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDi
1353
1774
  recursive: true,
1354
1775
  force: true
1355
1776
  });
1356
- } catch {}
1777
+ } catch (err) {
1778
+ logger.warn(`[luals] Failed to clean up broken destination directory ${destDir}: ${err instanceof Error ? err.message : String(err)}`);
1779
+ }
1357
1780
  throw renameErr;
1358
1781
  }
1359
1782
  }
1360
- if (!options?.quiet) console.log(`[luals] Ready: ${binaryPath}`);
1783
+ if (!options?.quiet) logger.info(`[luals] Ready: ${binaryPath}`);
1361
1784
  return binaryPath;
1362
1785
  } finally {
1363
1786
  if (fs.existsSync(tempDir)) try {
@@ -1365,137 +1788,13 @@ async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDi
1365
1788
  recursive: true,
1366
1789
  force: true
1367
1790
  });
1368
- } catch {}
1369
- }
1370
- }
1371
- async function resolveLuaLSBinary(version = DEFAULT_LUALS_VERSION, options) {
1372
- if (process.env.LUALS_BIN && fs.existsSync(process.env.LUALS_BIN)) return process.env.LUALS_BIN;
1373
- if (!version || version === "latest") {
1374
- const defaultInfo = getPlatformInfo(FALLBACK_LUALS_VERSION);
1375
- const defaultBundledPath = path.join(getPackageRoot(), defaultInfo.binaryRelativePath);
1376
- if (fs.existsSync(defaultBundledPath) && isBinaryValid(defaultBundledPath)) return defaultBundledPath;
1377
- }
1378
- const resolvedVersion = await resolveLuaLSVersion(version);
1379
- const info = getPlatformInfo(resolvedVersion);
1380
- const bundledPath = path.join(getPackageRoot(), info.binaryRelativePath);
1381
- if (fs.existsSync(bundledPath) && isBinaryValid(bundledPath)) return bundledPath;
1382
- const cachedDir = getCacheDir(resolvedVersion);
1383
- const cachedPath = path.join(cachedDir, info.binaryRelativePath);
1384
- const completeMarker = path.join(cachedDir, ".complete");
1385
- if (fs.existsSync(cachedPath)) {
1386
- if (fs.existsSync(completeMarker)) try {
1387
- if (fs.readFileSync(completeMarker, "utf-8").trim() === resolvedVersion && isBinaryValid(cachedPath)) return cachedPath;
1388
- } catch {}
1389
- if (!options?.quiet) console.warn(`[luals] Cached LuaLS binary at ${cachedPath} is corrupted or incomplete. Repairing...`);
1390
- try {
1391
- fs.rmSync(cachedDir, {
1392
- recursive: true,
1393
- force: true
1394
- });
1395
- } catch {}
1396
- }
1397
- const legacyDir = getLegacyCacheDir(resolvedVersion);
1398
- const legacyPath = path.join(legacyDir, info.binaryRelativePath);
1399
- const legacyMarker = path.join(legacyDir, ".complete");
1400
- if (fs.existsSync(legacyPath)) {
1401
- let validLegacy = false;
1402
- if (fs.existsSync(legacyMarker)) try {
1403
- if (fs.readFileSync(legacyMarker, "utf-8").trim() === resolvedVersion && isBinaryValid(legacyPath)) validLegacy = true;
1404
- } catch {}
1405
- else if (isBinaryValid(legacyPath)) validLegacy = true;
1406
- if (validLegacy) {
1407
- if (path.resolve(legacyDir) !== path.resolve(cachedDir)) try {
1408
- fs.mkdirSync(path.dirname(cachedDir), { recursive: true });
1409
- fs.cpSync(legacyDir, cachedDir, { recursive: true });
1410
- if (fs.existsSync(cachedPath) && isBinaryValid(cachedPath)) return cachedPath;
1411
- } catch {}
1412
- return legacyPath;
1413
- }
1414
- }
1415
- try {
1416
- const cmd = process.platform === "win32" ? "where.exe" : "which";
1417
- const { stdout } = await execFileAsync(cmd, ["lua-language-server"]);
1418
- const found = stdout.trim().split(/\r?\n/)[0];
1419
- if (found && fs.existsSync(found) && isBinaryValid(found)) return found;
1420
- } catch {}
1421
- return await downloadAndExtractLuaLS(resolvedVersion, void 0, options);
1422
- }
1423
- async function runLuaLSCheck(targetPath, configPath, options) {
1424
- const absoluteTarget = path.resolve(targetPath);
1425
- if (!fs.existsSync(absoluteTarget)) throw new Error(`Target path does not exist: ${targetPath}`);
1426
- const binary = options.lualsBin || await resolveLuaLSBinary(options.lualsVersion, { quiet: options.quiet });
1427
- let checkDir = absoluteTarget;
1428
- let targetFileOnly = null;
1429
- if (fs.statSync(absoluteTarget).isFile()) {
1430
- checkDir = path.dirname(absoluteTarget);
1431
- targetFileOnly = absoluteTarget;
1432
- }
1433
- const tempOutputDir = systemPaths.temp;
1434
- fs.mkdirSync(tempOutputDir, { recursive: true });
1435
- const checkOutPath = path.join(tempOutputDir, `check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`);
1436
- const args = [
1437
- `--check=${checkDir}`,
1438
- `--configpath=${path.resolve(configPath)}`,
1439
- `--check_out_path=${checkOutPath}`,
1440
- "--check_format=json"
1441
- ];
1442
- if (options.checklevel) args.push(`--checklevel=${options.checklevel}`);
1443
- let execError = null;
1444
- try {
1445
- await execFileAsync(binary, args, {
1446
- maxBuffer: 104857600,
1447
- timeout: 12e4
1448
- });
1449
- } catch (err) {
1450
- execError = err;
1451
- }
1452
- let diagnostics = {};
1453
- let parseSucceeded = false;
1454
- if (fs.existsSync(checkOutPath)) try {
1455
- const content = fs.readFileSync(checkOutPath, "utf-8");
1456
- diagnostics = JSON.parse(content);
1457
- parseSucceeded = true;
1458
- } catch {} finally {
1459
- try {
1460
- fs.unlinkSync(checkOutPath);
1461
- } catch {}
1462
- }
1463
- if (!parseSucceeded) {
1464
- const cacheHint = `(Cache location: ${getCacheDir()})`;
1465
- if (execError) throw new Error(`LuaLS check failed to execute or produce diagnostic output: ${execError instanceof Error ? execError.message : String(execError)}. ${cacheHint}`);
1466
- throw new Error(`LuaLS check failed to produce diagnostic output at: ${checkOutPath}. ${cacheHint}`);
1467
- }
1468
- if (targetFileOnly) {
1469
- const filtered = {};
1470
- const normTarget = path.resolve(targetFileOnly).toLowerCase();
1471
- for (const [rawUri, diags] of Object.entries(diagnostics)) {
1472
- const filePath = fileUriToPath(rawUri);
1473
- if (path.resolve(filePath).toLowerCase() === normTarget) filtered[rawUri] = diags;
1791
+ } catch (err) {
1792
+ logger.debug(`[luals] Failed to clean up temporary directory ${tempDir}: ${err instanceof Error ? err.message : String(err)}`);
1474
1793
  }
1475
- diagnostics = filtered;
1476
1794
  }
1477
- let totalProblems = 0;
1478
- let totalErrors = 0;
1479
- let totalWarnings = 0;
1480
- let problemFiles = 0;
1481
- for (const [_, diags] of Object.entries(diagnostics)) if (diags.length > 0) {
1482
- problemFiles += 1;
1483
- totalProblems += diags.length;
1484
- for (const d of diags) if (d.severity === 1) totalErrors += 1;
1485
- else if (d.severity === 2) totalWarnings += 1;
1486
- }
1487
- const passed = totalProblems === 0;
1488
- const filesChecked = countCheckedFiles(targetPath, configPath);
1489
- return {
1490
- passed,
1491
- totalProblems,
1492
- totalErrors,
1493
- totalWarnings,
1494
- totalFiles: passed ? filesChecked : problemFiles,
1495
- totalFilesChecked: filesChecked,
1496
- diagnostics
1497
- };
1498
1795
  }
1796
+ //#endregion
1797
+ //#region src/luals/files.ts
1499
1798
  /**
1500
1799
  * Counts candidate Lua files within targetPath, taking ignoreDir and files.exclude into account.
1501
1800
  */
@@ -1514,7 +1813,9 @@ function countCheckedFiles(targetPath, configPath) {
1514
1813
  const cfg = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1515
1814
  if (cfg.workspace?.ignoreDir) ignoreDirs = cfg.workspace.ignoreDir;
1516
1815
  if (cfg.files?.exclude) excludePatterns = cfg.files.exclude;
1517
- } catch {}
1816
+ } catch (err) {
1817
+ logger.warn(`[luals] Failed to parse config file for file counting at ${configPath}: ${err instanceof Error ? err.message : String(err)}`);
1818
+ }
1518
1819
  const normIgnoreDirs = new Set(ignoreDirs.map((d) => d.replace(/\\/g, "/").toLowerCase()));
1519
1820
  function isExcluded(relPath) {
1520
1821
  const norm = relPath.replace(/\\/g, "/");
@@ -1532,7 +1833,9 @@ function countCheckedFiles(targetPath, configPath) {
1532
1833
  const baseRegexStr = "^" + normPat.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$";
1533
1834
  try {
1534
1835
  if (new RegExp(baseRegexStr, "i").test(baseName)) return true;
1535
- } catch {}
1836
+ } catch (err) {
1837
+ logger.debug(`[luals] Invalid regex for pattern "${normPat}": ${err instanceof Error ? err.message : String(err)}`);
1838
+ }
1536
1839
  }
1537
1840
  let regexStr = normPat;
1538
1841
  const hasLeadingDoubleStar = regexStr.startsWith("**/");
@@ -1544,7 +1847,9 @@ function countCheckedFiles(targetPath, configPath) {
1544
1847
  if (hasTrailingDoubleStar) escaped = `${escaped}(?:/.*)?`;
1545
1848
  try {
1546
1849
  if (new RegExp(`^${escaped}$`, "i").test(norm)) return true;
1547
- } catch {}
1850
+ } catch (err) {
1851
+ logger.debug(`[luals] Invalid glob regex for pattern "${normPat}": ${err instanceof Error ? err.message : String(err)}`);
1852
+ }
1548
1853
  }
1549
1854
  }
1550
1855
  return false;
@@ -1554,7 +1859,8 @@ function countCheckedFiles(targetPath, configPath) {
1554
1859
  let entries;
1555
1860
  try {
1556
1861
  entries = fs.readdirSync(currentDir, { withFileTypes: true });
1557
- } catch {
1862
+ } catch (err) {
1863
+ logger.debug(`[luals] Failed to read directory ${currentDir}: ${err instanceof Error ? err.message : String(err)}`);
1558
1864
  return;
1559
1865
  }
1560
1866
  for (const entry of entries) {
@@ -1564,7 +1870,8 @@ function countCheckedFiles(targetPath, configPath) {
1564
1870
  if (entry.isDirectory() || entry.isSymbolicLink() && (() => {
1565
1871
  try {
1566
1872
  return fs.statSync(fullPath).isDirectory();
1567
- } catch {
1873
+ } catch (err) {
1874
+ logger.debug(`[luals] Failed to stat symlink target ${fullPath}: ${err instanceof Error ? err.message : String(err)}`);
1568
1875
  return false;
1569
1876
  }
1570
1877
  })()) {
@@ -1581,6 +1888,257 @@ function countCheckedFiles(targetPath, configPath) {
1581
1888
  return count;
1582
1889
  }
1583
1890
  //#endregion
1891
+ //#region src/luals/runner.ts
1892
+ const execFileAsync = promisify(execFile);
1893
+ function isOfflineError(err) {
1894
+ if (!err) return false;
1895
+ const msg = err instanceof Error ? err.message : String(err);
1896
+ return /offline|enotfound|eai_again|econnrefused|etimedout|fetch failed/i.test(msg);
1897
+ }
1898
+ async function resolveLuaLSBinary(version = DEFAULT_LUALS_VERSION, options) {
1899
+ if (process.env.LUALS_BIN && fs.existsSync(process.env.LUALS_BIN)) return process.env.LUALS_BIN;
1900
+ if (options?.reuseExisting !== false && (!version || version === "latest")) {
1901
+ const defaultInfo = getPlatformInfo(FALLBACK_LUALS_VERSION);
1902
+ const defaultBundledPath = path.join(getPackageRoot(), defaultInfo.binaryRelativePath);
1903
+ if (fs.existsSync(defaultBundledPath) && isBinaryValid(defaultBundledPath)) return defaultBundledPath;
1904
+ }
1905
+ const isDefaultOrLatest = !version || version === "latest";
1906
+ const baseCacheDir = options?.cacheDir ?? getBaseLuaLSCacheDir();
1907
+ if (!isDefaultOrLatest) {
1908
+ const resolvedVersion = await resolveLuaLSVersion(version);
1909
+ const info = getPlatformInfo(resolvedVersion);
1910
+ if (options?.reuseExisting !== false) {
1911
+ const bundledPath = path.join(getPackageRoot(), info.binaryRelativePath);
1912
+ if (fs.existsSync(bundledPath) && isBinaryValid(bundledPath)) return bundledPath;
1913
+ }
1914
+ const cachedDir = getCacheDir(resolvedVersion, baseCacheDir);
1915
+ const cachedPath = path.join(cachedDir, info.binaryRelativePath);
1916
+ const completeMarker = path.join(cachedDir, ".complete");
1917
+ let wasCorrupted = false;
1918
+ if (fs.existsSync(cachedPath)) {
1919
+ let isValid = false;
1920
+ if (fs.existsSync(completeMarker)) try {
1921
+ if (fs.readFileSync(completeMarker, "utf-8").trim() === resolvedVersion && isBinaryValid(cachedPath)) {
1922
+ isValid = true;
1923
+ if (options?.reuseExisting !== false) return cachedPath;
1924
+ }
1925
+ } catch (err) {
1926
+ logger.debug(`[luals] Failed to read complete marker at ${completeMarker}: ${err instanceof Error ? err.message : String(err)}`);
1927
+ }
1928
+ if (!isValid) {
1929
+ wasCorrupted = true;
1930
+ if (!options?.quiet) logger.warn(`[luals] Cached LuaLS binary at ${cachedPath} is corrupted or incomplete. Repairing...`);
1931
+ }
1932
+ try {
1933
+ fs.rmSync(cachedDir, {
1934
+ recursive: true,
1935
+ force: true
1936
+ });
1937
+ } catch (err) {
1938
+ logger.warn(`[luals] Failed to remove corrupted cache directory ${cachedDir}: ${err instanceof Error ? err.message : String(err)}`);
1939
+ }
1940
+ }
1941
+ if (options?.reuseExisting !== false) {
1942
+ const legacyDir = getLegacyCacheDir(resolvedVersion);
1943
+ if (path.resolve(legacyDir) !== path.resolve(cachedDir)) {
1944
+ const legacyPath = path.join(legacyDir, info.binaryRelativePath);
1945
+ const legacyMarker = path.join(legacyDir, ".complete");
1946
+ if (fs.existsSync(legacyPath)) {
1947
+ let validLegacy = false;
1948
+ if (fs.existsSync(legacyMarker)) try {
1949
+ if (fs.readFileSync(legacyMarker, "utf-8").trim() === resolvedVersion && isBinaryValid(legacyPath)) validLegacy = true;
1950
+ } catch (err) {
1951
+ logger.debug(`[luals] Failed to read legacy complete marker at ${legacyMarker}: ${err instanceof Error ? err.message : String(err)}`);
1952
+ }
1953
+ else if (isBinaryValid(legacyPath)) validLegacy = true;
1954
+ if (validLegacy) {
1955
+ try {
1956
+ fs.mkdirSync(path.dirname(cachedDir), { recursive: true });
1957
+ fs.cpSync(legacyDir, cachedDir, { recursive: true });
1958
+ if (fs.existsSync(cachedPath) && isBinaryValid(cachedPath)) return cachedPath;
1959
+ } catch (err) {
1960
+ logger.warn(`[luals] Failed to migrate legacy cache from ${legacyDir} to ${cachedDir}: ${err instanceof Error ? err.message : String(err)}`);
1961
+ }
1962
+ return legacyPath;
1963
+ }
1964
+ }
1965
+ }
1966
+ }
1967
+ if (options?.reuseExisting !== false) try {
1968
+ const cmd = process.platform === "win32" ? "where.exe" : "which";
1969
+ const { stdout } = await execFileAsync(cmd, ["lua-language-server"]);
1970
+ const found = stdout.trim().split(/\r?\n/)[0];
1971
+ if (found && fs.existsSync(found) && isBinaryValid(found)) return found;
1972
+ } catch (err) {
1973
+ logger.debug(`[luals] LuaLS binary not found in PATH: ${err instanceof Error ? err.message : String(err)}`);
1974
+ }
1975
+ try {
1976
+ return await downloadAndExtractLuaLS(resolvedVersion, getCacheDir(resolvedVersion, baseCacheDir), options);
1977
+ } catch (err) {
1978
+ if (wasCorrupted) {
1979
+ const isOffline = isOfflineError(err);
1980
+ const reason = isOffline ? "while offline" : err instanceof Error ? err.message : String(err);
1981
+ throw new LuaLSError(`Cached LuaLS binary at '${cachedPath}' is corrupted (failed execution/size check) and cannot be re-downloaded ${isOffline ? reason : `: ${reason}`}. Please ${isOffline ? "connect to the internet" : "verify your network connection"} to repair or run 'nanos-lint clean-cache'.`, "ERR_LUALS_CORRUPTED_CACHE", "Connect to the internet to repair the corrupted binary or run 'nanos-lint clean-cache'.", { cause: err });
1982
+ }
1983
+ throw err;
1984
+ }
1985
+ }
1986
+ const currentWeek = getIsoWeek();
1987
+ const metadata = readLuaLSMetadata(baseCacheDir);
1988
+ if (options?.reuseExisting !== false && metadata && metadata.lastCheckedWeek === currentWeek && metadata.latestVersion) {
1989
+ const info = getPlatformInfo(metadata.latestVersion);
1990
+ const cachedPath = path.join(getCacheDir(metadata.latestVersion, baseCacheDir), info.binaryRelativePath);
1991
+ if (isBinaryValid(cachedPath)) return cachedPath;
1992
+ }
1993
+ const cachedVersions = options?.reuseExisting !== false ? listCachedLuaLSVersions(baseCacheDir) : [];
1994
+ const firstCachedVersion = cachedVersions[0];
1995
+ if (options?.reuseExisting !== false && metadata && metadata.lastCheckedWeek === currentWeek && firstCachedVersion) {
1996
+ const info = getPlatformInfo(firstCachedVersion);
1997
+ return path.join(getCacheDir(firstCachedVersion, baseCacheDir), info.binaryRelativePath);
1998
+ }
1999
+ const onlineTag = await fetchLatestLuaLSVersionFromGitHub();
2000
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2001
+ let targetVersion;
2002
+ if (onlineTag) targetVersion = onlineTag;
2003
+ else if (metadata?.latestVersion && cachedVersions.includes(metadata.latestVersion)) {
2004
+ logger.info(`[luals] Network unreachable or rate limited; using cached LuaLS ${metadata.latestVersion}.`);
2005
+ targetVersion = metadata.latestVersion;
2006
+ } else if (firstCachedVersion) {
2007
+ logger.info(`[luals] Network unreachable or rate limited; using cached LuaLS ${firstCachedVersion}.`);
2008
+ targetVersion = firstCachedVersion;
2009
+ } else targetVersion = FALLBACK_LUALS_VERSION;
2010
+ writeLuaLSMetadata({
2011
+ lastCheckedWeek: currentWeek,
2012
+ latestVersion: targetVersion,
2013
+ lastCheckedDate: today
2014
+ }, baseCacheDir);
2015
+ const info = getPlatformInfo(targetVersion);
2016
+ const targetCacheDir = getCacheDir(targetVersion, baseCacheDir);
2017
+ const targetBinaryPath = path.join(targetCacheDir, info.binaryRelativePath);
2018
+ const completeMarker = path.join(targetCacheDir, ".complete");
2019
+ let wasCorrupted = false;
2020
+ if (fs.existsSync(targetBinaryPath)) {
2021
+ if (fs.existsSync(completeMarker) && isBinaryValid(targetBinaryPath)) {
2022
+ if (options?.reuseExisting !== false) {
2023
+ cleanupOldCachedLuaLSVersions(targetVersion, baseCacheDir);
2024
+ return targetBinaryPath;
2025
+ }
2026
+ } else wasCorrupted = true;
2027
+ }
2028
+ if (options?.reuseExisting !== false && !onlineTag) try {
2029
+ const cmd = process.platform === "win32" ? "where.exe" : "which";
2030
+ const { stdout } = await execFileAsync(cmd, ["lua-language-server"]);
2031
+ const found = stdout.trim().split(/\r?\n/)[0];
2032
+ if (found && fs.existsSync(found) && isBinaryValid(found)) return found;
2033
+ } catch (err) {
2034
+ logger.debug(`[luals] LuaLS binary not found in PATH: ${err instanceof Error ? err.message : String(err)}`);
2035
+ }
2036
+ let downloadedBinary;
2037
+ try {
2038
+ downloadedBinary = await downloadAndExtractLuaLS(targetVersion, targetCacheDir, options);
2039
+ } catch (err) {
2040
+ if (wasCorrupted) {
2041
+ const isOffline = isOfflineError(err);
2042
+ const reason = isOffline ? "while offline" : err instanceof Error ? err.message : String(err);
2043
+ throw new LuaLSError(`Cached LuaLS binary at '${targetBinaryPath}' is corrupted (failed execution/size check) and cannot be re-downloaded ${isOffline ? reason : `: ${reason}`}. Please ${isOffline ? "connect to the internet" : "verify your network connection"} to repair or run 'nanos-lint clean-cache'.`, "ERR_LUALS_CORRUPTED_CACHE", "Connect to the internet to repair the corrupted binary or run 'nanos-lint clean-cache'.", { cause: err });
2044
+ }
2045
+ throw err;
2046
+ }
2047
+ cleanupOldCachedLuaLSVersions(targetVersion, baseCacheDir);
2048
+ return downloadedBinary;
2049
+ }
2050
+ async function runLuaLSCheck(targetPath, configPath, options) {
2051
+ let absoluteTarget = path.resolve(targetPath);
2052
+ try {
2053
+ if (fs.existsSync(absoluteTarget)) absoluteTarget = fs.realpathSync.native(absoluteTarget);
2054
+ } catch (err) {
2055
+ logger.debug(`[luals] Failed to resolve native realpath for target: ${err instanceof Error ? err.message : String(err)}`);
2056
+ }
2057
+ if (!fs.existsSync(absoluteTarget)) throw new LuaLSError(`Target path does not exist: ${targetPath}`, "ERR_TARGET_NOT_FOUND", "Verify that the target path exists and is accessible.");
2058
+ const binary = options.lualsBin || await resolveLuaLSBinary(options.lualsVersion, { quiet: options.quiet });
2059
+ let checkDir = absoluteTarget;
2060
+ let targetFileOnly = null;
2061
+ if (fs.statSync(absoluteTarget).isFile()) {
2062
+ checkDir = path.dirname(absoluteTarget);
2063
+ targetFileOnly = absoluteTarget;
2064
+ }
2065
+ const tempOutputDir = systemPaths.temp;
2066
+ fs.mkdirSync(tempOutputDir, { recursive: true });
2067
+ const checkOutPath = path.join(tempOutputDir, `check-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`);
2068
+ const args = [
2069
+ `--check=${checkDir}`,
2070
+ `--configpath=${path.resolve(configPath)}`,
2071
+ `--check_out_path=${checkOutPath}`,
2072
+ "--check_format=json"
2073
+ ];
2074
+ if (options.checklevel) args.push(`--checklevel=${options.checklevel}`);
2075
+ let execError = null;
2076
+ try {
2077
+ await execFileAsync(binary, args, {
2078
+ maxBuffer: 104857600,
2079
+ timeout: 12e4
2080
+ });
2081
+ } catch (err) {
2082
+ execError = err;
2083
+ logger.debug(`[luals] LuaLS process exited with error or non-zero status: ${err instanceof Error ? err.message : String(err)}`);
2084
+ }
2085
+ let diagnostics = {};
2086
+ let parseSucceeded = false;
2087
+ if (fs.existsSync(checkOutPath)) try {
2088
+ const content = fs.readFileSync(checkOutPath, "utf-8");
2089
+ diagnostics = JSON.parse(content);
2090
+ parseSucceeded = true;
2091
+ } catch (err) {
2092
+ logger.error(`[luals] Failed to read or parse diagnostic output from ${checkOutPath}: ${err instanceof Error ? err.message : String(err)}`);
2093
+ } finally {
2094
+ try {
2095
+ fs.unlinkSync(checkOutPath);
2096
+ } catch (err) {
2097
+ logger.debug(`[luals] Failed to delete check output file ${checkOutPath}: ${err instanceof Error ? err.message : String(err)}`);
2098
+ }
2099
+ }
2100
+ if (!parseSucceeded) {
2101
+ const cacheHint = `(Cache location: ${getCacheDir()})`;
2102
+ if (execError) throw new LuaLSError(`LuaLS check failed to execute or produce diagnostic output: ${execError instanceof Error ? execError.message : String(execError)}. ${cacheHint}`, "ERR_LUALS_EXECUTION", "Inspect the debug log with --log-level=debug or run 'nanos-lint clean-cache' to re-fetch LuaLS.", { cause: execError });
2103
+ throw new LuaLSError(`LuaLS check failed to produce diagnostic output at: ${checkOutPath}. ${cacheHint}`, "ERR_LUALS_NO_OUTPUT", "Ensure the temporary directory is writable and sufficient disk space is available.");
2104
+ }
2105
+ if (targetFileOnly) {
2106
+ const filtered = {};
2107
+ const normalizeComparable = (p) => {
2108
+ try {
2109
+ if (fs.existsSync(p)) return fs.realpathSync.native(p).replace(/\\/g, "/").toLowerCase();
2110
+ } catch (err) {
2111
+ logger.debug(`[luals] Failed to resolve native realpath for comparison: ${err instanceof Error ? err.message : String(err)}`);
2112
+ }
2113
+ return path.resolve(p).replace(/\\/g, "/").toLowerCase();
2114
+ };
2115
+ const normTarget = normalizeComparable(targetFileOnly);
2116
+ for (const [rawUri, diags] of Object.entries(diagnostics)) if (normalizeComparable(fileUriToPath(rawUri)) === normTarget) filtered[rawUri] = diags;
2117
+ diagnostics = filtered;
2118
+ }
2119
+ let totalProblems = 0;
2120
+ let totalErrors = 0;
2121
+ let totalWarnings = 0;
2122
+ let problemFiles = 0;
2123
+ for (const [_, diags] of Object.entries(diagnostics)) if (diags.length > 0) {
2124
+ problemFiles += 1;
2125
+ totalProblems += diags.length;
2126
+ for (const d of diags) if (d.severity === 1) totalErrors += 1;
2127
+ else if (d.severity === 2) totalWarnings += 1;
2128
+ }
2129
+ const passed = totalProblems === 0;
2130
+ const filesChecked = countCheckedFiles(targetPath, configPath);
2131
+ return {
2132
+ passed,
2133
+ totalProblems,
2134
+ totalErrors,
2135
+ totalWarnings,
2136
+ totalFiles: passed ? filesChecked : problemFiles,
2137
+ totalFilesChecked: filesChecked,
2138
+ diagnostics
2139
+ };
2140
+ }
2141
+ //#endregion
1584
2142
  //#region src/reporter.ts
1585
2143
  const SEVERITY_NAMES = {
1586
2144
  1: "Error",
@@ -1661,7 +2219,9 @@ function formatPretty(result, cwd = process.cwd(), useColor = shouldEnableColor(
1661
2219
  let fileContent = [];
1662
2220
  try {
1663
2221
  if (fs.existsSync(filePath)) fileContent = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
1664
- } catch {}
2222
+ } catch (err) {
2223
+ logger.debug(`Failed to read file snippet for ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
2224
+ }
1665
2225
  for (const d of diags) {
1666
2226
  const line = d.range.start.line + 1;
1667
2227
  const col = d.range.start.character + 1;
@@ -4714,9 +5274,43 @@ function readAnnotationsMetadata(cacheDir = getAnnotationsCacheDir()) {
4714
5274
  const content = fs.readFileSync(metaPath, "utf-8");
4715
5275
  const parsed = JSON.parse(content);
4716
5276
  if (typeof parsed?.commitId === "string" && typeof parsed?.lastChecked === "string") return parsed;
4717
- } catch {}
5277
+ try {
5278
+ fs.unlinkSync(metaPath);
5279
+ logger.warn(`[annotations] Stale or invalid annotations metadata at ${metaPath} purged.`);
5280
+ } catch (unlinkErr) {
5281
+ logger.debug(`[annotations] Failed to unlink invalid metadata: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`);
5282
+ }
5283
+ } catch (err) {
5284
+ logger.debug(`Failed to parse annotations metadata: ${err instanceof Error ? err.message : String(err)}`);
5285
+ try {
5286
+ fs.unlinkSync(metaPath);
5287
+ logger.warn(`[annotations] Corrupted annotations metadata at ${metaPath} purged.`);
5288
+ } catch (unlinkErr) {
5289
+ logger.debug(`[annotations] Failed to unlink corrupted metadata: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`);
5290
+ }
5291
+ }
4718
5292
  return null;
4719
5293
  }
5294
+ /**
5295
+ * Verifies that an annotations file exists, is a regular file, is of non-trivial size (>= 1000 bytes),
5296
+ * and begins with valid Lua comments or nanos world definitions.
5297
+ */
5298
+ function isAnnotationsValid(filePath) {
5299
+ if (!fs.existsSync(filePath)) return false;
5300
+ try {
5301
+ const stat = fs.statSync(filePath);
5302
+ if (!stat.isFile() || stat.size < 1e3) return false;
5303
+ const fd = fs.openSync(filePath, "r");
5304
+ const buffer = Buffer.alloc(256);
5305
+ const bytesRead = fs.readSync(fd, buffer, 0, 256, 0);
5306
+ fs.closeSync(fd);
5307
+ const header = buffer.toString("utf-8", 0, bytesRead).trimStart();
5308
+ return header.startsWith("--") || header.includes("nanos world");
5309
+ } catch (err) {
5310
+ logger.debug(`[annotations] Annotation validation failed for ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
5311
+ return false;
5312
+ }
5313
+ }
4720
5314
  async function fetchLatestCommitId() {
4721
5315
  try {
4722
5316
  const headers = { "User-Agent": "nanos-lint" };
@@ -4729,7 +5323,9 @@ async function fetchLatestCommitId() {
4729
5323
  const data = await res.json();
4730
5324
  if (typeof data.sha === "string" && /^[0-9a-fA-F]{7,40}$/.test(data.sha)) return data.sha;
4731
5325
  }
4732
- } catch {}
5326
+ } catch (err) {
5327
+ logger.warn(`Failed to resolve latest annotations commit from GitHub: ${err instanceof Error ? err.message : String(err)}`);
5328
+ }
4733
5329
  return null;
4734
5330
  }
4735
5331
  async function fetchRawAnnotationsContent() {
@@ -4737,9 +5333,9 @@ async function fetchRawAnnotationsContent() {
4737
5333
  headers: { "User-Agent": "nanos-lint" },
4738
5334
  signal: AbortSignal.timeout(15e3)
4739
5335
  });
4740
- if (!res.ok) throw new Error(`Failed to download annotations.lua: ${res.status} ${res.statusText}`);
5336
+ if (!res.ok) throw new AnnotationsError(`Failed to download annotations.lua: ${res.status} ${res.statusText}`, "ERR_ANNOTATIONS_DOWNLOAD", "Verify your internet connection and that GitHub raw endpoints are accessible.");
4741
5337
  const text = await res.text();
4742
- if (!text || text.length < 1e3) throw new Error("Downloaded annotations.lua appears truncated or invalid");
5338
+ if (!text || text.length < 1e3) throw new AnnotationsError("Downloaded annotations.lua appears truncated or invalid", "ERR_ANNOTATIONS_INVALID", "Retry downloading or pass a local annotations file via --annotations <path>.");
4743
5339
  return text;
4744
5340
  }
4745
5341
  async function copyFileWithRetry(src, dest, maxRetries = 10) {
@@ -4774,7 +5370,9 @@ function updateLastCheckedDate(commitId, cacheDir = getAnnotationsCacheDir()) {
4774
5370
  } finally {
4775
5371
  try {
4776
5372
  if (fs.existsSync(tempMetaPath)) fs.unlinkSync(tempMetaPath);
4777
- } catch {}
5373
+ } catch (err) {
5374
+ logger.warn(`Failed to clean up temporary metadata file ${tempMetaPath}: ${err instanceof Error ? err.message : String(err)}`);
5375
+ }
4778
5376
  }
4779
5377
  return metadata;
4780
5378
  }
@@ -4792,8 +5390,8 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
4792
5390
  try {
4793
5391
  fs.mkdirSync(tempDir, { recursive: true });
4794
5392
  if (!options?.quiet) {
4795
- if (commitId && commitId !== "unknown") console.log(`[annotations] Downloading nanos world API annotations (${commitId.slice(0, 7)})...`);
4796
- else console.log("[annotations] Downloading nanos world API annotations...");
5393
+ if (commitId && commitId !== "unknown") logger.info(`[annotations] Downloading nanos world API annotations (${commitId.slice(0, 7)})...`);
5394
+ else logger.info("[annotations] Downloading nanos world API annotations...");
4797
5395
  }
4798
5396
  const content = await fetchRawAnnotationsContent();
4799
5397
  const tempAnnotationsPath = path.join(tempDir, ANNOTATIONS_FILENAME);
@@ -4819,8 +5417,8 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
4819
5417
  await copyFileWithRetry(tempAnnotationsPath, finalAnnotationsPath);
4820
5418
  await copyFileWithRetry(tempMetaPath, finalMetaPath);
4821
5419
  if (!options?.quiet) {
4822
- if (commitId && commitId !== "unknown") console.log(`[annotations] Updated annotations.lua to commit ${commitId.slice(0, 7)}.`);
4823
- else console.log("[annotations] Updated annotations.lua.");
5420
+ if (commitId && commitId !== "unknown") logger.info(`[annotations] Updated annotations.lua to commit ${commitId.slice(0, 7)}.`);
5421
+ else logger.info("[annotations] Updated annotations.lua.");
4824
5422
  }
4825
5423
  return finalAnnotationsPath;
4826
5424
  } catch (err) {
@@ -4829,7 +5427,9 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
4829
5427
  const backupMeta = path.join(backupDir, METADATA_FILENAME);
4830
5428
  if (fs.existsSync(backupAnnotations)) await copyFileWithRetry(backupAnnotations, finalAnnotationsPath);
4831
5429
  if (fs.existsSync(backupMeta)) await copyFileWithRetry(backupMeta, finalMetaPath);
4832
- } catch {}
5430
+ } catch (rollbackErr) {
5431
+ logger.error(`Failed to restore annotations from backup during rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`);
5432
+ }
4833
5433
  throw err;
4834
5434
  } finally {
4835
5435
  try {
@@ -4841,7 +5441,9 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
4841
5441
  recursive: true,
4842
5442
  force: true
4843
5443
  });
4844
- } catch {}
5444
+ } catch (err) {
5445
+ logger.warn(`Failed to clean up annotations temporary or backup directory: ${err instanceof Error ? err.message : String(err)}`);
5446
+ }
4845
5447
  }
4846
5448
  }
4847
5449
  /**
@@ -4855,39 +5457,53 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
4855
5457
  async function resolveAnnotations(options = {}) {
4856
5458
  if (options.customPath) {
4857
5459
  const resolved = path.resolve(options.customPath);
4858
- if (!fs.existsSync(resolved)) throw new Error(`Custom annotations file not found: ${resolved}`);
5460
+ if (!fs.existsSync(resolved)) throw new AnnotationsError(`Custom annotations file not found: ${resolved}`, "ERR_ANNOTATIONS_NOT_FOUND", "Verify that the path specified in --annotations exists and is accessible.");
4859
5461
  return resolved;
4860
5462
  }
4861
5463
  const envPath = process.env.NANOS_ANNOTATIONS_PATH || process.env.NANOS_ANNOTATIONS;
4862
5464
  if (envPath) {
4863
5465
  const resolved = path.resolve(envPath);
4864
- if (!fs.existsSync(resolved)) throw new Error(`Annotations file specified in environment not found: ${resolved}`);
5466
+ if (!fs.existsSync(resolved)) throw new AnnotationsError(`Annotations file specified in environment not found: ${resolved}`, "ERR_ANNOTATIONS_ENV_NOT_FOUND", "Verify that the path specified in NANOS_ANNOTATIONS_PATH or NANOS_ANNOTATIONS exists.");
4865
5467
  return resolved;
4866
5468
  }
4867
5469
  const bundled = path.join(getPackageRoot(), "annotations.lua");
4868
- if (fs.existsSync(bundled)) return bundled;
5470
+ if (fs.existsSync(bundled) && isAnnotationsValid(bundled)) return bundled;
4869
5471
  const cacheDir = options.cacheDir ?? getAnnotationsCacheDir();
4870
5472
  const cachedAnnotationsFile = path.join(cacheDir, ANNOTATIONS_FILENAME);
4871
5473
  const metadata = readAnnotationsMetadata(cacheDir);
4872
5474
  const { dateStr } = getTodayDateString();
4873
- if (metadata && metadata.lastChecked === dateStr && fs.existsSync(cachedAnnotationsFile)) return cachedAnnotationsFile;
5475
+ if (fs.existsSync(cachedAnnotationsFile) && !isAnnotationsValid(cachedAnnotationsFile)) {
5476
+ logger.warn("[annotations] Cached annotations.lua is corrupted or empty. Purging and refreshing...");
5477
+ try {
5478
+ fs.unlinkSync(cachedAnnotationsFile);
5479
+ } catch (err) {
5480
+ logger.warn(`[annotations] Failed to remove corrupted cached annotations: ${err instanceof Error ? err.message : String(err)}`);
5481
+ }
5482
+ }
5483
+ if (metadata && metadata.lastChecked === dateStr && fs.existsSync(cachedAnnotationsFile) && isAnnotationsValid(cachedAnnotationsFile)) return cachedAnnotationsFile;
4874
5484
  const latestCommitId = await fetchLatestCommitId();
4875
5485
  if (latestCommitId) {
4876
- if (metadata && metadata.commitId === latestCommitId && fs.existsSync(cachedAnnotationsFile)) {
5486
+ if (metadata && metadata.commitId === latestCommitId && fs.existsSync(cachedAnnotationsFile) && isAnnotationsValid(cachedAnnotationsFile)) {
4877
5487
  updateLastCheckedDate(latestCommitId, cacheDir);
4878
5488
  return cachedAnnotationsFile;
4879
5489
  }
4880
5490
  return await downloadAndCacheAnnotations(latestCommitId, cacheDir, options);
4881
5491
  }
4882
- if (fs.existsSync(cachedAnnotationsFile)) {
5492
+ if (fs.existsSync(cachedAnnotationsFile) && isAnnotationsValid(cachedAnnotationsFile)) {
4883
5493
  try {
4884
5494
  updateLastCheckedDate(metadata?.commitId || "unknown", cacheDir);
4885
- } catch {}
5495
+ } catch (err) {
5496
+ logger.warn(`Failed to update lastChecked date for cached annotations: ${err instanceof Error ? err.message : String(err)}`);
5497
+ }
4886
5498
  return cachedAnnotationsFile;
4887
5499
  }
4888
5500
  try {
4889
5501
  return await downloadAndCacheAnnotations("unknown", cacheDir, options);
4890
5502
  } catch (err) {
5503
+ if (fs.existsSync(bundled) && isAnnotationsValid(bundled)) {
5504
+ logger.info("[annotations] Network offline and cache unavailable. Falling back to bundled annotations.");
5505
+ return bundled;
5506
+ }
4891
5507
  if (Boolean(err && typeof err === "object" && "code" in err && typeof err.code === "string" && [
4892
5508
  "EACCES",
4893
5509
  "EPERM",
@@ -4895,20 +5511,116 @@ async function resolveAnnotations(options = {}) {
4895
5511
  "EROFS",
4896
5512
  "EEXIST",
4897
5513
  "ENOENT"
4898
- ].includes(err.code)) || err instanceof Error && /permission denied|read-only|no space left/i.test(err.message)) throw new Error(`Failed to resolve nanos world API annotations due to a filesystem error: ${err instanceof Error ? err.message : String(err)}. Please check directory permissions or pass a custom file with --annotations.`, { cause: err });
4899
- throw new Error(`Failed to resolve nanos world API annotations. Please check your network connection or pass a custom file with --annotations. (${err instanceof Error ? err.message : String(err)})`, { cause: err });
5514
+ ].includes(err.code)) || err instanceof Error && /permission denied|read-only|no space left/i.test(err.message)) throw new AnnotationsError(`Failed to resolve nanos world API annotations due to a filesystem error: ${err instanceof Error ? err.message : String(err)}. Please check directory permissions or pass a custom file with --annotations.`, "ERR_ANNOTATIONS_FS", "Check directory permissions for the cache directory or specify --annotations <path>.", { cause: err });
5515
+ throw new AnnotationsError(`Failed to resolve nanos world API annotations. Please check your network connection or pass a custom file with --annotations. (${err instanceof Error ? err.message : String(err)})`, "ERR_ANNOTATIONS_NETWORK", "Check your network connection, run 'nanos-lint warmup' when online, or specify --annotations <path>.", { cause: err });
4900
5516
  }
4901
5517
  }
4902
5518
  //#endregion
5519
+ //#region src/cache-status.ts
5520
+ function getCacheStatus(baseCacheDir = systemPaths.cache) {
5521
+ const totalSize = getDirectorySize(baseCacheDir);
5522
+ const totalSizeFormatted = formatBytes(totalSize);
5523
+ const lualsBaseDir = path.join(baseCacheDir, "luals");
5524
+ const lualsMeta = readLuaLSMetadata(lualsBaseDir);
5525
+ const candidateVersions = listCachedLuaLSVersionDirs(lualsBaseDir);
5526
+ const versionInfos = [];
5527
+ for (const ver of candidateVersions) {
5528
+ const versionDir = getCacheDir(ver, lualsBaseDir);
5529
+ const marker = path.join(versionDir, ".complete");
5530
+ const info = getPlatformInfo(ver);
5531
+ const binPath = path.join(versionDir, info.binaryRelativePath);
5532
+ let isValid = false;
5533
+ if (fs.existsSync(marker) && fs.existsSync(binPath)) try {
5534
+ isValid = fs.readFileSync(marker, "utf-8").trim() === ver && isBinaryValid(binPath);
5535
+ } catch {
5536
+ isValid = false;
5537
+ }
5538
+ const dirSize = getDirectorySize(versionDir);
5539
+ versionInfos.push({
5540
+ version: ver,
5541
+ status: isValid ? "valid" : "corrupted",
5542
+ size: dirSize,
5543
+ sizeFormatted: formatBytes(dirSize),
5544
+ path: versionDir
5545
+ });
5546
+ }
5547
+ const lualsStatus = {
5548
+ weeklyCheck: lualsMeta?.lastCheckedWeek ?? null,
5549
+ lastCheckedDate: lualsMeta?.lastCheckedDate ?? null,
5550
+ targetVersion: lualsMeta?.latestVersion ?? null,
5551
+ versions: versionInfos
5552
+ };
5553
+ const annotationsDir = path.join(baseCacheDir, "annotations");
5554
+ const annotationsFile = path.join(annotationsDir, "annotations.lua");
5555
+ const annMeta = readAnnotationsMetadata(annotationsDir);
5556
+ let annStatus = "missing";
5557
+ let annSize = 0;
5558
+ if (fs.existsSync(annotationsFile)) {
5559
+ annSize = getDirectorySize(annotationsFile);
5560
+ annStatus = isAnnotationsValid(annotationsFile) ? "valid" : "corrupted";
5561
+ }
5562
+ return {
5563
+ cacheDirectory: baseCacheDir,
5564
+ totalSize,
5565
+ totalSizeFormatted,
5566
+ luals: lualsStatus,
5567
+ annotations: {
5568
+ status: annStatus,
5569
+ size: annSize,
5570
+ sizeFormatted: formatBytes(annSize),
5571
+ path: annotationsFile,
5572
+ commitId: annMeta?.commitId ?? null,
5573
+ lastChecked: annMeta?.lastChecked ?? null
5574
+ }
5575
+ };
5576
+ }
5577
+ function formatCacheStatusPretty(report) {
5578
+ const lines = [];
5579
+ lines.push("nanos-lint Cache Status");
5580
+ lines.push("");
5581
+ lines.push(`Cache Directory: ${report.cacheDirectory}`);
5582
+ lines.push(`Total Disk Usage: ${report.totalSizeFormatted}`);
5583
+ lines.push("");
5584
+ lines.push("Lua Language Server (LuaLS):");
5585
+ const weeklyStr = report.luals.weeklyCheck ? `${report.luals.weeklyCheck}${report.luals.lastCheckedDate ? ` (last checked: ${report.luals.lastCheckedDate})` : ""}` : "none";
5586
+ lines.push(` - Weekly Check: ${weeklyStr}`);
5587
+ const targetVer = report.luals.targetVersion ?? "none";
5588
+ lines.push(` - Target Version: ${targetVer}`);
5589
+ if (report.luals.versions.length === 0) lines.push(" - Cached Copies: none");
5590
+ else {
5591
+ lines.push(" - Cached Copies:");
5592
+ for (const v of report.luals.versions) lines.push(` * ${v.version} (status: ${v.status}, size: ${v.sizeFormatted})`);
5593
+ }
5594
+ lines.push("");
5595
+ lines.push("nanos world Annotations:");
5596
+ if (report.annotations.status === "missing") lines.push(" - Status: Not cached");
5597
+ else {
5598
+ const statusCap = report.annotations.status === "valid" ? "Valid" : "Corrupted";
5599
+ lines.push(` - Status: ${statusCap} (${report.annotations.sizeFormatted})`);
5600
+ if (report.annotations.commitId) {
5601
+ const sha = report.annotations.commitId;
5602
+ const shaDisplay = sha.length >= 7 && sha !== "unknown" ? `${sha.slice(0, 8)} (docgen-output)` : sha;
5603
+ lines.push(` - Commit SHA: ${shaDisplay}`);
5604
+ }
5605
+ if (report.annotations.lastChecked) lines.push(` - Last Checked: ${report.annotations.lastChecked}`);
5606
+ }
5607
+ return lines.join("\n");
5608
+ }
5609
+ //#endregion
4903
5610
  //#region src/cli.ts
4904
5611
  function getVersionString() {
4905
5612
  const root = getPackageRoot();
4906
5613
  try {
4907
5614
  return `nanos-lint v${JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8")).version}`;
4908
- } catch {
5615
+ } catch (err) {
5616
+ logger.debug(`Could not read version from package.json: ${err instanceof Error ? err.message : String(err)}`);
4909
5617
  return "nanos-lint v1.0.0";
4910
5618
  }
4911
5619
  }
5620
+ /** Writes command results to stdout unless the level is `silent`. */
5621
+ function writeOutput(message) {
5622
+ if (logger.isOutputEnabled()) console.log(message);
5623
+ }
4912
5624
  function collectIgnorePatterns(val, prev) {
4913
5625
  const parts = val.split(/[\r\n,]+/).map((s) => s.trim()).filter(Boolean);
4914
5626
  return (prev ?? []).concat(parts);
@@ -4916,9 +5628,20 @@ function collectIgnorePatterns(val, prev) {
4916
5628
  function createProgram(options) {
4917
5629
  const setExitCode = options?.setExitCode ?? (() => {});
4918
5630
  const program = new Command("nanos-lint");
4919
- program.description("Linter and type-checker for nanos world Lua scripts").version(getVersionString(), "-v, --version", "Show version information").exitOverride().configureOutput({
5631
+ program.description("Linter and type-checker for nanos world Lua scripts").version(getVersionString(), "-v, --version", "Show version information").addOption(new Option("-l, --log-level <level>", "Logging level: error, warn, info, debug, silent").choices([
5632
+ "error",
5633
+ "warn",
5634
+ "info",
5635
+ "debug",
5636
+ "silent"
5637
+ ]).default(DEFAULT_LOG_LEVEL)).hook("preAction", (thisCommand, actionCommand) => {
5638
+ const target = actionCommand || thisCommand;
5639
+ const opts = target.optsWithGlobals ? target.optsWithGlobals() : target.opts();
5640
+ if (opts.quiet) logger.setLevel("error");
5641
+ else if (opts.logLevel && isValidLogLevel(opts.logLevel)) logger.setLevel(opts.logLevel);
5642
+ }).exitOverride().configureOutput({
4920
5643
  writeOut: (str) => console.log(str.trimEnd()),
4921
- writeErr: (str) => console.error(str.trimEnd())
5644
+ writeErr: (str) => logger.error(str.trimEnd())
4922
5645
  });
4923
5646
  program.command("check [path]", { isDefault: true }).description("Check a workspace or Lua file (default command)").addOption(new Option("--checklevel <level>", "Minimum diagnostic level: Error, Warning, Information, Hint").choices([
4924
5647
  "Error",
@@ -4929,7 +5652,15 @@ function createProgram(options) {
4929
5652
  "pretty",
4930
5653
  "json",
4931
5654
  "github"
4932
- ])).option("-i, --ignore <pattern>", "Files or directories to ignore (supports glob patterns, repeatable)", collectIgnorePatterns).option("--luals-version <ver>", `Version of LuaLS to use (default: ${DEFAULT_LUALS_VERSION})`, DEFAULT_LUALS_VERSION).option("--no-fail", "Do not exit with code 1 if diagnostics are found").option("--quiet", "Suppress progress output").option("--github", "Output in GitHub Actions format (shortcut for --format=github)").action(async (targetPath = ".", opts) => {
5655
+ ])).option("-i, --ignore <pattern>", "Files or directories to ignore (supports glob patterns, repeatable)", collectIgnorePatterns).option("--luals-version <ver>", `Version of LuaLS to use (default: ${DEFAULT_LUALS_VERSION})`, DEFAULT_LUALS_VERSION).option("--no-fail", "Do not exit with code 1 if diagnostics are found").option("--quiet", "Suppress progress output").addOption(new Option("-l, --log-level <level>", "Logging level: error, warn, info, debug, silent").choices([
5656
+ "error",
5657
+ "warn",
5658
+ "info",
5659
+ "debug",
5660
+ "silent"
5661
+ ])).option("--github", "Output in GitHub Actions format (shortcut for --format=github)").action(async (targetPath = ".", opts) => {
5662
+ if (opts.quiet) logger.setLevel("error");
5663
+ else if (opts.logLevel && isValidLogLevel(opts.logLevel)) logger.setLevel(opts.logLevel);
4933
5664
  const format = opts.github ? "github" : opts.format || (process.env.GITHUB_ACTIONS ? "github" : "pretty");
4934
5665
  const checkOptions = {
4935
5666
  path: targetPath,
@@ -4943,7 +5674,7 @@ function createProgram(options) {
4943
5674
  };
4944
5675
  if (opts.config) {
4945
5676
  const resolvedConfig = path.resolve(opts.config);
4946
- if (!fs.existsSync(resolvedConfig)) throw new Error(`Configuration file not found: ${resolvedConfig}`);
5677
+ if (!fs.existsSync(resolvedConfig)) throw new ConfigError(`Configuration file not found: ${resolvedConfig}`, "ERR_CONFIG_NOT_FOUND", "Verify the path passed to --config exists and is readable.");
4947
5678
  }
4948
5679
  const annotationsPath = await resolveAnnotations({
4949
5680
  customPath: opts.annotations,
@@ -4959,48 +5690,81 @@ function createProgram(options) {
4959
5690
  } finally {
4960
5691
  if (resolved.isTemp && fs.existsSync(resolved.configPath)) try {
4961
5692
  fs.unlinkSync(resolved.configPath);
4962
- } catch {}
5693
+ } catch (err) {
5694
+ logger.warn(`Failed to clean up temporary config file ${resolved.configPath}: ${err instanceof Error ? err.message : String(err)}`);
5695
+ }
4963
5696
  }
4964
5697
  const output = formatReport(result, checkOptions.format, process.cwd());
4965
- if (output) console.log(output);
5698
+ if (output) writeOutput(output);
4966
5699
  if (!result.passed && checkOptions.failOnError) setExitCode(1);
4967
5700
  else setExitCode(0);
4968
5701
  });
4969
5702
  program.command("init [path]").description("Scaffold a .luarc.json configuration in the workspace").option("-f, --force", "Overwrite existing .luarc.json configuration").option("--annotations <path>", "Path to custom annotations.lua file").action(async (targetPath = ".", opts) => {
4970
5703
  const annotationsPath = await resolveAnnotations({ customPath: opts.annotations });
4971
- const created = initWorkspace(path.resolve(targetPath), {
5704
+ writeOutput(`[init] Initialized nanos world LuaLS configuration: ${initWorkspace(path.resolve(targetPath), {
4972
5705
  force: opts.force,
4973
5706
  annotationsPath
5707
+ })}`);
5708
+ setExitCode(0);
5709
+ });
5710
+ program.command("warmup").alias("download").description("Pre-fetch and cache both LuaLS binary and annotations for offline execution").option("--luals-version <ver>", `Version of LuaLS to use (default: ${DEFAULT_LUALS_VERSION})`).option("--annotations <path>", "Path to custom annotations.lua file").option("-q, --quiet", "Suppress download progress logging").action(async (opts) => {
5711
+ writeOutput(`[warmup] LuaLS binary ready: ${await resolveLuaLSBinary(opts?.lualsVersion || "latest", { quiet: opts?.quiet })}`);
5712
+ const annotationsPath = await resolveAnnotations({
5713
+ customPath: opts?.annotations,
5714
+ quiet: opts?.quiet
4974
5715
  });
4975
- console.log(`[init] Initialized nanos world LuaLS configuration: ${created}`);
5716
+ const meta = readAnnotationsMetadata();
5717
+ writeOutput(`[warmup] nanos world annotations ready: ${annotationsPath}${meta?.commitId && meta.commitId !== "unknown" ? ` (commit ${meta.commitId.slice(0, 7)})` : ""}`);
5718
+ writeOutput("[warmup] Cache pre-warmed successfully. Ready for offline execution.");
4976
5719
  setExitCode(0);
4977
5720
  });
4978
5721
  program.command("download-luals [version]").description("Download and cache the LuaLS binary").option("--luals-version <ver>", `Version of LuaLS to use (default: ${DEFAULT_LUALS_VERSION})`).action(async (version, opts) => {
4979
5722
  const ver = version || opts?.lualsVersion || "latest";
4980
- console.log(`[luals] Downloading LuaLS ${ver}...`);
4981
- const bin = await resolveLuaLSBinary(ver);
4982
- console.log(`[luals] Ready at: ${bin}`);
5723
+ writeOutput(`[luals] Downloading LuaLS ${ver}...`);
5724
+ writeOutput(`[luals] Ready at: ${await resolveLuaLSBinary(ver)}`);
4983
5725
  setExitCode(0);
4984
5726
  });
4985
- program.command("clean-cache").alias("clean").description("Clear the nanos-lint cache").action(() => {
5727
+ const handleCleanCache = () => {
4986
5728
  try {
4987
5729
  const cleared = cleanCache();
4988
- if (cleared) console.log(`[cache] Cleared cache at: ${cleared}`);
4989
- else console.log(`[cache] Cache is already empty (${systemPaths.cache})`);
5730
+ if (cleared) writeOutput(`[cache] Cleared cache at: ${cleared}`);
5731
+ else writeOutput(`[cache] Cache is already empty (${systemPaths.cache})`);
4990
5732
  setExitCode(0);
4991
5733
  } catch (err) {
4992
- console.error(`[cache] Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`);
5734
+ logger.error(`[cache] Failed to clear cache: ${err instanceof Error ? err.message : String(err)}`);
4993
5735
  setExitCode(1);
4994
5736
  }
5737
+ };
5738
+ const handleCacheStatus = (opts) => {
5739
+ const report = getCacheStatus();
5740
+ if (opts?.json) writeOutput(JSON.stringify(report, null, 2));
5741
+ else writeOutput(formatCacheStatusPretty(report));
5742
+ setExitCode(0);
5743
+ };
5744
+ const cacheCmd = program.command("cache").description("Inspect or manage the nanos-lint cache").option("--json", "Output cache state in JSON format");
5745
+ cacheCmd.command("status", { isDefault: true }).alias("info").description("Show cache status, installed versions, and disk usage").option("--json", "Output cache state in JSON format").action((opts, cmd) => {
5746
+ const json = opts?.json || (cmd.parent ? Boolean(cmd.parent.opts()?.json) : false);
5747
+ handleCacheStatus({ json });
5748
+ });
5749
+ cacheCmd.command("clean").description("Clear the nanos-lint cache").action(() => {
5750
+ handleCleanCache();
5751
+ });
5752
+ program.command("cache-status").description("Show cache status, installed versions, and disk usage").option("--json", "Output cache state in JSON format").action((opts) => {
5753
+ handleCacheStatus(opts);
5754
+ });
5755
+ program.command("clean-cache").alias("clean").description("Clear the nanos-lint cache").action(() => {
5756
+ handleCleanCache();
4995
5757
  });
4996
5758
  program.command("version").description("Show version information").action(() => {
4997
- console.log(getVersionString());
5759
+ writeOutput(getVersionString());
4998
5760
  setExitCode(0);
4999
5761
  });
5000
5762
  program.helpCommand("help [command]", "Show this help message");
5001
5763
  program.addHelpText("after", `
5002
5764
  Examples:
5003
5765
  $ npx nanos-lint
5766
+ $ npx nanos-lint warmup
5767
+ $ npx nanos-lint cache status
5004
5768
  $ npx nanos-lint check ./my-package
5005
5769
  $ npx nanos-lint check . --checklevel=Error
5006
5770
  $ npx nanos-lint check . --ignore "myfolder/hello-*.lua"
@@ -5010,6 +5774,17 @@ Examples:
5010
5774
  return program;
5011
5775
  }
5012
5776
  async function runCLI(args = process.argv.slice(2)) {
5777
+ for (let i = 0; i < args.length; i++) {
5778
+ const arg = args[i];
5779
+ if (!arg) continue;
5780
+ if (arg === "-l" || arg === "--log-level") {
5781
+ const nextArg = args[i + 1];
5782
+ if (nextArg && isValidLogLevel(nextArg)) logger.setLevel(nextArg);
5783
+ } else if (arg.startsWith("--log-level=")) {
5784
+ const val = arg.split("=")[1];
5785
+ if (val && isValidLogLevel(val)) logger.setLevel(val);
5786
+ }
5787
+ }
5013
5788
  let exitCode = 0;
5014
5789
  const program = createProgram({ setExitCode: (code) => {
5015
5790
  exitCode = code;
@@ -5019,9 +5794,18 @@ async function runCLI(args = process.argv.slice(2)) {
5019
5794
  return exitCode;
5020
5795
  } catch (err) {
5021
5796
  if (err instanceof CommanderError) return err.exitCode;
5797
+ if (err instanceof NanosLintError) {
5798
+ logger.error(`error: ${err.message}`);
5799
+ if (err.remedy) logger.error(`hint: ${err.remedy}`);
5800
+ if ((logger.getLevel() === "debug" || process.env.DEBUG) && err.stack) {
5801
+ logger.error(err.stack);
5802
+ if (err.cause) logger.error(`cause: ${err.cause instanceof Error ? err.cause.stack || err.cause.message : String(err.cause)}`);
5803
+ }
5804
+ return 1;
5805
+ }
5022
5806
  const message = err instanceof Error ? err.message : String(err);
5023
- console.error(`error: ${message}`);
5024
- if (process.env.DEBUG && err instanceof Error && err.stack) console.error(err.stack);
5807
+ logger.error(`error: ${message}`);
5808
+ if ((logger.getLevel() === "debug" || process.env.DEBUG) && err instanceof Error && err.stack) logger.error(err.stack);
5025
5809
  return 1;
5026
5810
  }
5027
5811
  }
@@ -5031,7 +5815,8 @@ function isDirectExecution(importMetaUrl = import.meta.url, argv1 = process.argv
5031
5815
  try {
5032
5816
  if (urlStr.startsWith("file:")) return fileURLToPath(urlStr);
5033
5817
  return urlStr;
5034
- } catch {
5818
+ } catch (err) {
5819
+ logger.debug(`[cli] Failed to convert URL "${urlStr}" using fileURLToPath: ${err instanceof Error ? err.message : String(err)}`);
5035
5820
  return urlStr.replace(/^file:\/\/\/?/, "");
5036
5821
  }
5037
5822
  };
@@ -5043,7 +5828,8 @@ function isDirectExecution(importMetaUrl = import.meta.url, argv1 = process.argv
5043
5828
  const cliJsPath = path.join(moduleDir, "cli.js").toLowerCase();
5044
5829
  const cliTsPath = path.join(moduleDir, "cli.ts").toLowerCase();
5045
5830
  if (scriptPath === cliJsPath || scriptPath === cliTsPath) return true;
5046
- } catch {
5831
+ } catch (err) {
5832
+ logger.debug(`[cli] Failed to resolve realpath for entry detection: ${err instanceof Error ? err.message : String(err)}`);
5047
5833
  const normArgv = path.resolve(argv1).toLowerCase();
5048
5834
  const normMeta = toPath(importMetaUrl).toLowerCase();
5049
5835
  if (normArgv === normMeta) return true;
@@ -5055,10 +5841,10 @@ function isDirectExecution(importMetaUrl = import.meta.url, argv1 = process.argv
5055
5841
  if (isDirectExecution()) runCLI().then((code) => {
5056
5842
  process.exit(code);
5057
5843
  }).catch((err) => {
5058
- console.error(err);
5844
+ logger.error(err);
5059
5845
  process.exit(1);
5060
5846
  });
5061
5847
  //#endregion
5062
- export { stripTrailingSlashes as $, FALLBACK_LUALS_VERSION as A, resolveLuaLSVersion as B, formatProblemSummary as C, pluralize as D, getColors as E, getLegacyCacheDir as F, getDefinitionsDir as G, sanitizeLuaLSVersion as H, getPlatformInfo as I, loadConfigFile as J, getPackageRoot as K, isBinaryValid as L, downloadAndExtractLuaLS as M, escapePowerShellSingleQuote as N, shouldEnableColor as O, getCacheDir as P, stripJsonComments as Q, resolveLatestLuaLSVersion as R, formatPretty as S, formatSeverityBadge as T, getDefaultAnnotationsPath as U, runLuaLSCheck as V, getDefaultTemplatePath as W, parseJsonc as X, mergeConfigs as Y, resolveWorkspaceConfig as Z, getTodayDateString as _, ANNOTATIONS_FILENAME as a, updateLastCheckedDate as b, GITHUB_COMMITS_API as c, downloadAndCacheAnnotations as d, cleanCache as et, fetchLatestCommitId as f, getCachedAnnotationsFilePath as g, getAnnotationsMetadataFilePath as h, runCLI as i, countCheckedFiles as j, DEFAULT_LUALS_VERSION as k, METADATA_FILENAME as l, getAnnotationsCacheDir as m, createProgram as n, fileUriToPath as nt, DOCGEN_BRANCH as o, fetchRawAnnotationsContent as p, initWorkspace as q, isDirectExecution as r, DOCGEN_REPO as s, collectIgnorePatterns as t, systemPaths as tt, RAW_ANNOTATIONS_URL as u, readAnnotationsMetadata as v, formatReport as w, formatGitHubAnnotations as x, resolveAnnotations as y, resolveLuaLSBinary as z };
5848
+ export { fetchLatestLuaLSVersionFromGitHub as $, pluralize as A, logger as At, getBaseLuaLSCacheDir as B, updateLastCheckedDate as C, systemPaths as Ct, formatReport as D, LOG_LEVEL_PRIORITY as Dt, formatProblemSummary as E, LOG_LEVELS as Et, downloadAndExtractLuaLS as F, listCachedLuaLSVersionDirs as G, getIsoWeek as H, escapePowerShellSingleQuote as I, writeLuaLSMetadata as J, listCachedLuaLSVersions as K, LUALS_METADATA_FILENAME as L, resolveLuaLSBinary as M, runLuaLSCheck as N, formatSeverityBadge as O, Logger as Ot, countCheckedFiles as P, FALLBACK_LUALS_VERSION as Q, cleanupOldCachedLuaLSVersions as R, resolveAnnotations as S, getDirectorySize as St, formatPretty as T, DEFAULT_LOG_LEVEL as Tt, getLegacyCacheDir as U, getCacheDir as V, getLuaLSMetadataPath as W, getPlatformInfo as X, isBinaryValid as Y, DEFAULT_LUALS_VERSION as Z, getAnnotationsMetadataFilePath as _, ConfigError as _t, formatCacheStatusPretty as a, getDefaultTemplatePath as at, isAnnotationsValid as b, cleanCache as bt, DOCGEN_BRANCH as c, initWorkspace as ct, METADATA_FILENAME as d, parseJsonc as dt, resolveLatestLuaLSVersion as et, RAW_ANNOTATIONS_URL as f, resolveWorkspaceConfig as ft, getAnnotationsCacheDir as g, CacheError as gt, fetchRawAnnotationsContent as h, AnnotationsError as ht, runCLI as i, getDefaultAnnotationsPath as it, shouldEnableColor as j, parseLogLevel as jt, getColors as k, isValidLogLevel as kt, DOCGEN_REPO as l, loadConfigFile as lt, fetchLatestCommitId as m, stripTrailingSlashes as mt, createProgram as n, sanitizeLuaLSVersion as nt, getCacheStatus as o, getDefinitionsDir as ot, downloadAndCacheAnnotations as p, stripJsonComments as pt, readLuaLSMetadata as q, isDirectExecution as r, VALID_LUALS_DIAGNOSTIC_CODES as rt, ANNOTATIONS_FILENAME as s, getPackageRoot as st, collectIgnorePatterns as t, resolveLuaLSVersion as tt, GITHUB_COMMITS_API as u, mergeConfigs as ut, getCachedAnnotationsFilePath as v, LuaLSError as vt, formatGitHubAnnotations as w, fileUriToPath as wt, readAnnotationsMetadata as x, formatBytes as xt, getTodayDateString as y, NanosLintError as yt, findExistingLuaLSDir as z };
5063
5849
 
5064
- //# sourceMappingURL=cli-5LLXRlN7.js.map
5850
+ //# sourceMappingURL=cli-DGez8MSI.js.map