nanos-lint 2.8.1 → 2.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,8 @@ import path, { posix, win32 } from "node:path";
5
5
  import os from "node:os";
6
6
  import process$1 from "node:process";
7
7
  import childProcess, { execFile, execFileSync } from "node:child_process";
8
- import crypto from "node:crypto";
9
8
  import { promisify, stripVTControlCharacters } from "node:util";
9
+ import crypto from "node:crypto";
10
10
  import Pe, { Readable } from "node:stream";
11
11
  import { pipeline } from "node:stream/promises";
12
12
  import { lstatSync, readdir, readdirSync, readlinkSync, realpathSync } from "fs";
@@ -29,9 +29,11 @@ const LOG_LEVEL_PRIORITY = {
29
29
  debug: 4
30
30
  };
31
31
  const DEFAULT_LOG_LEVEL = "warn";
32
+ /** Validates whether a string corresponds to a recognized log level. */
32
33
  function isValidLogLevel(level) {
33
34
  return level in LOG_LEVEL_PRIORITY;
34
35
  }
36
+ /** Parses a string into a valid LogLevel, defaulting to 'warn'. */
35
37
  function parseLogLevel(raw) {
36
38
  if (!raw) return DEFAULT_LOG_LEVEL;
37
39
  const lower = raw.trim().toLowerCase();
@@ -46,12 +48,15 @@ var Logger = class {
46
48
  if (envLevel && isValidLogLevel(envLevel.trim().toLowerCase())) this.level = envLevel.trim().toLowerCase();
47
49
  }
48
50
  }
51
+ /** Sets the active minimum logging level. */
49
52
  setLevel(level) {
50
53
  if (isValidLogLevel(level)) this.level = level;
51
54
  }
55
+ /** Returns the current active log level. */
52
56
  getLevel() {
53
57
  return this.level;
54
58
  }
59
+ /** Checks if a message at the specified level should be emitted. */
55
60
  isEnabledFor(level) {
56
61
  return LOG_LEVEL_PRIORITY[this.level] >= LOG_LEVEL_PRIORITY[level];
57
62
  }
@@ -59,15 +64,19 @@ var Logger = class {
59
64
  isOutputEnabled() {
60
65
  return this.level !== "silent";
61
66
  }
67
+ /** Logs an error-level message to stderr. */
62
68
  error(...args) {
63
69
  if (this.isEnabledFor("error")) console.error(...args);
64
70
  }
71
+ /** Logs a warning-level message to stderr. */
65
72
  warn(...args) {
66
73
  if (this.isEnabledFor("warn")) console.warn(...args);
67
74
  }
75
+ /** Logs an informational message to stdout. */
68
76
  info(...args) {
69
77
  if (this.isEnabledFor("info")) console.log(...args);
70
78
  }
79
+ /** Logs a debug-level diagnostic message to stdout. */
71
80
  debug(...args) {
72
81
  if (this.isEnabledFor("debug")) console.debug(...args);
73
82
  }
@@ -1022,6 +1031,7 @@ function getPackageRoot() {
1022
1031
  }
1023
1032
  return path.resolve(__dirname, "..");
1024
1033
  }
1034
+ /** Resolves the default annotations path from environment variable, bundled file, or user cache. */
1025
1035
  function getDefaultAnnotationsPath() {
1026
1036
  const envPath = process.env.NANOS_ANNOTATIONS_PATH || process.env.NANOS_ANNOTATIONS;
1027
1037
  if (envPath && fs.existsSync(envPath)) return path.resolve(envPath);
@@ -1036,6 +1046,7 @@ function getDefaultAnnotationsPath() {
1036
1046
  function getDefinitionsDir() {
1037
1047
  return path.dirname(getDefaultAnnotationsPath());
1038
1048
  }
1049
+ /** Resolves the absolute path to the default .luarc.json template shipped with the package. */
1039
1050
  function getDefaultTemplatePath() {
1040
1051
  const root = getPackageRoot();
1041
1052
  return path.join(root, "templates", ".luarc.json");
@@ -1047,6 +1058,7 @@ function stripJsonComments(text) {
1047
1058
  const cleanText = text.replace(/^\uFEFF/, "");
1048
1059
  return stripComments(cleanText);
1049
1060
  }
1061
+ /** Parses JSONC text with support for comments and trailing commas. */
1050
1062
  function parseJsonc(text) {
1051
1063
  const cleanText = text.replace(/^\uFEFF/, "");
1052
1064
  const errors = [];
@@ -1057,6 +1069,7 @@ function parseJsonc(text) {
1057
1069
  }
1058
1070
  return result;
1059
1071
  }
1072
+ /** Reads and parses a .luarc.json configuration file from disk. */
1060
1073
  function loadConfigFile(filePath) {
1061
1074
  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.");
1062
1075
  const content = fs.readFileSync(filePath, "utf-8");
@@ -1352,25 +1365,12 @@ function initWorkspace(workspacePath, options) {
1352
1365
  //#region src/luals/version.ts
1353
1366
  const FALLBACK_LUALS_VERSION = "3.19.1";
1354
1367
  const DEFAULT_LUALS_VERSION = "latest";
1355
- /**
1356
- * Characters accepted in a LuaLS version/tag. Only ASCII letters, digits, dots,
1357
- * dashes and underscores are allowed, so a version can never contain a path
1358
- * separator, a drive letter or a traversal segment.
1359
- */
1368
+ /** Characters accepted in a safe LuaLS version tag. */
1360
1369
  const SAFE_VERSION_CHARS = new Map([..."0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._-"].map((ch) => [ch, ch]));
1361
1370
  const MAX_VERSION_LENGTH = 64;
1362
1371
  /**
1363
- * Validates a LuaLS version/tag and rebuilds it from the allow-list above.
1364
- *
1365
- * Version strings originate from untrusted sources: the GitHub releases API
1366
- * response and user supplied `--luals-version` arguments. They are interpolated
1367
- * into cache directory paths, download URLs, and the path of the binary that is
1368
- * eventually executed, so they must be constrained to a single safe path
1369
- * segment. Rebuilding the value character by character guarantees the returned
1370
- * string only ever contains allow-listed characters (CodeQL: js/command-line-injection).
1371
- *
1372
- * @returns the normalized version (a single leading `v` is dropped), or `null`
1373
- * when the input cannot be used as a version tag.
1372
+ * Validates and normalizes a LuaLS version string to prevent path traversal or injection.
1373
+ * Drops a single leading 'v' and returns null on invalid characters.
1374
1374
  */
1375
1375
  function sanitizeLuaLSVersion(raw) {
1376
1376
  const trimmed = raw.trim();
@@ -1427,6 +1427,7 @@ async function resolveLuaLSVersion(version) {
1427
1427
  }
1428
1428
  //#endregion
1429
1429
  //#region src/luals/platform.ts
1430
+ /** Resolves platform-specific binary naming and relative paths for the host operating system. */
1430
1431
  function getPlatformInfo(version = FALLBACK_LUALS_VERSION) {
1431
1432
  const platform = process.platform;
1432
1433
  const arch = process.arch;
@@ -1464,6 +1465,158 @@ function getPlatformInfo(version = FALLBACK_LUALS_VERSION) {
1464
1465
  }
1465
1466
  //#endregion
1466
1467
  //#region src/luals/validation.ts
1468
+ const execFileAsync$2 = promisify(execFile);
1469
+ /**
1470
+ * Escapes single quotes for safe PowerShell single-quoted string interpolation.
1471
+ */
1472
+ function escapePowerShellSingleQuote(str) {
1473
+ return str.replace(/'/g, "''");
1474
+ }
1475
+ /**
1476
+ * Maximum total decompressed size of a LuaLS release archive (500 MB).
1477
+ * Protects against zip bombs and maliciously inflated archives (#31).
1478
+ */
1479
+ const MAX_DECOMPRESSED_SIZE_BYTES = 524288e3;
1480
+ /**
1481
+ * Maximum number of members allowed in a LuaLS release archive (10,000).
1482
+ * Protects against inode exhaustion and archive recursion attacks (#31).
1483
+ */
1484
+ const MAX_ARCHIVE_MEMBER_COUNT = 1e4;
1485
+ /** Parses the file size column from a 'tar -tvf' listing output line. */
1486
+ function parseTarTvSize(line) {
1487
+ const parts = line.trim().split(/\s+/);
1488
+ if (parts.length < 5) return 0;
1489
+ const p1 = parts[1];
1490
+ const p2 = parts[2];
1491
+ const p4 = parts[4];
1492
+ if (p1 && p2 && p1.includes("/") && /^\d+$/.test(p2)) return parseInt(p2, 10) || 0;
1493
+ if (p4 && /^\d+$/.test(p4)) return parseInt(p4, 10) || 0;
1494
+ for (let i = 2; i < Math.min(parts.length - 1, 6); i++) {
1495
+ const p = parts[i];
1496
+ if (p && /^\d+$/.test(p)) return parseInt(p, 10) || 0;
1497
+ }
1498
+ return 0;
1499
+ }
1500
+ /** Asserts that an archive entry name does not escape the destination directory. */
1501
+ function checkEscapedMember(member) {
1502
+ if (member.startsWith("/") || member.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(member) || member.split(/[/\\]/).includes("..")) throw new LuaLSError(`Archive member path escapes extraction directory: ${member}`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1503
+ }
1504
+ /** Validates that archive entry count and decompressed size do not exceed safety limits. */
1505
+ function checkArchiveLimits(count, size) {
1506
+ if (count > 1e4) throw new LuaLSError(`Archive member count (${count}) exceeds maximum limit (${MAX_ARCHIVE_MEMBER_COUNT})`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1507
+ if (size > 524288e3) throw new LuaLSError(`Archive declared decompressed size (${size} bytes) exceeds maximum limit (${MAX_DECOMPRESSED_SIZE_BYTES} bytes)`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and ensure there is sufficient disk space.");
1508
+ }
1509
+ /** Locates the platform tar executable path, checking System32 on Windows. */
1510
+ function getTarBinary() {
1511
+ if (process.platform === "win32") {
1512
+ const sysTar = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "tar.exe");
1513
+ if (fs.existsSync(sysTar)) return sysTar;
1514
+ }
1515
+ return "tar";
1516
+ }
1517
+ /** Pre-inspects a ZIP archive's central directory to enforce extraction safety constraints (#31). */
1518
+ function inspectZipMembers(archivePath) {
1519
+ const buf = fs.readFileSync(archivePath);
1520
+ if (buf.length < 22) throw new LuaLSError("Failed to inspect release archive before extraction: archive is too small to be a valid zip", "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1521
+ let eocdOffset = -1;
1522
+ const minOffset = Math.max(0, buf.length - 22 - 65535);
1523
+ for (let i = buf.length - 22; i >= minOffset; i--) if (buf.readUInt32LE(i) === 101010256) {
1524
+ const commentLen = buf.readUInt16LE(i + 20);
1525
+ if (i + 22 + commentLen === buf.length) {
1526
+ eocdOffset = i;
1527
+ break;
1528
+ }
1529
+ }
1530
+ if (eocdOffset === -1) {
1531
+ for (let i = buf.length - 22; i >= minOffset; i--) if (buf.readUInt32LE(i) === 101010256) {
1532
+ eocdOffset = i;
1533
+ break;
1534
+ }
1535
+ }
1536
+ if (eocdOffset === -1) throw new LuaLSError("Failed to inspect release archive before extraction: corrupt or invalid zip archive (missing EOCD)", "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1537
+ const entryCount = buf.readUInt16LE(eocdOffset + 10);
1538
+ let offset = buf.readUInt32LE(eocdOffset + 16);
1539
+ let memberCount = 0;
1540
+ let totalDeclaredSize = 0;
1541
+ for (let i = 0; i < entryCount && offset + 46 <= buf.length; i++) {
1542
+ if (buf.readUInt32LE(offset) !== 33639248) throw new LuaLSError("Failed to inspect release archive before extraction: invalid central directory header", "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1543
+ const uncompressedSize = buf.readUInt32LE(offset + 24);
1544
+ const fileNameLen = buf.readUInt16LE(offset + 28);
1545
+ const extraLen = buf.readUInt16LE(offset + 30);
1546
+ const commentLen = buf.readUInt16LE(offset + 32);
1547
+ if ((buf.readUInt32LE(offset + 38) >>> 16 & 61440) === 40960) throw new LuaLSError("Archive member is a symbolic or hard link. Refusing to extract archive-planted links.", "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1548
+ const fileNameStart = offset + 46;
1549
+ const fileNameEnd = fileNameStart + fileNameLen;
1550
+ if (fileNameEnd > buf.length) throw new LuaLSError("Failed to inspect release archive before extraction: corrupt file name length in zip", "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1551
+ const fileName = buf.toString("utf-8", fileNameStart, fileNameEnd);
1552
+ memberCount++;
1553
+ totalDeclaredSize += uncompressedSize;
1554
+ checkEscapedMember(fileName);
1555
+ offset += 46 + fileNameLen + extraLen + commentLen;
1556
+ }
1557
+ checkArchiveLimits(memberCount, totalDeclaredSize);
1558
+ return {
1559
+ memberCount,
1560
+ totalDeclaredSize
1561
+ };
1562
+ }
1563
+ /** Detects whether an archive file starts with ZIP magic bytes. */
1564
+ function isZipArchive(archivePath) {
1565
+ try {
1566
+ const fd = fs.openSync(archivePath, "r");
1567
+ const buf = Buffer.alloc(4);
1568
+ const bytesRead = fs.readSync(fd, buf, 0, 4, 0);
1569
+ fs.closeSync(fd);
1570
+ if (bytesRead < 4) return false;
1571
+ return buf[0] === 80 && buf[1] === 75 && (buf[2] === 3 && buf[3] === 4 || buf[2] === 5 && buf[3] === 6 || buf[2] === 7 && buf[3] === 8);
1572
+ } catch {
1573
+ return false;
1574
+ }
1575
+ }
1576
+ /** Detects whether an archive file starts with GZIP magic bytes. */
1577
+ function isGzipArchive(archivePath) {
1578
+ try {
1579
+ const fd = fs.openSync(archivePath, "r");
1580
+ const buf = Buffer.alloc(2);
1581
+ const bytesRead = fs.readSync(fd, buf, 0, 2, 0);
1582
+ fs.closeSync(fd);
1583
+ return bytesRead >= 2 && buf[0] === 31 && buf[1] === 139;
1584
+ } catch {
1585
+ return false;
1586
+ }
1587
+ }
1588
+ /** Pre-inspects archive members using tar listing or zip directory parsing to enforce extraction safety constraints (#31). */
1589
+ async function validateArchiveMembers(archivePath) {
1590
+ if (isZipArchive(archivePath) || archivePath.toLowerCase().endsWith(".zip") && !isGzipArchive(archivePath)) return inspectZipMembers(archivePath);
1591
+ let memberCount = 0;
1592
+ let totalDeclaredSize = 0;
1593
+ const tarBin = getTarBinary();
1594
+ const archiveDir = path.dirname(archivePath);
1595
+ const archiveFile = path.basename(archivePath);
1596
+ try {
1597
+ const [namesOutput, verboseOutput] = await Promise.all([execFileAsync$2(tarBin, ["-tf", archiveFile], { cwd: archiveDir }), execFileAsync$2(tarBin, ["-tvf", archiveFile], { cwd: archiveDir })]);
1598
+ for (const rawName of namesOutput.stdout.split(/\r?\n/)) {
1599
+ const member = rawName.trim();
1600
+ if (!member) continue;
1601
+ memberCount++;
1602
+ checkEscapedMember(member);
1603
+ }
1604
+ for (const rawLine of verboseOutput.stdout.split(/\r?\n/)) {
1605
+ const line = rawLine.trim();
1606
+ if (!line) continue;
1607
+ if (/^[lh]/.test(line) || line.includes(" -> ") || line.includes(" link to ")) throw new LuaLSError("Archive member is a symbolic or hard link. Refusing to extract archive-planted links.", "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and verify the LuaLS release integrity.");
1608
+ totalDeclaredSize += parseTarTvSize(line);
1609
+ }
1610
+ checkArchiveLimits(memberCount, totalDeclaredSize);
1611
+ } catch (err) {
1612
+ if (err instanceof LuaLSError) throw err;
1613
+ throw new LuaLSError(`Failed to inspect release archive before extraction: ${err instanceof Error ? err.message : String(err)}`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and ensure there is sufficient disk space.", { cause: err });
1614
+ }
1615
+ return {
1616
+ memberCount,
1617
+ totalDeclaredSize
1618
+ };
1619
+ }
1467
1620
  /**
1468
1621
  * Minimum plausible size of a downloaded LuaLS executable. Release assets are
1469
1622
  * several megabytes, so anything smaller is a truncated download or a stray
@@ -1545,6 +1698,7 @@ function assertValidLuaLSBinary(binaryPath, source) {
1545
1698
  }
1546
1699
  //#endregion
1547
1700
  //#region src/luals/cache.ts
1701
+ /** Returns the base directory in the system cache where LuaLS versions and metadata are stored. */
1548
1702
  function getBaseLuaLSCacheDir() {
1549
1703
  return path.join(systemPaths.cache, "luals");
1550
1704
  }
@@ -1574,9 +1728,11 @@ function getIsoWeek(d = /* @__PURE__ */ new Date()) {
1574
1728
  const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
1575
1729
  return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
1576
1730
  }
1731
+ /** Returns the path to the LuaLS metadata.json file within the specified cache directory. */
1577
1732
  function getLuaLSMetadataPath(baseCacheDir = getBaseLuaLSCacheDir()) {
1578
1733
  return path.join(baseCacheDir, LUALS_METADATA_FILENAME);
1579
1734
  }
1735
+ /** Reads and parses the LuaLS metadata.json file, purging corrupted or invalid files automatically. */
1580
1736
  function readLuaLSMetadata(baseCacheDir = getBaseLuaLSCacheDir()) {
1581
1737
  const metaPath = getLuaLSMetadataPath(baseCacheDir);
1582
1738
  if (!fs.existsSync(metaPath)) return null;
@@ -1606,6 +1762,7 @@ function readLuaLSMetadata(baseCacheDir = getBaseLuaLSCacheDir()) {
1606
1762
  }
1607
1763
  return null;
1608
1764
  }
1765
+ /** Writes metadata.json containing the latest checked version and timestamp. */
1609
1766
  function writeLuaLSMetadata(metadata, baseCacheDir = getBaseLuaLSCacheDir()) {
1610
1767
  try {
1611
1768
  fs.mkdirSync(baseCacheDir, { recursive: true });
@@ -1768,12 +1925,6 @@ function computeFileSha256(filePath) {
1768
1925
  }
1769
1926
  const execFileAsync$1 = promisify(execFile);
1770
1927
  /**
1771
- * Escapes single quotes for safe PowerShell single-quoted string interpolation.
1772
- */
1773
- function escapePowerShellSingleQuote(str) {
1774
- return str.replace(/'/g, "''");
1775
- }
1776
- /**
1777
1928
  * Enforces a maximum byte count on an asynchronous download stream.
1778
1929
  */
1779
1930
  async function* limitDownloadStream(source) {
@@ -1784,6 +1935,7 @@ async function* limitDownloadStream(source) {
1784
1935
  yield chunk;
1785
1936
  }
1786
1937
  }
1938
+ /** Downloads, verifies, and extracts the LuaLS release archive for the current platform. */
1787
1939
  async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDir, options) {
1788
1940
  const resolvedVersion = await resolveLuaLSVersion(version);
1789
1941
  const info = getPlatformInfo(resolvedVersion);
@@ -1866,21 +2018,22 @@ async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDi
1866
2018
  const archiveSha256 = computeFileSha256(archivePath);
1867
2019
  logger.info(`[luals] Downloaded ${info.assetName} (SHA-256 ${archiveSha256})`);
1868
2020
  if (!options?.quiet) logger.info(`[luals] Extracting to ${destDir}...`);
2021
+ await validateArchiveMembers(archivePath);
1869
2022
  try {
1870
2023
  const tarArgs = process.platform === "win32" ? [
1871
2024
  "-xf",
1872
- archivePath,
2025
+ path.basename(archivePath),
1873
2026
  "-C",
1874
2027
  tempDir
1875
2028
  ] : [
1876
2029
  "-xf",
1877
- archivePath,
2030
+ path.basename(archivePath),
1878
2031
  "--no-same-owner",
1879
2032
  "--no-same-permissions",
1880
2033
  "-C",
1881
2034
  tempDir
1882
2035
  ];
1883
- await execFileAsync$1("tar", tarArgs);
2036
+ await execFileAsync$1(getTarBinary(), tarArgs, { cwd: path.dirname(archivePath) });
1884
2037
  } catch (tarErr) {
1885
2038
  if (process.platform === "win32" && info.assetName.endsWith(".zip")) await execFileAsync$1("powershell.exe", [
1886
2039
  "-NoProfile",
@@ -1889,6 +2042,8 @@ async function downloadAndExtractLuaLS(version = DEFAULT_LUALS_VERSION, targetDi
1889
2042
  ]);
1890
2043
  else throw tarErr;
1891
2044
  }
2045
+ const extractedSize = getDirectorySize(tempDir);
2046
+ if (extractedSize > 524288e3) throw new LuaLSError(`Extracted archive size (${extractedSize} bytes) exceeds maximum limit (${MAX_DECOMPRESSED_SIZE_BYTES} bytes)`, "ERR_LUALS_EXTRACT", "Run 'nanos-lint clean-cache' and ensure there is sufficient disk space.");
1892
2047
  try {
1893
2048
  fs.unlinkSync(archivePath);
1894
2049
  } catch (err) {
@@ -5256,21 +5411,8 @@ const LUA_FILE_PATTERN = "**/*.lua";
5256
5411
  /** `glob` rejects patterns longer than this, so they are dropped before matching. */
5257
5412
  const MAX_PATTERN_LENGTH = 65536;
5258
5413
  /**
5259
- * Complexity budget per pattern. `minimatch` evaluates patterns through an AST
5260
- * but still compiles them to regular expressions, so ambiguous wildcards inside
5261
- * one path segment can backtrack, and brace groups expand combinatorially
5262
- * (`{a,b}` repeated 16 times is 65 536 alternatives). A hostile or accidental
5263
- * `.luarc.json` could otherwise stall the file walk, so patterns beyond the
5264
- * budget are skipped with a warning instead of being matched.
5265
- *
5266
- * The ambiguity of one segment grows like `C(segment length, wildcards)`, so the
5267
- * per-segment cap is what keeps matching cheap. `**\/*a*a*a*a\/*a*a*a*a\/*a*a*a*a\/*a*a*a*z.lua`
5268
- * (4 wildcards per segment) stays within a 4-wildcard cap yet costs ~5 ms per
5269
- * candidate file — ~100 s on a 20 000-file tree — while two wildcards per
5270
- * segment cost `C(60, 2) = 1 770` splits, hundreds of times less. The budget is
5271
- * deliberately far below what minimatch can parse: every realistic pattern
5272
- * (`**\/*.{bak,tmp}`, `**\/node_modules\/**`, `**\/item-[0-9].lua`,
5273
- * `**\/*.min.*`) needs at most two wildcards in any one segment.
5414
+ * Complexity budget per pattern to bound backtracking and combinatorial brace expansion.
5415
+ * Patterns exceeding limits are skipped with a warning to protect against ReDoS (#27).
5274
5416
  */
5275
5417
  const MAX_WILDCARDS_PER_SEGMENT = 2;
5276
5418
  const MAX_TOTAL_WILDCARDS = 12;
@@ -5303,15 +5445,7 @@ function isPatternWithinBudget(pattern) {
5303
5445
  if (countWildcards(pattern) > MAX_TOTAL_WILDCARDS) return false;
5304
5446
  return pattern.split("/").every((segment) => countWildcards(segment) <= MAX_WILDCARDS_PER_SEGMENT);
5305
5447
  }
5306
- /**
5307
- * Normalizes one user-supplied pattern (`workspace.ignoreDir`, `files.exclude`
5308
- * or a built-in default) into the slash-separated form `glob` expects.
5309
- * Backslashes are accepted as separators so Windows-authored `.luarc.json`
5310
- * files keep working, and trailing slashes are dropped so `vendor/` behaves
5311
- * exactly like `vendor`. Patterns that cannot be matched (`""`, `"."`, NUL
5312
- * bytes, oversized input, absolute paths, or non-string JSON values) are
5313
- * rejected instead of being handed to `glob`.
5314
- */
5448
+ /** Normalizes a user pattern to slash-separated form, dropping trailing slashes and invalid inputs. */
5315
5449
  function normalizePattern(pattern) {
5316
5450
  if (typeof pattern !== "string") return null;
5317
5451
  const normalized = pattern.trim().replace(/\\/g, "/").replace(/\/+$/, "");
@@ -5320,27 +5454,11 @@ function normalizePattern(pattern) {
5320
5454
  }
5321
5455
  /** Drive-letter prefixes, recognized on every platform (not just Windows). */
5322
5456
  const WINDOWS_ABSOLUTE_PATTERN = /^[A-Za-z]:\//;
5323
- /**
5324
- * `workspace.ignoreDir` / `files.exclude` entries are workspace-relative in
5325
- * LuaLS, and `glob` matches ignore patterns against walk-relative paths. An
5326
- * absolute entry is therefore unusable, and `glob` anchors it on some platforms
5327
- * but not others (a macOS `/var` -> `/private/var` walk root or a Windows drive
5328
- * letter behave differently again), so it is skipped to keep one `.luarc.json`
5329
- * behaving identically on Linux, macOS and Windows.
5330
- */
5457
+ /** Rejects absolute patterns to maintain cross-platform consistency with LuaLS relative paths. */
5331
5458
  function isAbsolutePattern(normalized) {
5332
5459
  return path.posix.isAbsolute(normalized) || WINDOWS_ABSOLUTE_PATTERN.test(normalized);
5333
5460
  }
5334
- /**
5335
- * Expands one LuaLS pattern into the equivalent `glob` ignore patterns:
5336
- *
5337
- * - basename-only patterns (`*.lua`, `vendor`) match at any depth, so the
5338
- * `**\/`-prefixed variants are added (mirrors LuaLS `matchBase` semantics);
5339
- * - a pattern also excludes everything below a matching directory, which is
5340
- * what the trailing `/**` variant expresses;
5341
- * - the pattern itself is always kept so an exact file or directory match
5342
- * works as written.
5343
- */
5461
+ /** Expands a LuaLS pattern to include basename and sub-directory variants for glob matching. */
5344
5462
  function expandIgnorePattern(pattern) {
5345
5463
  return pattern.includes("/") ? [pattern, `${pattern}/**`] : [`**/${pattern}`, `**/${pattern}/**`];
5346
5464
  }
@@ -5361,17 +5479,7 @@ function toIgnorePatterns(patterns) {
5361
5479
  }
5362
5480
  return ignore;
5363
5481
  }
5364
- /**
5365
- * Counts candidate Lua files within targetPath, taking `workspace.ignoreDir`
5366
- * and `files.exclude` into account.
5367
- *
5368
- * Traversal and matching are delegated to the bundled `glob` package, whose
5369
- * `minimatch` matcher understands the full glob syntax LuaLS accepts (brace
5370
- * expansion, character classes, `**`, `?`) instead of the hand-rolled
5371
- * glob-to-regex translation used before. Combined with the pattern budgets
5372
- * above, this keeps the count predictable for adversarial configurations
5373
- * while remaining consistent with LuaLS exclude semantics (#27).
5374
- */
5482
+ /** Counts candidate Lua files within targetPath, taking workspace ignoreDir and files.exclude into account (#27). */
5375
5483
  function countCheckedFiles(targetPath, configPath) {
5376
5484
  let absPath = path.resolve(targetPath);
5377
5485
  if (!fs.existsSync(absPath)) return 0;
@@ -5410,11 +5518,13 @@ function countCheckedFiles(targetPath, configPath) {
5410
5518
  //#endregion
5411
5519
  //#region src/luals/runner.ts
5412
5520
  const execFileAsync = promisify(execFile);
5521
+ /** Determines if an error represents an offline network condition. */
5413
5522
  function isOfflineError(err) {
5414
5523
  if (!err) return false;
5415
5524
  const msg = err instanceof Error ? err.message : String(err);
5416
5525
  return /offline|enotfound|eai_again|econnrefused|etimedout|fetch failed/i.test(msg);
5417
5526
  }
5527
+ /** Resolves the executable path of a LuaLS binary from env, bundle, cache, or network download. */
5418
5528
  async function resolveLuaLSBinary(version = DEFAULT_LUALS_VERSION, options) {
5419
5529
  if (process.env.LUALS_BIN) return assertValidLuaLSBinary(process.env.LUALS_BIN, "LUALS_BIN");
5420
5530
  if (options?.reuseExisting !== false && (!version || version === "latest")) {
@@ -5572,6 +5682,7 @@ async function resolveLuaLSBinary(version = DEFAULT_LUALS_VERSION, options) {
5572
5682
  cleanupOldCachedLuaLSVersions(targetVersion, baseCacheDir);
5573
5683
  return downloadedBinary;
5574
5684
  }
5685
+ /** Executes the LuaLS diagnostics check on the target path using merged configurations. */
5575
5686
  async function runLuaLSCheck(targetPath, configPath, options) {
5576
5687
  let absoluteTarget = path.resolve(targetPath);
5577
5688
  try {
@@ -5680,6 +5791,7 @@ function shouldEnableColor() {
5680
5791
  if (process.env.FORCE_COLOR !== void 0 && process.env.FORCE_COLOR !== "0") return true;
5681
5792
  return process.stdout ? Boolean(process.stdout.isTTY) : true;
5682
5793
  }
5794
+ /** Returns ANSI color escape code helpers or empty strings when colors are disabled. */
5683
5795
  function getColors(useColor = shouldEnableColor()) {
5684
5796
  if (!useColor) return {
5685
5797
  reset: "",
@@ -5706,6 +5818,7 @@ function getColors(useColor = shouldEnableColor()) {
5706
5818
  gray: "\x1B[90m"
5707
5819
  };
5708
5820
  }
5821
+ /** Formats a numeric diagnostic severity level into a colored badge. */
5709
5822
  function formatSeverityBadge(severity, useColor = shouldEnableColor()) {
5710
5823
  const c = getColors(useColor);
5711
5824
  const name = SEVERITY_NAMES[severity] || "Warning";
@@ -5716,9 +5829,11 @@ function formatSeverityBadge(severity, useColor = shouldEnableColor()) {
5716
5829
  default: return `${c.gray}[${name}]${c.reset}`;
5717
5830
  }
5718
5831
  }
5832
+ /** Formats a noun with its count and pluralizes if necessary. */
5719
5833
  function pluralize(count, singular, plural = `${singular}s`) {
5720
5834
  return `${count} ${count === 1 ? singular : plural}`;
5721
5835
  }
5836
+ /** Formats the summary text detailing diagnostic counts and affected files. */
5722
5837
  function formatProblemSummary(totalProblems, errors, warnings, files) {
5723
5838
  const parts = [];
5724
5839
  if (errors > 0) parts.push(pluralize(errors, "error"));
@@ -5728,6 +5843,7 @@ function formatProblemSummary(totalProblems, errors, warnings, files) {
5728
5843
  const breakdown = parts.length > 0 ? ` (${parts.join(", ")})` : "";
5729
5844
  return `Diagnosis complete: ${pluralize(totalProblems, "problem")}${breakdown} found across ${pluralize(files, "file")}.`;
5730
5845
  }
5846
+ /** Formats check results into pretty human-readable terminal output. */
5731
5847
  function formatPretty(result, cwd = process.cwd(), useColor = shouldEnableColor()) {
5732
5848
  const c = getColors(useColor);
5733
5849
  const symCross = "✖ ";
@@ -5777,6 +5893,7 @@ function formatPretty(result, cwd = process.cwd(), useColor = shouldEnableColor(
5777
5893
  lines.push(`${c.red}${c.bold}${symCross}${summary}${c.reset}`);
5778
5894
  return lines.join("\n");
5779
5895
  }
5896
+ /** Formats check diagnostics into GitHub Actions workflow annotation commands. */
5780
5897
  function formatGitHubAnnotations(result, cwd = process.cwd()) {
5781
5898
  const commands = [];
5782
5899
  for (const [rawUri, diags] of Object.entries(result.diagnostics)) {
@@ -5796,6 +5913,7 @@ function formatGitHubAnnotations(result, cwd = process.cwd()) {
5796
5913
  }
5797
5914
  return commands.join("\n");
5798
5915
  }
5916
+ /** Formats check results according to the chosen format (pretty, json, github). */
5799
5917
  function formatReport(result, format = "pretty", cwd = process.cwd(), useColor = shouldEnableColor()) {
5800
5918
  switch (format) {
5801
5919
  case "json": return JSON.stringify(result, null, 2);
@@ -8763,13 +8881,13 @@ function useColor() {
8763
8881
  }
8764
8882
  new Command();
8765
8883
  //#endregion
8766
- //#region src/annotations.ts
8767
- const DOCGEN_REPO = "nanos-world/vscode-extension";
8768
- const DOCGEN_BRANCH = "docgen-output";
8769
- const RAW_ANNOTATIONS_URL = "https://raw.githubusercontent.com/nanos-world/vscode-extension/refs/heads/docgen-output/annotations.lua";
8770
- const GITHUB_COMMITS_API = "https://api.github.com/repos/nanos-world/vscode-extension/commits/docgen-output";
8884
+ //#region src/annotations-metadata.ts
8771
8885
  const ANNOTATIONS_FILENAME = "annotations.lua";
8772
8886
  const METADATA_FILENAME = "metadata.json";
8887
+ const MIN_ANNOTATIONS_SIZE_BYTES = 1e3;
8888
+ const MAX_ANNOTATIONS_SIZE_BYTES = 10485760;
8889
+ const MAX_COMMIT_JSON_SIZE_BYTES = 1048576;
8890
+ /** Returns current date string formatted as YYYY-MM-DD alongside a date components object. */
8773
8891
  function getTodayDateString(d = /* @__PURE__ */ new Date()) {
8774
8892
  const year = d.getFullYear();
8775
8893
  const month = d.getMonth() + 1;
@@ -8783,60 +8901,127 @@ function getTodayDateString(d = /* @__PURE__ */ new Date()) {
8783
8901
  }
8784
8902
  };
8785
8903
  }
8904
+ /** Returns the system cache directory used for nanos world annotations. */
8786
8905
  function getAnnotationsCacheDir() {
8787
8906
  return path.join(systemPaths.cache, "annotations");
8788
8907
  }
8908
+ /** Returns the full path to the cached annotations.lua file. */
8789
8909
  function getCachedAnnotationsFilePath() {
8790
8910
  return path.join(getAnnotationsCacheDir(), ANNOTATIONS_FILENAME);
8791
8911
  }
8912
+ /** Returns the full path to the annotations metadata.json file. */
8792
8913
  function getAnnotationsMetadataFilePath() {
8793
8914
  return path.join(getAnnotationsCacheDir(), METADATA_FILENAME);
8794
8915
  }
8916
+ /** Reads and parses annotations metadata.json, purging corrupt files automatically. */
8795
8917
  function readAnnotationsMetadata(cacheDir = getAnnotationsCacheDir()) {
8796
8918
  const metaPath = path.join(cacheDir, "metadata.json");
8797
8919
  if (!fs.existsSync(metaPath)) return null;
8798
- try {
8799
- const content = fs.readFileSync(metaPath, "utf-8");
8800
- const parsed = JSON.parse(content);
8801
- if (typeof parsed?.commitId === "string" && typeof parsed?.lastChecked === "string") return parsed;
8920
+ const purge = (reason) => {
8802
8921
  try {
8803
8922
  fs.unlinkSync(metaPath);
8804
- logger.warn(`[annotations] Stale or invalid annotations metadata at ${metaPath} purged.`);
8805
- } catch (unlinkErr) {
8806
- logger.debug(`[annotations] Failed to unlink invalid metadata: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`);
8923
+ logger.warn(`[annotations] ${reason} annotations metadata at ${metaPath} purged.`);
8924
+ } catch (err) {
8925
+ logger.debug(`[annotations] Failed to unlink metadata: ${err instanceof Error ? err.message : String(err)}`);
8807
8926
  }
8927
+ };
8928
+ try {
8929
+ const parsed = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
8930
+ if (typeof parsed?.commitId === "string" && typeof parsed?.lastChecked === "string") return parsed;
8931
+ purge("Stale or invalid");
8808
8932
  } catch (err) {
8809
8933
  logger.debug(`Failed to parse annotations metadata: ${err instanceof Error ? err.message : String(err)}`);
8810
- try {
8811
- fs.unlinkSync(metaPath);
8812
- logger.warn(`[annotations] Corrupted annotations metadata at ${metaPath} purged.`);
8813
- } catch (unlinkErr) {
8814
- logger.debug(`[annotations] Failed to unlink corrupted metadata: ${unlinkErr instanceof Error ? unlinkErr.message : String(unlinkErr)}`);
8815
- }
8934
+ purge("Corrupted");
8816
8935
  }
8817
8936
  return null;
8818
8937
  }
8819
- const MIN_ANNOTATIONS_SIZE_BYTES = 1e3;
8820
- /**
8821
- * Verifies that an annotations file exists, is a regular file, is of non-trivial size (>= MIN_ANNOTATIONS_SIZE_BYTES),
8822
- * and begins with valid LuaLS metadata or nanos world definitions.
8823
- */
8938
+ /** Verifies that an annotations file exists, is non-trivial in size, and contains valid header. */
8824
8939
  function isAnnotationsValid(filePath) {
8825
8940
  if (!fs.existsSync(filePath)) return false;
8826
8941
  try {
8827
8942
  const stat = fs.statSync(filePath);
8828
8943
  if (!stat.isFile() || stat.size < 1e3) return false;
8829
8944
  const fd = fs.openSync(filePath, "r");
8830
- const buffer = Buffer.alloc(512);
8831
- const bytesRead = fs.readSync(fd, buffer, 0, 512, 0);
8832
- fs.closeSync(fd);
8833
- const header = buffer.toString("utf-8", 0, bytesRead).trimStart();
8834
- return header.startsWith("---@meta") || header.includes("nanos world") || header.includes("nanos-world");
8945
+ try {
8946
+ const buffer = Buffer.alloc(512);
8947
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
8948
+ const header = buffer.toString("utf-8", 0, bytesRead).trimStart();
8949
+ return header.startsWith("---@meta") || header.includes("nanos world") || header.includes("nanos-world");
8950
+ } finally {
8951
+ fs.closeSync(fd);
8952
+ }
8835
8953
  } catch (err) {
8836
8954
  logger.debug(`[annotations] Annotation validation failed for ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
8837
8955
  return false;
8838
8956
  }
8839
8957
  }
8958
+ /** Updates metadata lastChecked date atomically without modifying annotations.lua. */
8959
+ function updateLastCheckedDate(commitId, cacheDir = getAnnotationsCacheDir()) {
8960
+ const { dateStr, dateObj } = getTodayDateString();
8961
+ const metadata = {
8962
+ commitId,
8963
+ lastChecked: dateStr,
8964
+ date: dateObj
8965
+ };
8966
+ fs.mkdirSync(cacheDir, { recursive: true });
8967
+ const metaPath = path.join(cacheDir, METADATA_FILENAME);
8968
+ const tempMetaPath = path.join(os.tmpdir(), `.metadata-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`);
8969
+ try {
8970
+ fs.writeFileSync(tempMetaPath, JSON.stringify(metadata, null, 2), "utf-8");
8971
+ fs.copyFileSync(tempMetaPath, metaPath);
8972
+ } finally {
8973
+ try {
8974
+ if (fs.existsSync(tempMetaPath)) fs.unlinkSync(tempMetaPath);
8975
+ } catch (err) {
8976
+ logger.warn(`Failed to clean up temporary metadata file ${tempMetaPath}: ${err instanceof Error ? err.message : String(err)}`);
8977
+ }
8978
+ }
8979
+ return metadata;
8980
+ }
8981
+ //#endregion
8982
+ //#region src/annotations-download.ts
8983
+ const DOCGEN_REPO = "nanos-world/vscode-extension";
8984
+ const DOCGEN_BRANCH = "docgen-output";
8985
+ const RAW_ANNOTATIONS_URL = "https://raw.githubusercontent.com/nanos-world/vscode-extension/refs/heads/docgen-output/annotations.lua";
8986
+ const GITHUB_COMMITS_API = "https://api.github.com/repos/nanos-world/vscode-extension/commits/docgen-output";
8987
+ /** Reads an HTTP response body with an upper byte limit to guard against memory exhaustion. */
8988
+ async function readBoundedResponseBody(res, maxBytes, onExceeded) {
8989
+ const lengthHeader = res.headers?.get?.("content-length");
8990
+ if (lengthHeader) {
8991
+ const declared = parseInt(lengthHeader, 10);
8992
+ if (!isNaN(declared) && declared > maxBytes) {
8993
+ if (typeof res.body?.cancel === "function") await res.body.cancel().catch(() => {});
8994
+ onExceeded(declared, "header");
8995
+ return null;
8996
+ }
8997
+ }
8998
+ if (res.body && typeof res.body[Symbol.asyncIterator] === "function") {
8999
+ let total = 0;
9000
+ const chunks = [];
9001
+ for await (const chunk of res.body) {
9002
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
9003
+ total += buf.length;
9004
+ if (total > maxBytes) {
9005
+ if (typeof res.body.cancel === "function") await res.body.cancel().catch(() => {});
9006
+ onExceeded(total, "stream");
9007
+ return null;
9008
+ }
9009
+ chunks.push(buf);
9010
+ }
9011
+ return Buffer.concat(chunks).toString("utf-8");
9012
+ }
9013
+ if (typeof res.text === "function") {
9014
+ const text = await res.text();
9015
+ const len = Buffer.byteLength(text, "utf-8");
9016
+ if (len > maxBytes) {
9017
+ onExceeded(len, "stream");
9018
+ return null;
9019
+ }
9020
+ return text;
9021
+ }
9022
+ return "";
9023
+ }
9024
+ /** Fetches the latest commit SHA for the annotations branch from the GitHub API. */
8840
9025
  async function fetchLatestCommitId() {
8841
9026
  try {
8842
9027
  const headers = { "User-Agent": "nanos-lint" };
@@ -8846,7 +9031,17 @@ async function fetchLatestCommitId() {
8846
9031
  signal: AbortSignal.timeout(5e3)
8847
9032
  });
8848
9033
  if (res.ok) {
8849
- const data = await res.json();
9034
+ const rawJson = await readBoundedResponseBody(res, MAX_COMMIT_JSON_SIZE_BYTES, (bytes) => {
9035
+ logger.warn(`GitHub commits response exceeded size limit of ${MAX_COMMIT_JSON_SIZE_BYTES} bytes (${bytes} bytes)`);
9036
+ });
9037
+ if (rawJson === null) return null;
9038
+ if (!rawJson && !res.bodyUsed && typeof res.json === "function") {
9039
+ const data = await res.json();
9040
+ if (typeof data.sha === "string" && /^[0-9a-fA-F]{7,40}$/.test(data.sha)) return data.sha;
9041
+ return null;
9042
+ }
9043
+ if (!rawJson) return null;
9044
+ const data = JSON.parse(rawJson);
8850
9045
  if (typeof data.sha === "string" && /^[0-9a-fA-F]{7,40}$/.test(data.sha)) return data.sha;
8851
9046
  }
8852
9047
  } catch (err) {
@@ -8854,13 +9049,12 @@ async function fetchLatestCommitId() {
8854
9049
  }
8855
9050
  return null;
8856
9051
  }
8857
- /**
8858
- * Returns raw annotations URL, pinned to commit SHA if provided and valid.
8859
- */
9052
+ /** Returns raw annotations URL, pinned to commit SHA if provided and valid. */
8860
9053
  function getRawAnnotationsUrl(commitSha) {
8861
9054
  if (commitSha && /^[0-9a-fA-F]{7,40}$/.test(commitSha)) return `https://raw.githubusercontent.com/${DOCGEN_REPO}/${commitSha}/annotations.lua`;
8862
9055
  return RAW_ANNOTATIONS_URL;
8863
9056
  }
9057
+ /** Downloads raw annotations.lua content from GitHub raw content endpoints. */
8864
9058
  async function fetchRawAnnotationsContent(commitSha) {
8865
9059
  const url = getRawAnnotationsUrl(commitSha);
8866
9060
  const res = await fetch(url, {
@@ -8868,10 +9062,13 @@ async function fetchRawAnnotationsContent(commitSha) {
8868
9062
  signal: AbortSignal.timeout(15e3)
8869
9063
  });
8870
9064
  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.");
8871
- const text = await res.text();
9065
+ const text = await readBoundedResponseBody(res, MAX_ANNOTATIONS_SIZE_BYTES, (bytes, reason) => {
9066
+ throw new AnnotationsError(reason === "header" ? `Annotations file size (${bytes} bytes) exceeds maximum limit (${MAX_ANNOTATIONS_SIZE_BYTES} bytes)` : `Annotations file download exceeded maximum allowed size of ${MAX_ANNOTATIONS_SIZE_BYTES} bytes`, "ERR_ANNOTATIONS_TOO_LARGE", "Specify a local annotations file via --annotations <path>.");
9067
+ });
8872
9068
  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>.");
8873
9069
  return text;
8874
9070
  }
9071
+ /** Copies a file with exponential backoff retries to handle transient file lock contention. */
8875
9072
  async function copyFileWithRetry(src, dest, maxRetries = 10) {
8876
9073
  for (let attempt = 0; attempt < maxRetries; attempt++) try {
8877
9074
  fs.copyFileSync(src, dest);
@@ -8885,35 +9082,7 @@ async function copyFileWithRetry(src, dest, maxRetries = 10) {
8885
9082
  throw err;
8886
9083
  }
8887
9084
  }
8888
- /**
8889
- * Updates the metadata lastChecked date atomically without modifying annotations.lua.
8890
- */
8891
- function updateLastCheckedDate(commitId, cacheDir = getAnnotationsCacheDir()) {
8892
- const { dateStr, dateObj } = getTodayDateString();
8893
- const metadata = {
8894
- commitId,
8895
- lastChecked: dateStr,
8896
- date: dateObj
8897
- };
8898
- fs.mkdirSync(cacheDir, { recursive: true });
8899
- const metaPath = path.join(cacheDir, METADATA_FILENAME);
8900
- const tempMetaPath = path.join(os.tmpdir(), `.metadata-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.tmp`);
8901
- try {
8902
- fs.writeFileSync(tempMetaPath, JSON.stringify(metadata, null, 2), "utf-8");
8903
- fs.copyFileSync(tempMetaPath, metaPath);
8904
- } finally {
8905
- try {
8906
- if (fs.existsSync(tempMetaPath)) fs.unlinkSync(tempMetaPath);
8907
- } catch (err) {
8908
- logger.warn(`Failed to clean up temporary metadata file ${tempMetaPath}: ${err instanceof Error ? err.message : String(err)}`);
8909
- }
8910
- }
8911
- return metadata;
8912
- }
8913
- /**
8914
- * Downloads annotations.lua, writes it and the new metadata in a chained atomic transaction.
8915
- * If any step fails, the previous cache state is reverted.
8916
- */
9085
+ /** Downloads annotations.lua and writes metadata in a chained atomic transaction with rollback. */
8917
9086
  async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCacheDir(), options) {
8918
9087
  fs.mkdirSync(cacheDir, { recursive: true });
8919
9088
  const finalAnnotationsPath = path.join(cacheDir, ANNOTATIONS_FILENAME);
@@ -8924,8 +9093,8 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
8924
9093
  try {
8925
9094
  fs.mkdirSync(tempDir, { recursive: true });
8926
9095
  if (!options?.quiet) {
8927
- if (commitId && commitId !== "unknown") logger.info(`[annotations] Downloading nanos world API annotations (${commitId.slice(0, 7)})...`);
8928
- else logger.info("[annotations] Downloading nanos world API annotations...");
9096
+ const label = commitId && commitId !== "unknown" ? ` (${commitId.slice(0, 7)})` : "";
9097
+ logger.info(`[annotations] Downloading nanos world API annotations${label}...`);
8929
9098
  }
8930
9099
  const content = await fetchRawAnnotationsContent(commitId);
8931
9100
  const tempAnnotationsPath = path.join(tempDir, ANNOTATIONS_FILENAME);
@@ -8951,16 +9120,16 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
8951
9120
  await copyFileWithRetry(tempAnnotationsPath, finalAnnotationsPath);
8952
9121
  await copyFileWithRetry(tempMetaPath, finalMetaPath);
8953
9122
  if (!options?.quiet) {
8954
- if (commitId && commitId !== "unknown") logger.info(`[annotations] Updated annotations.lua to commit ${commitId.slice(0, 7)}.`);
8955
- else logger.info("[annotations] Updated annotations.lua.");
9123
+ const label = commitId && commitId !== "unknown" ? ` to commit ${commitId.slice(0, 7)}` : "";
9124
+ logger.info(`[annotations] Updated annotations.lua${label}.`);
8956
9125
  }
8957
9126
  return finalAnnotationsPath;
8958
9127
  } catch (err) {
8959
9128
  if (hasBackup && fs.existsSync(backupDir)) try {
8960
- const backupAnnotations = path.join(backupDir, ANNOTATIONS_FILENAME);
8961
- const backupMeta = path.join(backupDir, METADATA_FILENAME);
8962
- if (fs.existsSync(backupAnnotations)) await copyFileWithRetry(backupAnnotations, finalAnnotationsPath);
8963
- if (fs.existsSync(backupMeta)) await copyFileWithRetry(backupMeta, finalMetaPath);
9129
+ const bAnn = path.join(backupDir, ANNOTATIONS_FILENAME);
9130
+ const bMeta = path.join(backupDir, METADATA_FILENAME);
9131
+ if (fs.existsSync(bAnn)) await copyFileWithRetry(bAnn, finalAnnotationsPath);
9132
+ if (fs.existsSync(bMeta)) await copyFileWithRetry(bMeta, finalMetaPath);
8964
9133
  } catch (rollbackErr) {
8965
9134
  logger.error(`Failed to restore annotations from backup during rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`);
8966
9135
  }
@@ -8980,21 +9149,28 @@ async function downloadAndCacheAnnotations(commitId, cacheDir = getAnnotationsCa
8980
9149
  }
8981
9150
  }
8982
9151
  }
9152
+ //#endregion
9153
+ //#region src/annotations.ts
9154
+ /** Validates that a user-supplied or environment-specified annotations path exists and is a valid file. */
8983
9155
  function validateCustomAnnotationsPath(filePath, source) {
8984
9156
  const resolved = path.resolve(filePath);
8985
- if (!fs.existsSync(resolved)) {
8986
- if (source === "custom") throw new AnnotationsError(`Custom annotations file not found: ${resolved}`, "ERR_ANNOTATIONS_NOT_FOUND", "Verify that the path specified in --annotations exists and is accessible.");
8987
- else 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.");
8988
- }
9157
+ const isCustom = source === "custom";
9158
+ const targetDesc = isCustom ? "path specified in --annotations" : "path specified in NANOS_ANNOTATIONS_PATH or NANOS_ANNOTATIONS";
9159
+ if (!fs.existsSync(resolved)) throw new AnnotationsError(isCustom ? `Custom annotations file not found: ${resolved}` : `Annotations file specified in environment not found: ${resolved}`, isCustom ? "ERR_ANNOTATIONS_NOT_FOUND" : "ERR_ANNOTATIONS_ENV_NOT_FOUND", `Verify that the ${targetDesc} exists and is accessible.`);
8989
9160
  const stat = fs.statSync(resolved);
8990
- if (!stat.isFile()) throw new AnnotationsError(source === "custom" ? `Custom annotations path is not a file: ${resolved}` : `Annotations path specified in environment is not a file: ${resolved}`, "ERR_ANNOTATIONS_NOT_A_FILE", source === "custom" ? "Verify that the path specified in --annotations points to a regular file, not a directory." : "Verify that the path specified in NANOS_ANNOTATIONS_PATH or NANOS_ANNOTATIONS points to a regular file, not a directory.");
8991
- if (stat.size === 0) throw new AnnotationsError(source === "custom" ? `Custom annotations file is empty: ${resolved}` : `Annotations file specified in environment is empty: ${resolved}`, "ERR_ANNOTATIONS_INVALID", source === "custom" ? "Verify that the path specified in --annotations is a valid non-empty Lua annotations file." : "Verify that the path specified in NANOS_ANNOTATIONS_PATH or NANOS_ANNOTATIONS is a valid non-empty Lua annotations file.");
9161
+ if (!stat.isFile()) throw new AnnotationsError(isCustom ? `Custom annotations path is not a file: ${resolved}` : `Annotations path specified in environment is not a file: ${resolved}`, "ERR_ANNOTATIONS_NOT_A_FILE", `Verify that the ${targetDesc} points to a regular file, not a directory.`);
9162
+ if (stat.size === 0) throw new AnnotationsError(isCustom ? `Custom annotations file is empty: ${resolved}` : `Annotations file specified in environment is empty: ${resolved}`, "ERR_ANNOTATIONS_INVALID", `Verify that the ${targetDesc} is a valid non-empty Lua annotations file.`);
8992
9163
  try {
8993
9164
  const fd = fs.openSync(resolved, "r");
8994
- const buffer = Buffer.alloc(Math.min(stat.size, 512));
8995
- const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
8996
- fs.closeSync(fd);
8997
- if (buffer.subarray(0, bytesRead).includes(0)) throw new AnnotationsError(source === "custom" ? `Custom annotations file appears to be a binary file: ${resolved}` : `Annotations file specified in environment appears to be a binary file: ${resolved}`, "ERR_ANNOTATIONS_INVALID", "Verify that the annotations file is a valid Lua text annotations file.");
9165
+ let isBinary = false;
9166
+ try {
9167
+ const buffer = Buffer.alloc(Math.min(stat.size, 512));
9168
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
9169
+ isBinary = buffer.subarray(0, bytesRead).includes(0);
9170
+ } finally {
9171
+ fs.closeSync(fd);
9172
+ }
9173
+ if (isBinary) throw new AnnotationsError(isCustom ? `Custom annotations file appears to be a binary file: ${resolved}` : `Annotations file specified in environment appears to be a binary file: ${resolved}`, "ERR_ANNOTATIONS_INVALID", "Verify that the annotations file is a valid Lua text annotations file.");
8998
9174
  } catch (err) {
8999
9175
  if (err instanceof AnnotationsError) throw err;
9000
9176
  }
@@ -9063,6 +9239,7 @@ async function resolveAnnotations(options = {}) {
9063
9239
  }
9064
9240
  //#endregion
9065
9241
  //#region src/cache-status.ts
9242
+ /** Collects detailed status and disk usage information for LuaLS and annotations caches. */
9066
9243
  function getCacheStatus(baseCacheDir = systemPaths.cache) {
9067
9244
  const totalSize = getDirectorySize(baseCacheDir);
9068
9245
  const totalSizeFormatted = formatBytes(totalSize);
@@ -9120,6 +9297,7 @@ function getCacheStatus(baseCacheDir = systemPaths.cache) {
9120
9297
  }
9121
9298
  };
9122
9299
  }
9300
+ /** Formats a cache status report into human-readable terminal output. */
9123
9301
  function formatCacheStatusPretty(report) {
9124
9302
  const lines = [];
9125
9303
  lines.push("nanos-lint Cache Status");
@@ -9154,6 +9332,7 @@ function formatCacheStatusPretty(report) {
9154
9332
  }
9155
9333
  //#endregion
9156
9334
  //#region src/cli.ts
9335
+ /** Retrieves the formatted package name and version string from package.json. */
9157
9336
  function getVersionString() {
9158
9337
  const root = getPackageRoot();
9159
9338
  try {
@@ -9167,10 +9346,12 @@ function getVersionString() {
9167
9346
  function writeOutput(message) {
9168
9347
  if (logger.isOutputEnabled()) console.log(message);
9169
9348
  }
9349
+ /** Accumulates repeatable --ignore pattern arguments, splitting by comma and newline. */
9170
9350
  function collectIgnorePatterns(val, prev) {
9171
9351
  const parts = val.split(/[\r\n,]+/).map((s) => s.trim()).filter(Boolean);
9172
9352
  return (prev ?? []).concat(parts);
9173
9353
  }
9354
+ /** Constructs and configures the top-level Commander program and its CLI subcommands. */
9174
9355
  function createProgram(options) {
9175
9356
  const setExitCode = options?.setExitCode ?? (() => {});
9176
9357
  const program = new Command("nanos-lint");
@@ -9319,6 +9500,7 @@ Examples:
9319
9500
  `);
9320
9501
  return program;
9321
9502
  }
9503
+ /** Parses command-line arguments and executes the requested CLI action. */
9322
9504
  async function runCLI(args = process.argv.slice(2)) {
9323
9505
  for (let i = 0; i < args.length; i++) {
9324
9506
  const arg = args[i];
@@ -9355,6 +9537,7 @@ async function runCLI(args = process.argv.slice(2)) {
9355
9537
  return 1;
9356
9538
  }
9357
9539
  }
9540
+ /** Determines whether the current module is being directly executed as an application entrypoint. */
9358
9541
  function isDirectExecution(importMetaUrl = import.meta.url, argv1 = process.argv[1]) {
9359
9542
  if (!argv1) return false;
9360
9543
  const toPath = (urlStr) => {
@@ -9391,6 +9574,6 @@ if (isDirectExecution()) runCLI().then((code) => {
9391
9574
  process.exit(1);
9392
9575
  });
9393
9576
  //#endregion
9394
- export { listCachedLuaLSVersionDirs as $, formatSeverityBadge as A, NanosLintError as At, computeFileSha256 as B, isValidLogLevel as Bt, readAnnotationsMetadata as C, resolveWorkspaceConfig as Ct, formatPretty as D, CacheError as Dt, formatGitHubAnnotations as E, AnnotationsError as Et, runLuaLSCheck as F, fileUriToPath as Ft, LUALS_METADATA_FILENAME as G, escapePowerShellSingleQuote as H, parseLogLevel as Ht, countCheckedFiles as I, DEFAULT_LOG_LEVEL as It, getBaseLuaLSCacheDir as J, cleanupOldCachedLuaLSVersions as K, ALLOWED_DOWNLOAD_DOMAINS as L, LOG_LEVELS as Lt, pluralize as M, formatBytes as Mt, shouldEnableColor as N, getDirectorySize as Nt, formatProblemSummary as O, ConfigError as Ot, resolveLuaLSBinary as P, systemPaths as Pt, getLuaLSMetadataPath as Q, DOWNLOAD_TIMEOUT_MS as R, LOG_LEVEL_PRIORITY as Rt, isAnnotationsValid as S, parseJsonc as St, updateLastCheckedDate as T, stripTrailingSlashes as Tt, isAllowedDownloadUrl as U, downloadAndExtractLuaLS as V, logger as Vt, limitDownloadStream as W, getIsoWeek as X, getCacheDir as Y, getLegacyCacheDir as Z, getAnnotationsCacheDir as _, getDefinitionsDir as _t, formatCacheStatusPretty as a, isBinaryRunnable as at, getRawAnnotationsUrl as b, loadConfigFile as bt, DOCGEN_BRANCH as c, DEFAULT_LUALS_VERSION as ct, METADATA_FILENAME as d, resolveLatestLuaLSVersion as dt, listCachedLuaLSVersions as et, MIN_ANNOTATIONS_SIZE_BYTES as f, resolveLuaLSVersion as ft, fetchRawAnnotationsContent as g, getDefaultTemplatePath as gt, fetchLatestCommitId as h, getDefaultAnnotationsPath as ht, runCLI as i, assertValidLuaLSBinary as it, getColors as j, cleanCache as jt, formatReport as k, LuaLSError as kt, DOCGEN_REPO as l, FALLBACK_LUALS_VERSION as lt, downloadAndCacheAnnotations as m, VALID_LUALS_DIAGNOSTIC_CODES as mt, createProgram as n, writeLuaLSMetadata as nt, getCacheStatus as o, isBinaryValid as ot, RAW_ANNOTATIONS_URL as p, sanitizeLuaLSVersion as pt, findExistingLuaLSDir as q, isDirectExecution as r, MIN_DOWNLOADED_BINARY_SIZE_BYTES as rt, ANNOTATIONS_FILENAME as s, getPlatformInfo as st, collectIgnorePatterns as t, readLuaLSMetadata as tt, GITHUB_COMMITS_API as u, fetchLatestLuaLSVersionFromGitHub as ut, getAnnotationsMetadataFilePath as v, getPackageRoot as vt, resolveAnnotations as w, stripJsonComments as wt, getTodayDateString as x, mergeConfigs as xt, getCachedAnnotationsFilePath as y, initWorkspace as yt, MAX_ARCHIVE_SIZE_BYTES as z, Logger as zt };
9577
+ export { getLuaLSMetadataPath as $, formatProblemSummary as A, loadConfigFile as At, DOWNLOAD_TIMEOUT_MS as B, NanosLintError as Bt, getCachedAnnotationsFilePath as C, sanitizeLuaLSVersion as Ct, updateLastCheckedDate as D, getDefinitionsDir as Dt, readAnnotationsMetadata as E, getDefaultTemplatePath as Et, shouldEnableColor as F, stripTrailingSlashes as Ft, limitDownloadStream as G, fileUriToPath as Gt, computeFileSha256 as H, formatBytes as Ht, resolveLuaLSBinary as I, AnnotationsError as It, findExistingLuaLSDir as J, LOG_LEVEL_PRIORITY as Jt, LUALS_METADATA_FILENAME as K, DEFAULT_LOG_LEVEL as Kt, runLuaLSCheck as L, CacheError as Lt, formatSeverityBadge as M, parseJsonc as Mt, getColors as N, resolveWorkspaceConfig as Nt, formatGitHubAnnotations as O, getPackageRoot as Ot, pluralize as P, stripJsonComments as Pt, getLegacyCacheDir as Q, parseLogLevel as Qt, countCheckedFiles as R, ConfigError as Rt, getAnnotationsMetadataFilePath as S, resolveLuaLSVersion as St, isAnnotationsValid as T, getDefaultAnnotationsPath as Tt, downloadAndExtractLuaLS as U, getDirectorySize as Ut, MAX_ARCHIVE_SIZE_BYTES as V, cleanCache as Vt, isAllowedDownloadUrl as W, systemPaths as Wt, getCacheDir as X, isValidLogLevel as Xt, getBaseLuaLSCacheDir as Y, Logger as Yt, getIsoWeek as Z, logger as Zt, MAX_ANNOTATIONS_SIZE_BYTES as _, getPlatformInfo as _t, formatCacheStatusPretty as a, MAX_DECOMPRESSED_SIZE_BYTES as at, MIN_ANNOTATIONS_SIZE_BYTES as b, fetchLatestLuaLSVersionFromGitHub as bt, DOCGEN_BRANCH as c, escapePowerShellSingleQuote as ct, RAW_ANNOTATIONS_URL as d, isBinaryRunnable as dt, listCachedLuaLSVersionDirs as et, downloadAndCacheAnnotations as f, isBinaryValid as ft, ANNOTATIONS_FILENAME as g, validateArchiveMembers as gt, getRawAnnotationsUrl as h, parseTarTvSize as ht, runCLI as i, MAX_ARCHIVE_MEMBER_COUNT as it, formatReport as j, mergeConfigs as jt, formatPretty as k, initWorkspace as kt, DOCGEN_REPO as l, getTarBinary as lt, fetchRawAnnotationsContent as m, isZipArchive as mt, createProgram as n, readLuaLSMetadata as nt, getCacheStatus as o, MIN_DOWNLOADED_BINARY_SIZE_BYTES as ot, fetchLatestCommitId as p, isGzipArchive as pt, cleanupOldCachedLuaLSVersions as q, LOG_LEVELS as qt, isDirectExecution as r, writeLuaLSMetadata as rt, resolveAnnotations as s, assertValidLuaLSBinary as st, collectIgnorePatterns as t, listCachedLuaLSVersions as tt, GITHUB_COMMITS_API as u, inspectZipMembers as ut, MAX_COMMIT_JSON_SIZE_BYTES as v, DEFAULT_LUALS_VERSION as vt, getTodayDateString as w, VALID_LUALS_DIAGNOSTIC_CODES as wt, getAnnotationsCacheDir as x, resolveLatestLuaLSVersion as xt, METADATA_FILENAME as y, FALLBACK_LUALS_VERSION as yt, ALLOWED_DOWNLOAD_DOMAINS as z, LuaLSError as zt };
9395
9578
 
9396
- //# sourceMappingURL=cli-BAy_Ztvh.js.map
9579
+ //# sourceMappingURL=cli-BTHx4yp_.js.map