unguard 0.15.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,8 @@ unguard src --severity=error,warning # show errors+warnings
36
36
  unguard src --fail-on=error # fail only on errors
37
37
  unguard src --format=flat # one-line-per-diagnostic, grepable
38
38
  unguard src --format=flat | grep error
39
+ unguard src --concurrency 1 # disable worker-thread parallelism
40
+ unguard src --no-cache # bypass on-disk diagnostic cache
39
41
  ```
40
42
 
41
43
  Add `unguard` to your lint check, especially if code is written by AI.
@@ -56,12 +58,15 @@ Add `unguard` to your lint check, especially if code is written by AI.
56
58
  "no-ts-ignore": "error",
57
59
  "prefer-*": "off"
58
60
  },
59
- "failOn": "error"
61
+ "failOn": "error",
62
+ "concurrency": 4,
63
+ "cache": true
60
64
  }
61
65
  ```
62
66
 
63
67
  `rules` values can be `off`, `info`, `warning`, or `error`.
64
68
  Selectors support:
69
+
65
70
  - exact rule id: `no-ts-ignore`
66
71
  - wildcard: `duplicate-*`
67
72
  - category: `category:cross-file`
@@ -70,6 +75,7 @@ Selectors support:
70
75
  ### Ignore behavior
71
76
 
72
77
  `unguard` ignores:
78
+
73
79
  - built-in: `node_modules`, `dist`, `.git`, declaration files (`*.d.ts`, `*.d.cts`, `*.d.mts`)
74
80
  - generated files: `*.gen.*`, `*.generated.*`
75
81
  - project `.gitignore`
@@ -232,6 +238,21 @@ for (const d of execution.visibleDiagnostics) {
232
238
  }
233
239
  ```
234
240
 
241
+ ### Caching
242
+
243
+ unguard caches scan results under `node_modules/.cache/unguard/`. On a warm
244
+ run, if every file's content hash and the active rule set are unchanged,
245
+ unguard returns cached diagnostics without building a TypeScript program.
246
+ The cache invalidates automatically on:
247
+
248
+ - file content changes (mtime-only changes are ignored — `git checkout` and `git stash` stay cache hits)
249
+ - changes to active rules or rule severities
250
+ - changes to scan paths, ignore globs, or `failOn`
251
+ - unguard version upgrades
252
+
253
+ Disable with `--no-cache` (CLI), `cache: false` (config), or pass
254
+ `cache: false` to `executeScan({ cache: false })` programmatically.
255
+
235
256
  ## License
236
257
 
237
258
  MIT
@@ -1,3 +1,37 @@
1
+ // src/rules/types.ts
2
+ function isTSRule(r) {
3
+ return "kind" in r && r.kind === "ts";
4
+ }
5
+ function reportDuplicateGroup(group, ruleId, severity, formatOther, formatMessage, diagnostics, context) {
6
+ const sorted = [...group].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
7
+ const entries = selectDuplicateReportEntries(sorted, context.reportableFiles);
8
+ for (const entry of entries) {
9
+ const others = sorted.filter((e) => e !== entry).map(formatOther).join(", ");
10
+ diagnostics.push({
11
+ ruleId,
12
+ severity,
13
+ message: formatMessage(entry, others),
14
+ file: entry.file,
15
+ line: entry.line,
16
+ column: 1
17
+ });
18
+ }
19
+ }
20
+ function selectReportTarget(group, reportableFiles) {
21
+ const sorted = [...group].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
22
+ if (reportableFiles === void 0) return sorted[0];
23
+ return sorted.find((entry) => reportableFiles.has(entry.file));
24
+ }
25
+ function selectDuplicateReportEntries(sorted, reportableFiles) {
26
+ const defaultEntries = sorted.slice(1);
27
+ if (reportableFiles === void 0) return defaultEntries;
28
+ const reportableDefaultEntries = defaultEntries.filter((entry) => reportableFiles.has(entry.file));
29
+ if (reportableDefaultEntries.length > 0) return reportableDefaultEntries;
30
+ const first = sorted[0];
31
+ if (first !== void 0 && sorted.length > 1 && reportableFiles.has(first.file)) return [first];
32
+ return [];
33
+ }
34
+
1
35
  // src/rules/cross-file/dead-overload.ts
2
36
  import * as ts from "typescript";
3
37
  var deadOverload = {
@@ -170,40 +204,6 @@ function normalizeText(text) {
170
204
  return text.replace(/\s+/g, "");
171
205
  }
172
206
 
173
- // src/rules/types.ts
174
- function isTSRule(r) {
175
- return "kind" in r && r.kind === "ts";
176
- }
177
- function reportDuplicateGroup(group, ruleId, severity, formatOther, formatMessage, diagnostics, context) {
178
- const sorted = [...group].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
179
- const entries = selectDuplicateReportEntries(sorted, context.reportableFiles);
180
- for (const entry of entries) {
181
- const others = sorted.filter((e) => e !== entry).map(formatOther).join(", ");
182
- diagnostics.push({
183
- ruleId,
184
- severity,
185
- message: formatMessage(entry, others),
186
- file: entry.file,
187
- line: entry.line,
188
- column: 1
189
- });
190
- }
191
- }
192
- function selectReportTarget(group, reportableFiles) {
193
- const sorted = [...group].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
194
- if (reportableFiles === void 0) return sorted[0];
195
- return sorted.find((entry) => reportableFiles.has(entry.file));
196
- }
197
- function selectDuplicateReportEntries(sorted, reportableFiles) {
198
- const defaultEntries = sorted.slice(1);
199
- if (reportableFiles === void 0) return defaultEntries;
200
- const reportableDefaultEntries = defaultEntries.filter((entry) => reportableFiles.has(entry.file));
201
- if (reportableDefaultEntries.length > 0) return reportableDefaultEntries;
202
- const first = sorted[0];
203
- if (first !== void 0 && sorted.length > 1 && reportableFiles.has(first.file)) return [first];
204
- return [];
205
- }
206
-
207
207
  // src/rules/cross-file/duplicate-constant-declaration.ts
208
208
  var duplicateConstantDeclaration = {
209
209
  id: "duplicate-constant-declaration",
@@ -2389,57 +2389,6 @@ function getRuleMetadata(ruleId) {
2389
2389
  return { category: "cross-file", tags: [] };
2390
2390
  }
2391
2391
 
2392
- // src/scan/config.ts
2393
- var BUILTIN_IGNORE = [
2394
- "**/node_modules/**",
2395
- "**/dist/**",
2396
- "**/.git/**",
2397
- "**/*.d.ts",
2398
- "**/*.d.cts",
2399
- "**/*.d.mts"
2400
- ];
2401
- var GENERATED_IGNORE = [
2402
- "**/*.gen.*",
2403
- "**/*.generated.*"
2404
- ];
2405
- var DEFAULT_FAIL_ON = "info";
2406
- function resolveScanConfig(options) {
2407
- const paths = options.paths.length > 0 ? options.paths : ["."];
2408
- const ignore2 = [...BUILTIN_IGNORE, ...GENERATED_IGNORE, ...options.ignore ?? []];
2409
- return {
2410
- paths,
2411
- strict: options.strict ?? false,
2412
- rules: options.rules ? [...options.rules] : null,
2413
- ignore: ignore2,
2414
- rulePolicy: toRulePolicyEntries(options.rulePolicy),
2415
- showSeverities: normalizeSeveritySet(options.showSeverities),
2416
- failOn: options.failOn ?? DEFAULT_FAIL_ON,
2417
- useGitIgnore: options.useGitIgnore ?? true
2418
- };
2419
- }
2420
- function toRulePolicyEntries(policy) {
2421
- if (policy === void 0) return [];
2422
- if (Array.isArray(policy)) return [...policy];
2423
- const entries = [];
2424
- for (const [selector, severity] of Object.entries(policy)) {
2425
- entries.push({ selector, severity });
2426
- }
2427
- return entries;
2428
- }
2429
- function normalizeSeveritySet(levels) {
2430
- if (levels === void 0 || levels.length === 0) return null;
2431
- return new Set(levels);
2432
- }
2433
- function isRulePolicySeverity(value) {
2434
- return value === "off" || value === "info" || value === "warning" || value === "error";
2435
- }
2436
- function isSeverity(value) {
2437
- return value === "info" || value === "warning" || value === "error";
2438
- }
2439
- function isFailOn(value) {
2440
- return value === "none" || isSeverity(value);
2441
- }
2442
-
2443
2392
  // src/scan/analyze.ts
2444
2393
  import { readFileSync } from "fs";
2445
2394
 
@@ -3484,9 +3433,8 @@ function createCachedCompilerHost(options, cache) {
3484
3433
  };
3485
3434
  return host;
3486
3435
  }
3487
- function isStableCachedSourceFile(fileName) {
3488
- const normalized = fileName.replaceAll("\\", "/");
3489
- return normalized.includes("/node_modules/") || /\.d\.[cm]?ts$/.test(normalized) || normalized.endsWith(".json");
3436
+ function isStableCachedSourceFile(_fileName) {
3437
+ return true;
3490
3438
  }
3491
3439
  function sourceFileCacheKey(fileKey, languageVersionOrOptions) {
3492
3440
  if (typeof languageVersionOrOptions !== "object") {
@@ -3566,8 +3514,80 @@ function createProgramForGroup(group, options) {
3566
3514
  return createProgramForConfig(rootFiles, group.configPath, options.cache);
3567
3515
  }
3568
3516
 
3517
+ // src/scan/worker-pool.ts
3518
+ import { existsSync as existsSync2 } from "fs";
3519
+ import { Worker } from "worker_threads";
3520
+ import { fileURLToPath } from "url";
3521
+ var WORKER_URL = new URL("./worker.js", import.meta.url);
3522
+ function workersAvailable() {
3523
+ return existsSync2(fileURLToPath(WORKER_URL));
3524
+ }
3525
+ async function runGroupsInWorkers(tasks, ruleSpecs, indexNeeds, concurrency) {
3526
+ if (tasks.length === 0) return [];
3527
+ const ordered = [...tasks].sort((a, b) => b.groupConfig.scanFiles.length - a.groupConfig.scanFiles.length);
3528
+ const workerCount = Math.min(concurrency, ordered.length);
3529
+ const workers = [];
3530
+ const results = new Array(tasks.length);
3531
+ for (let i = 0; i < tasks.length; i++) results[i] = [];
3532
+ let nextIndex = 0;
3533
+ let remaining = ordered.length;
3534
+ let firstError = null;
3535
+ await new Promise((resolve2, reject) => {
3536
+ function finishOne() {
3537
+ remaining--;
3538
+ if (remaining === 0) {
3539
+ if (firstError !== null) reject(firstError);
3540
+ else resolve2();
3541
+ }
3542
+ }
3543
+ function dispatchNext(worker) {
3544
+ if (nextIndex >= ordered.length) return;
3545
+ const task = ordered[nextIndex];
3546
+ if (task === void 0) return;
3547
+ nextIndex++;
3548
+ const req = {
3549
+ taskId: task.id,
3550
+ groupConfig: task.groupConfig,
3551
+ ruleSpecs,
3552
+ indexNeeds
3553
+ };
3554
+ worker.postMessage(req);
3555
+ }
3556
+ for (let i = 0; i < workerCount; i++) {
3557
+ const worker = new Worker(fileURLToPath(WORKER_URL));
3558
+ workers.push(worker);
3559
+ worker.on("message", (response) => {
3560
+ if (response.ok) {
3561
+ results[response.taskId] = response.diagnostics;
3562
+ } else if (firstError === null) {
3563
+ firstError = new Error(`unguard worker error: ${response.error}`);
3564
+ }
3565
+ finishOne();
3566
+ dispatchNext(worker);
3567
+ });
3568
+ worker.on("error", (err) => {
3569
+ if (firstError === null) {
3570
+ firstError = err instanceof Error ? err : new Error(String(err));
3571
+ }
3572
+ finishOne();
3573
+ });
3574
+ worker.on("exit", (code) => {
3575
+ if (remaining > 0 && code !== 0 && firstError === null) {
3576
+ firstError = new Error(`unguard worker exited with code ${code}`);
3577
+ finishOne();
3578
+ }
3579
+ });
3580
+ dispatchNext(worker);
3581
+ }
3582
+ }).finally(() => {
3583
+ for (const w of workers) w.terminate().catch(() => {
3584
+ });
3585
+ });
3586
+ return results;
3587
+ }
3588
+
3569
3589
  // src/scan/analyze.ts
3570
- function analyzeFiles(files, rules) {
3590
+ async function analyzeFiles(files, rules, options) {
3571
3591
  const tsRules = rules.filter(isTSRule);
3572
3592
  const crossFileRules = rules.filter((r) => !isTSRule(r));
3573
3593
  const indexNeeds = collectIndexNeeds(crossFileRules);
@@ -3576,24 +3596,46 @@ function analyzeFiles(files, rules) {
3576
3596
  }
3577
3597
  const groupConfigs = mergeCompatibleGroups(groupFilesByTsconfig(files));
3578
3598
  if (groupConfigs.length === 0) return [];
3579
- const allDiagnostics = [];
3580
- const allFiles = /* @__PURE__ */ new Map();
3599
+ const concurrency = resolveConcurrency(options.concurrency, groupConfigs.length);
3600
+ if (concurrency > 1 && groupConfigs.length > 1 && workersAvailable()) {
3601
+ return await runGroupsViaWorkers(groupConfigs, rules, indexNeeds, concurrency);
3602
+ }
3603
+ return runGroupsSerial(groupConfigs, tsRules, crossFileRules, indexNeeds);
3604
+ }
3605
+ function runGroupsSerial(groupConfigs, tsRules, crossFileRules, indexNeeds) {
3581
3606
  const programCache = createProgramBuildCache();
3607
+ const allDiagnostics = [];
3582
3608
  for (const groupConfig of groupConfigs) {
3583
- const projectIndex = createProjectIndex();
3584
- const program = createProgramForGroup(groupConfig, { expandProjectFiles: true, cache: programCache });
3585
- const allowed = new Set(groupConfig.scanFiles);
3586
- const collectFiles = indexNeeds.size > 0 ? new Set(expandProjectFiles(groupConfig).filter(isAnalyzableSourcePath)) : /* @__PURE__ */ new Set();
3587
- const { diagnostics } = collectProject(program, tsRules, allowed, {
3588
- collectFiles,
3589
- needs: indexNeeds,
3590
- index: projectIndex
3591
- });
3592
- allDiagnostics.push(...diagnostics);
3593
- allDiagnostics.push(...runCrossFileRules(crossFileRules, projectIndex, allowed));
3594
- addFileData(allFiles, projectIndex, allowed);
3609
+ const groupDiags = analyzeGroup(groupConfig, tsRules, crossFileRules, indexNeeds, programCache);
3610
+ allDiagnostics.push(...groupDiags);
3595
3611
  }
3596
- return finalizeDiagnostics(allDiagnostics, allFiles);
3612
+ return dedupeDiagnostics(allDiagnostics);
3613
+ }
3614
+ async function runGroupsViaWorkers(groupConfigs, rules, indexNeeds, concurrency) {
3615
+ const ruleSpecs = rules.map((rule) => ({ id: rule.id, severity: rule.severity }));
3616
+ const tasks = groupConfigs.map((groupConfig, idx) => ({ id: idx, groupConfig }));
3617
+ const diagnosticsByTask = await runGroupsInWorkers(tasks, ruleSpecs, [...indexNeeds], concurrency);
3618
+ const allDiagnostics = [];
3619
+ for (const diags of diagnosticsByTask) {
3620
+ allDiagnostics.push(...diags);
3621
+ }
3622
+ return dedupeDiagnostics(allDiagnostics);
3623
+ }
3624
+ function analyzeGroup(groupConfig, tsRules, crossFileRules, indexNeeds, programCache) {
3625
+ const projectIndex = createProjectIndex();
3626
+ const program = createProgramForGroup(groupConfig, { expandProjectFiles: true, cache: programCache });
3627
+ const allowed = new Set(groupConfig.scanFiles);
3628
+ const collectFiles = indexNeeds.size > 0 ? new Set(expandProjectFiles(groupConfig).filter(isAnalyzableSourcePath)) : /* @__PURE__ */ new Set();
3629
+ const { diagnostics } = collectProject(program, tsRules, allowed, {
3630
+ collectFiles,
3631
+ needs: indexNeeds,
3632
+ index: projectIndex
3633
+ });
3634
+ const groupDiagnostics = [...diagnostics];
3635
+ groupDiagnostics.push(...runCrossFileRules(crossFileRules, projectIndex, allowed));
3636
+ const fileData = /* @__PURE__ */ new Map();
3637
+ addFileData(fileData, projectIndex, allowed);
3638
+ return finalizeDiagnostics(groupDiagnostics, fileData);
3597
3639
  }
3598
3640
  function analyzeSourceOnlyFiles(files, tsRules, crossFileRules, indexNeeds) {
3599
3641
  const diagnostics = [];
@@ -3685,6 +3727,13 @@ function finalizeDiagnostics(diagnostics, files) {
3685
3727
  }
3686
3728
  return dedupeDiagnostics(finalized);
3687
3729
  }
3730
+ function resolveConcurrency(requested, groupCount) {
3731
+ if (requested !== void 0) {
3732
+ if (!Number.isInteger(requested) || requested < 1) return 1;
3733
+ return Math.min(requested, groupCount);
3734
+ }
3735
+ return 1;
3736
+ }
3688
3737
  function annotateAndFilter(diagnostics, comments, source) {
3689
3738
  if (diagnostics.length === 0) return diagnostics;
3690
3739
  if (comments.length === 0) return diagnostics;
@@ -3791,145 +3840,11 @@ function lineAt(source, offset) {
3791
3840
  return line;
3792
3841
  }
3793
3842
 
3794
- // src/scan/discover.ts
3795
- import fg from "fast-glob";
3796
- import ignore from "ignore";
3797
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
3798
- import { relative, resolve as resolve2, sep } from "path";
3799
- async function discoverFiles(config) {
3800
- const globs = expandGlobs(config.paths);
3801
- const discoveredFiles = await fg(globs, {
3802
- ignore: config.ignore,
3803
- absolute: true
3804
- });
3805
- if (!config.useGitIgnore) return discoveredFiles;
3806
- return applyGitIgnore(discoveredFiles);
3807
- }
3808
- function expandGlobs(paths) {
3809
- return paths.map((p) => {
3810
- if (p === ".") return "./**/*.{ts,cts,mts,tsx}";
3811
- if (p.endsWith("/")) return `${p}**/*.{ts,cts,mts,tsx}`;
3812
- if (!p.includes("*") && !p.endsWith(".ts") && !p.endsWith(".tsx") && !p.endsWith(".cts") && !p.endsWith(".mts")) {
3813
- return `${p}/**/*.{ts,cts,mts,tsx}`;
3814
- }
3815
- return p;
3816
- });
3817
- }
3818
- function applyGitIgnore(files) {
3819
- const gitIgnorePath = resolve2(process.cwd(), ".gitignore");
3820
- if (!existsSync2(gitIgnorePath)) return files;
3821
- const matcher = ignore().add(readFileSync2(gitIgnorePath, "utf8"));
3822
- return files.filter((file) => {
3823
- const rel = relative(process.cwd(), file);
3824
- if (rel.startsWith("..")) return true;
3825
- const normalized = rel.split(sep).join("/");
3826
- return !matcher.ignores(normalized);
3827
- });
3828
- }
3829
-
3830
- // src/scan/policy.ts
3831
- function buildRuleDescriptors(rules) {
3832
- return rules.map((rule) => {
3833
- const metadata = getRuleMetadata(rule.id);
3834
- return {
3835
- rule,
3836
- category: metadata.category,
3837
- tags: metadata.tags
3838
- };
3839
- });
3840
- }
3841
- function resolveActiveRules(descriptors, config) {
3842
- const selectedRules = config.rules ? new Set(config.rules) : null;
3843
- const active = [];
3844
- for (const descriptor of descriptors) {
3845
- if (selectedRules && !selectedRules.has(descriptor.rule.id)) continue;
3846
- const resolvedSeverity = resolveRuleSeverity(descriptor, config);
3847
- if (resolvedSeverity === "off") continue;
3848
- if (descriptor.rule.severity === resolvedSeverity) {
3849
- active.push(descriptor.rule);
3850
- continue;
3851
- }
3852
- active.push({ ...descriptor.rule, severity: resolvedSeverity });
3853
- }
3854
- return active;
3855
- }
3856
- function resolveRuleSeverity(descriptor, config) {
3857
- let severity = descriptor.rule.severity;
3858
- for (const entry of config.rulePolicy) {
3859
- if (!matchesSelector(descriptor, entry.selector)) continue;
3860
- severity = entry.severity;
3861
- }
3862
- if (severity === "off") return "off";
3863
- if (config.strict) return "error";
3864
- return severity;
3865
- }
3866
- function matchesSelector(descriptor, selector) {
3867
- if (selector.startsWith("category:")) {
3868
- return descriptor.category === selector.slice("category:".length);
3869
- }
3870
- if (selector.startsWith("tag:")) {
3871
- return descriptor.tags.includes(selector.slice("tag:".length));
3872
- }
3873
- if (selector === descriptor.rule.id) return true;
3874
- if (!selector.includes("*")) return false;
3875
- const regex = new RegExp(`^${escapeRegex(selector).replaceAll("\\*", ".*")}$`);
3876
- return regex.test(descriptor.rule.id);
3877
- }
3878
- function escapeRegex(value) {
3879
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3880
- }
3881
- function finalizeScanResult(diagnostics, fileCount, config) {
3882
- const visibleDiagnostics = config.showSeverities ? diagnostics.filter((d) => config.showSeverities?.has(d.severity)) : diagnostics;
3883
- return {
3884
- diagnostics,
3885
- visibleDiagnostics,
3886
- fileCount,
3887
- exitCode: computeExitCode(diagnostics, config.failOn)
3888
- };
3889
- }
3890
- function toScanResult(execution) {
3891
- return {
3892
- diagnostics: execution.diagnostics,
3893
- fileCount: execution.fileCount
3894
- };
3895
- }
3896
- function computeExitCode(diagnostics, failOn) {
3897
- if (failOn === "none") return 0;
3898
- const hasError = diagnostics.some((d) => d.severity === "error");
3899
- const hasWarning = diagnostics.some((d) => d.severity === "warning");
3900
- const hasInfo = diagnostics.some((d) => d.severity === "info");
3901
- if (failOn === "error") return hasError ? 2 : 0;
3902
- if (failOn === "warning") {
3903
- if (hasError) return 2;
3904
- return hasWarning ? 1 : 0;
3905
- }
3906
- if (hasError) return 2;
3907
- if (hasWarning || hasInfo) return 1;
3908
- return 0;
3909
- }
3910
-
3911
- // src/engine.ts
3912
- async function executeScan(options) {
3913
- const config = resolveScanConfig(options);
3914
- const files = await discoverFiles(config);
3915
- const descriptors = buildRuleDescriptors(allRules);
3916
- const activeRules = resolveActiveRules(descriptors, config);
3917
- const diagnostics = analyzeFiles(files, activeRules);
3918
- return finalizeScanResult(diagnostics, files.length, config);
3919
- }
3920
- async function scan(options) {
3921
- const execution = await executeScan(options);
3922
- return toScanResult(execution);
3923
- }
3924
-
3925
3843
  export {
3844
+ isTSRule,
3926
3845
  allRules,
3927
3846
  getRuleMetadata,
3928
- toRulePolicyEntries,
3929
- isRulePolicySeverity,
3930
- isSeverity,
3931
- isFailOn,
3932
- executeScan,
3933
- scan
3847
+ analyzeFiles,
3848
+ analyzeGroup
3934
3849
  };
3935
- //# sourceMappingURL=chunk-VHRD75ET.js.map
3850
+ //# sourceMappingURL=chunk-BLCVXVLX.js.map