@atlisp/lint 0.2.10 → 0.2.12

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
@@ -128,6 +128,7 @@ npx @atlisp/lint --format-check
128
128
  | `shadow_builtin` | warn | 最佳实践 | defun 覆盖内置函数 |
129
129
  | `dynamic_doc` | warn | 最佳实践 | C: 命令缺少 (princ) |
130
130
  | `open_without_close` | warn | 最佳实践 | open/close 数量不匹配 |
131
+ | `strcat_or_arg` | error | 正确性 | strcat 参数中使用 or(or 返回 T/nil) |
131
132
  | `strcat_usage` | warn | 最佳实践 | + 拼接而非 strcat |
132
133
  | `cond_simplify` | warn | 风格 | 单分支 cond 可简化为 if |
133
134
  | `arg_count` | warn | 最佳实践 | 参数数量不匹配 |
@@ -59,13 +59,14 @@
59
59
  "extra_parens": "warn",
60
60
  "arg_count": "error",
61
61
  "builtin_arg_count": "error",
62
+ "strcat_or_arg": "error",
62
63
  "strcat_usage": "warn",
63
64
  "cond_simplify": "warn",
64
65
  "quote_style": "warn",
65
66
  "eq_usage": "warn",
66
67
  "lambda_syntax": "off",
67
68
  "comment_style": "warn",
68
- "empty_catch": "warn",
69
+ "empty_catch": "info",
69
70
  "nth_usage": "warn",
70
71
  "append_single": "warn",
71
72
  "setq_multiple": "warn",
@@ -59,7 +59,7 @@
59
59
  "duplicate_defun": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
60
60
  "dynamic_doc": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
61
61
  "empty_branch": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
62
- "empty_catch": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
62
+ "empty_catch": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "info" },
63
63
  "empty_comment": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "info" },
64
64
  "encoding": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "error" },
65
65
  "entget_in_loop": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
@@ -111,6 +111,7 @@
111
111
  "shadow_builtin": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
112
112
  "single_arg_and_or": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "info" },
113
113
  "startapp": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
114
+ "strcat_or_arg": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "error" },
114
115
  "strcat_usage": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "info" },
115
116
  "string_concat_loop": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "info" },
116
117
  "token_in_url": { "type": "string", "enum": ["off", "info", "warn", "error"], "default": "warn" },
@@ -195,6 +196,13 @@
195
196
  "walk_exclude": { "type": "array", "items": { "type": "string" }, "default": [".vscode", "vendor", ".git"] },
196
197
  "defmacro_allow_files": { "type": "array", "items": { "type": "string" }, "default": ["compat-cl"] }
197
198
  }
199
+ },
200
+ "project_analysis": {
201
+ "type": "object",
202
+ "properties": {
203
+ "maxFiles": { "type": "integer", "minimum": 1, "default": 500, "description": "Maximum number of files for cross-file analysis. Exceeding this threshold skips project analysis." },
204
+ "batchSize": { "type": "integer", "minimum": 1, "default": 50, "description": "Files processed per batch (reserved for future streaming)" }
205
+ }
198
206
  }
199
207
  }
200
208
  }
@@ -6,4 +6,11 @@ export declare function checkDanglingDefun(file: string, allDefuns: Map<string,
6
6
  file: string;
7
7
  line: number;
8
8
  }[]>): Issue[];
9
+ export declare function checkDanglingDefunFromDefs(localDefuns: {
10
+ name: string;
11
+ line: number;
12
+ }[], allReferences: Map<string, {
13
+ file: string;
14
+ line: number;
15
+ }[]>, file: string): Issue[];
9
16
  //# sourceMappingURL=dangling-defun.d.ts.map
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.checkDanglingDefun = checkDanglingDefun;
37
+ exports.checkDanglingDefunFromDefs = checkDanglingDefunFromDefs;
37
38
  const locale_1 = require("../locale");
38
39
  const parser_1 = require("@atlisp/parser");
39
40
  const fs = __importStar(require("fs"));
@@ -64,4 +65,22 @@ function checkDanglingDefun(file, allDefuns, allReferences) {
64
65
  }
65
66
  return issues;
66
67
  }
68
+ function checkDanglingDefunFromDefs(localDefuns, allReferences, file) {
69
+ const issues = [];
70
+ for (const defun of localDefuns) {
71
+ const refs = allReferences.get(defun.name) || [];
72
+ const calledElsewhere = refs.some(r => r.file !== file);
73
+ const calledLocally = refs.some(r => r.file === file);
74
+ if (!calledLocally && !calledElsewhere) {
75
+ issues.push({
76
+ file,
77
+ line: defun.line,
78
+ severity: 'warn',
79
+ rule: 'dangling_defun',
80
+ message: (0, locale_1.t)('dangling_defun', defun.name),
81
+ });
82
+ }
83
+ }
84
+ return issues;
85
+ }
67
86
  //# sourceMappingURL=dangling-defun.js.map
@@ -1,3 +1,4 @@
1
1
  import { Issue } from '../types';
2
2
  export declare function checkUnusedPackageDep(file: string): Issue[];
3
+ export declare function checkUnusedPackageDepFromContent(file: string, content: string): Issue[];
3
4
  //# sourceMappingURL=unused-package-dep.d.ts.map
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.checkUnusedPackageDep = checkUnusedPackageDep;
37
+ exports.checkUnusedPackageDepFromContent = checkUnusedPackageDepFromContent;
37
38
  const locale_1 = require("../locale");
38
39
  const parser_1 = require("@atlisp/parser");
39
40
  const fs = __importStar(require("fs"));
@@ -90,4 +91,56 @@ function checkUnusedPackageDep(file) {
90
91
  }
91
92
  return issues;
92
93
  }
94
+ function checkUnusedPackageDepFromContent(file, content) {
95
+ const issues = [];
96
+ const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
97
+ const inPackageNodes = (0, parser_1.astFindAll)(ast, n => (0, parser_1.astIsList)(n, 'in-package'));
98
+ if (inPackageNodes.length === 0)
99
+ return issues;
100
+ const allPackages = new Set();
101
+ for (const node of inPackageNodes) {
102
+ if (node.children && node.children.length >= 2 && (0, parser_1.astIsSymbol)(node.children[1])) {
103
+ allPackages.add(node.children[1].name);
104
+ }
105
+ }
106
+ const usedSymbols = new Set();
107
+ const allLists = (0, parser_1.astFindAll)(ast, n => n.type === 'list');
108
+ for (const node of allLists) {
109
+ if (!node.children || node.children.length === 0)
110
+ continue;
111
+ const car = node.children[0];
112
+ if (car.type === 'symbol' && car.name) {
113
+ const parts = car.name.split(':');
114
+ if (parts.length === 2 && parts[0] && parts[1]) {
115
+ usedSymbols.add(parts[0]);
116
+ }
117
+ }
118
+ for (let i = 1; i < node.children.length; i++) {
119
+ const child = node.children[i];
120
+ if (child.type === 'symbol' && child.name) {
121
+ const parts = child.name.split(':');
122
+ if (parts.length === 2 && parts[0] && parts[1]) {
123
+ usedSymbols.add(parts[0]);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ const mainPackage = inPackageNodes.length > 0 && inPackageNodes[0].children && inPackageNodes[0].children[1]
129
+ ? inPackageNodes[0].children[1].name || ''
130
+ : '';
131
+ allPackages.delete(mainPackage);
132
+ for (const pkg of allPackages) {
133
+ if (!usedSymbols.has(pkg)) {
134
+ const node = inPackageNodes.find(n => n.children && n.children.length >= 2 && (0, parser_1.astIsSymbol)(n.children[1]) && n.children[1].name === pkg);
135
+ issues.push({
136
+ file,
137
+ line: node ? node.pos.line : 1,
138
+ severity: 'warn',
139
+ rule: 'unused_package_dep',
140
+ message: (0, locale_1.t)('unused_package_dep', pkg),
141
+ });
142
+ }
143
+ }
144
+ return issues;
145
+ }
93
146
  //# sourceMappingURL=unused-package-dep.js.map
package/dist/config.js CHANGED
@@ -102,13 +102,14 @@ const DEFAULT_CONFIG = {
102
102
  extra_parens: 'warn',
103
103
  arg_count: 'warn',
104
104
  builtin_arg_count: 'error',
105
+ strcat_or_arg: 'error',
105
106
  strcat_usage: 'info',
106
107
  cond_simplify: 'info',
107
108
  quote_style: 'info',
108
109
  eq_usage: 'warn',
109
110
  lambda_syntax: 'off',
110
111
  comment_style: 'info',
111
- empty_catch: 'warn',
112
+ empty_catch: 'info',
112
113
  nth_usage: 'info',
113
114
  append_single: 'info',
114
115
  setq_multiple: 'warn',
@@ -120,7 +121,7 @@ const DEFAULT_CONFIG = {
120
121
  shadow_builtin: 'warn',
121
122
  dynamic_doc: 'warn',
122
123
  loop_optimization: 'info',
123
- format_indent: 'warn',
124
+ format_indent: 'off',
124
125
  redundant_if: 'info',
125
126
  unused_catch_result: 'info',
126
127
  string_concat_loop: 'info',
@@ -171,6 +172,10 @@ const DEFAULT_CONFIG = {
171
172
  walk_exclude: ['.vscode', 'vendor', '.git'],
172
173
  defmacro_allow_files: ['compat-cl'],
173
174
  },
175
+ project_analysis: {
176
+ maxFiles: 500,
177
+ batchSize: 50,
178
+ },
174
179
  };
175
180
  function loadConfig(configPath) {
176
181
  if (configPath) {
package/dist/locale.js CHANGED
@@ -94,6 +94,7 @@ const messages = {
94
94
  'index.watch_status': '{0} files checked: {1} error(s), {2} warning(s)',
95
95
  'index.format_unstable': 'Formatting is not idempotent — re-running formatCode produces different output',
96
96
  unused_catch_result: 'vl-catch-all-apply result used as boolean — use vl-catch-all-error-p to check',
97
+ strcat_or_arg: 'or in strcat argument — or returns T/nil, not a string',
97
98
  string_concat_loop: 'String concatenation inside {0} — move strcat outside the loop for performance',
98
99
  if_without_else: 'if without else branch — else is implicitly nil, which is idiomatic in Lisp',
99
100
  redundant_list: '(list) evaluates to nil — use nil directly',
@@ -222,6 +223,7 @@ Options:
222
223
  'index.watch_status': '已检查 {0} 个文件: {1} 个错误, {2} 个警告',
223
224
  'index.format_unstable': '格式化结果不稳定——再次格式化后输出不同',
224
225
  unused_catch_result: 'vl-catch-all-apply 的结果被当作布尔值使用——应用 vl-catch-all-error-p 检查',
226
+ strcat_or_arg: 'strcat 参数中使用了 or 函数调用(or 返回 T 或 nil,不是字符串)',
225
227
  string_concat_loop: '循环 {0} 内使用了字符串拼接——为提升性能请将 strcat 移到循环外',
226
228
  if_without_else: 'if 没有 else 分支——else 默认为 nil 是 Lisp 惯用写法',
227
229
  redundant_list: '(list) 等于 nil——请直接使用 nil',
package/dist/presets.js CHANGED
@@ -50,13 +50,14 @@ exports.PRESETS = {
50
50
  extra_parens: 'warn',
51
51
  arg_count: 'warn',
52
52
  builtin_arg_count: 'error',
53
+ strcat_or_arg: 'error',
53
54
  strcat_usage: 'info',
54
55
  cond_simplify: 'info',
55
56
  quote_style: 'info',
56
57
  eq_usage: 'warn',
57
58
  lambda_syntax: 'off',
58
59
  comment_style: 'info',
59
- empty_catch: 'warn',
60
+ empty_catch: 'info',
60
61
  nth_usage: 'info',
61
62
  append_single: 'info',
62
63
  setq_multiple: 'warn',
@@ -133,6 +134,7 @@ exports.PRESETS = {
133
134
  extra_parens: 'error',
134
135
  arg_count: 'error',
135
136
  builtin_arg_count: 'error',
137
+ strcat_or_arg: 'error',
136
138
  strcat_usage: 'error',
137
139
  cond_simplify: 'error',
138
140
  quote_style: 'error',
@@ -151,7 +153,7 @@ exports.PRESETS = {
151
153
  shadow_builtin: 'error',
152
154
  dynamic_doc: 'error',
153
155
  loop_optimization: 'warn',
154
- format_indent: 'warn',
156
+ format_indent: 'off',
155
157
  redundant_if: 'error',
156
158
  module_registration: 'error',
157
159
  namespace_header: 'error',
package/dist/project.js CHANGED
@@ -42,9 +42,8 @@ const missing_export_1 = require("./checks/missing-export");
42
42
  const unused_package_dep_1 = require("./checks/unused-package-dep");
43
43
  const duplicate_defun_1 = require("./checks/duplicate-defun");
44
44
  const locale_1 = require("./locale");
45
- function collectDefuns(file) {
46
- const content = fs.readFileSync(file, 'utf-8');
47
- const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
45
+ const locale_2 = require("./locale");
46
+ function collectDefunsFromAst(ast) {
48
47
  const results = [];
49
48
  const defunNodes = (0, parser_1.astFindAll)(ast, n => (0, parser_1.astIsList)(n, 'defun') || (0, parser_1.astIsList)(n, 'defun-q'));
50
49
  for (const node of defunNodes) {
@@ -54,9 +53,7 @@ function collectDefuns(file) {
54
53
  }
55
54
  return results;
56
55
  }
57
- function collectReferences(file) {
58
- const content = fs.readFileSync(file, 'utf-8');
59
- const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
56
+ function collectReferencesFromAst(ast) {
60
57
  const results = [];
61
58
  const allCalls = (0, parser_1.astFindAll)(ast, n => {
62
59
  if (n.type !== 'list' || !n.children || n.children.length === 0)
@@ -71,9 +68,7 @@ function collectReferences(file) {
71
68
  }
72
69
  return results;
73
70
  }
74
- function countFunctionArgs(file) {
75
- const content = fs.readFileSync(file, 'utf-8');
76
- const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
71
+ function countFunctionArgsFromAst(ast) {
77
72
  const result = new Map();
78
73
  const defunNodes = (0, parser_1.astFindAll)(ast, n => (0, parser_1.astIsList)(n, 'defun') || (0, parser_1.astIsList)(n, 'defun-q'));
79
74
  for (const node of defunNodes) {
@@ -100,9 +95,7 @@ function countFunctionArgs(file) {
100
95
  }
101
96
  return result;
102
97
  }
103
- function countCallArgs(file) {
104
- const content = fs.readFileSync(file, 'utf-8');
105
- const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
98
+ function countCallArgsFromAst(ast) {
106
99
  const result = new Map();
107
100
  const allCalls = (0, parser_1.astFindAll)(ast, n => {
108
101
  if (n.type !== 'list' || !n.children || n.children.length === 0)
@@ -120,9 +113,7 @@ function countCallArgs(file) {
120
113
  }
121
114
  return result;
122
115
  }
123
- function findModuleDeps(filepath) {
124
- const content = fs.readFileSync(filepath, 'utf-8');
125
- const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
116
+ function findModuleDepsFromAst(ast) {
126
117
  const imports = [];
127
118
  const exports = [];
128
119
  const inPackageNodes = (0, parser_1.astFindAll)(ast, n => (0, parser_1.astIsList)(n, 'in-package'));
@@ -154,56 +145,148 @@ function findModuleDeps(filepath) {
154
145
  }
155
146
  return { imports, exports };
156
147
  }
157
- function collectAllModuleDeps(files) {
158
- const deps = new Map();
159
- for (const f of files) {
160
- deps.set(f, findModuleDeps(f));
148
+ function checkArgCountProject(filepath, defunArgs, callArgCounts, symbols, relPath) {
149
+ const issues = [];
150
+ const fileArgs = defunArgs.get(filepath);
151
+ if (!fileArgs)
152
+ return issues;
153
+ const callArgs = callArgCounts.get(filepath);
154
+ if (!callArgs)
155
+ return issues;
156
+ for (const [fnName, calls] of callArgs) {
157
+ const defs = symbols.defuns.get(fnName);
158
+ if (!defs)
159
+ continue;
160
+ const defArgCount = fileArgs.get(fnName);
161
+ if (defArgCount === undefined)
162
+ continue;
163
+ let allMatch = true;
164
+ for (const def of defs) {
165
+ const otherFileArgs = defunArgs.get(def.file);
166
+ if (otherFileArgs && otherFileArgs.get(fnName) !== defArgCount) {
167
+ allMatch = false;
168
+ break;
169
+ }
170
+ }
171
+ if (!allMatch)
172
+ continue;
173
+ for (const call of calls) {
174
+ if (call.count !== defArgCount) {
175
+ issues.push({
176
+ file: relPath,
177
+ line: call.line,
178
+ severity: 'warn',
179
+ rule: 'arg_count_project',
180
+ message: (0, locale_2.t)('arg_count_project', fnName, String(call.count), String(defArgCount)),
181
+ });
182
+ }
183
+ }
184
+ }
185
+ return issues;
186
+ }
187
+ function checkModuleCycle(moduleDeps, rootDir, filepath, relPath, allIssues) {
188
+ const visited = new Set();
189
+ const stack = new Set();
190
+ const cyclePath = [];
191
+ const dfs = (current) => {
192
+ if (stack.has(current)) {
193
+ const idx = cyclePath.indexOf(current);
194
+ const cycle = cyclePath.slice(idx).concat(current);
195
+ const displayCycle = cycle.map(c => path.relative(rootDir, c)).join(' → ');
196
+ allIssues.push({
197
+ file: relPath,
198
+ line: 1,
199
+ severity: 'warn',
200
+ rule: 'module_cycle',
201
+ message: `Module dependency cycle detected: ${displayCycle}`,
202
+ });
203
+ return true;
204
+ }
205
+ if (visited.has(current))
206
+ return false;
207
+ visited.add(current);
208
+ stack.add(current);
209
+ cyclePath.push(current);
210
+ const deps = moduleDeps.get(current);
211
+ if (deps) {
212
+ for (const imp of deps.imports) {
213
+ for (const [otherFile, otherDeps] of moduleDeps) {
214
+ if (otherDeps.exports.includes(imp)) {
215
+ if (dfs(otherFile))
216
+ return true;
217
+ }
218
+ }
219
+ }
220
+ }
221
+ cyclePath.pop();
222
+ stack.delete(current);
223
+ return false;
224
+ };
225
+ for (const f of moduleDeps.keys()) {
226
+ if (!visited.has(f)) {
227
+ dfs(f);
228
+ }
161
229
  }
162
- return deps;
163
230
  }
164
231
  function lintProject(files, config, rootDir) {
165
232
  (0, locale_1.setLocale)(config.locale || 'zh');
166
233
  const allIssues = [];
167
234
  const symbols = { defuns: new Map(), references: new Map() };
235
+ const maxFiles = config.project_analysis?.maxFiles ?? 500;
236
+ if (files.length > maxFiles) {
237
+ const msg = `${files.length} files exceeds project_analysis.maxFiles (${maxFiles}). Skipping cross-file analysis. To increase, set project_analysis.maxFiles in config.`;
238
+ console.warn(`[atlisp-lint] ${msg}`);
239
+ return [];
240
+ }
168
241
  const fileContents = new Map();
242
+ const defunArgs = new Map();
243
+ const callArgCounts = new Map();
244
+ const moduleDeps = new Map();
245
+ // Phase A: Streaming collection — one file at a time, keep only metadata
169
246
  for (const filepath of files) {
247
+ let content;
170
248
  try {
171
- const content = fs.readFileSync(filepath, 'utf-8');
172
- fileContents.set(filepath, content);
249
+ content = fs.readFileSync(filepath, 'utf-8');
173
250
  }
174
251
  catch {
175
252
  continue;
176
253
  }
177
- }
178
- // Build project symbol table
179
- for (const [filepath] of fileContents) {
180
- const defunList = collectDefuns(filepath);
254
+ const ast = (0, parser_1.parseAst)(content, { errorRecovery: true });
255
+ // Extract all metadata from a single AST parse
256
+ const defunList = collectDefunsFromAst(ast);
181
257
  for (const d of defunList) {
182
258
  const list = symbols.defuns.get(d.name) || [];
183
259
  list.push({ file: filepath, line: d.line });
184
260
  symbols.defuns.set(d.name, list);
185
261
  }
186
- const refList = collectReferences(filepath);
262
+ const refList = collectReferencesFromAst(ast);
187
263
  for (const r of refList) {
188
264
  const list = symbols.references.get(r.name) || [];
189
265
  list.push({ file: filepath, line: r.line });
190
266
  symbols.references.set(r.name, list);
191
267
  }
268
+ defunArgs.set(filepath, countFunctionArgsFromAst(ast));
269
+ callArgCounts.set(filepath, countCallArgsFromAst(ast));
270
+ moduleDeps.set(filepath, findModuleDepsFromAst(ast));
271
+ // Cache file content for checks that still need it
272
+ fileContents.set(filepath, content);
273
+ // AST goes out of scope here → GC reclaims it
192
274
  }
193
- // New: build module dependency graph
194
- const moduleDeps = collectAllModuleDeps(Array.from(fileContents.keys()));
195
- // New: collect function arg counts per file
196
- const defunArgs = new Map();
197
- for (const [filepath] of fileContents) {
198
- defunArgs.set(filepath, countFunctionArgs(filepath));
199
- }
275
+ // Phase B: Cross-file checks using only symbol tables
200
276
  for (const [filepath] of fileContents) {
201
277
  const relPath = path.relative(rootDir, filepath);
202
278
  const override = findProjectOverride(filepath);
203
279
  const checks = override?.checks || config.checks;
204
- // Existing checks
205
280
  if (checks['dangling_defun'] !== 'off') {
206
- const danglingIssues = (0, dangling_defun_1.checkDanglingDefun)(filepath, symbols.defuns, symbols.references);
281
+ const defunList = [];
282
+ for (const [name, locations] of symbols.defuns) {
283
+ for (const loc of locations) {
284
+ if (loc.file === filepath) {
285
+ defunList.push({ name, line: loc.line });
286
+ }
287
+ }
288
+ }
289
+ const danglingIssues = (0, dangling_defun_1.checkDanglingDefunFromDefs)(defunList, symbols.references, filepath);
207
290
  for (const iss of danglingIssues) {
208
291
  iss.file = relPath;
209
292
  allIssues.push(iss);
@@ -220,10 +303,13 @@ function lintProject(files, config, rootDir) {
220
303
  }
221
304
  }
222
305
  if (checks['unused_package_dep'] !== 'off') {
223
- const depIssues = (0, unused_package_dep_1.checkUnusedPackageDep)(filepath);
224
- for (const iss of depIssues) {
225
- iss.file = relPath;
226
- allIssues.push(iss);
306
+ const content = fileContents.get(filepath);
307
+ if (content) {
308
+ const depIssues = (0, unused_package_dep_1.checkUnusedPackageDepFromContent)(filepath, content);
309
+ for (const iss of depIssues) {
310
+ iss.file = relPath;
311
+ allIssues.push(iss);
312
+ }
227
313
  }
228
314
  }
229
315
  if (checks['duplicate_defun'] !== 'off') {
@@ -233,87 +319,19 @@ function lintProject(files, config, rootDir) {
233
319
  allIssues.push(iss);
234
320
  }
235
321
  }
236
- // New: module cycle detection
237
- if (checks['module_cycle'] !== 'off') {
238
- const visited = new Set();
239
- const stack = new Set();
240
- const cyclePath = [];
241
- const dfs = (current) => {
242
- if (stack.has(current)) {
243
- const idx = cyclePath.indexOf(current);
244
- const cycle = cyclePath.slice(idx).concat(current);
245
- const displayCycle = cycle.map(c => path.relative(rootDir, c)).join(' → ');
246
- allIssues.push({
247
- file: relPath,
248
- line: 1,
249
- severity: 'warn',
250
- rule: 'module_cycle',
251
- message: `Module dependency cycle detected: ${displayCycle}`,
252
- });
253
- return true;
254
- }
255
- if (visited.has(current))
256
- return false;
257
- visited.add(current);
258
- stack.add(current);
259
- cyclePath.push(current);
260
- const deps = moduleDeps.get(current);
261
- if (deps) {
262
- for (const imp of deps.imports) {
263
- for (const [otherFile, otherDeps] of moduleDeps) {
264
- if (otherDeps.exports.includes(imp)) {
265
- if (dfs(otherFile))
266
- return true;
267
- }
268
- }
269
- }
270
- }
271
- cyclePath.pop();
272
- stack.delete(current);
273
- return false;
274
- };
275
- for (const f of moduleDeps.keys()) {
276
- if (!visited.has(f)) {
277
- dfs(f);
278
- }
279
- }
280
- }
281
- // New: signature mismatch check
282
322
  if (checks['arg_count_project'] !== 'off') {
283
- const fileArgs = defunArgs.get(filepath);
284
- if (fileArgs) {
285
- const callArgs = countCallArgs(filepath);
286
- for (const [fnName, calls] of callArgs) {
287
- const defs = symbols.defuns.get(fnName);
288
- if (!defs)
289
- continue;
290
- const defArgCount = fileArgs.get(fnName);
291
- if (defArgCount === undefined)
292
- continue;
293
- // Only check if all definitions agree
294
- let allMatch = true;
295
- for (const def of defs) {
296
- const otherFileArgs = defunArgs.get(def.file);
297
- if (otherFileArgs && otherFileArgs.get(fnName) !== defArgCount) {
298
- allMatch = false;
299
- break;
300
- }
301
- }
302
- if (!allMatch)
303
- continue;
304
- for (const call of calls) {
305
- if (call.count !== defArgCount) {
306
- allIssues.push({
307
- file: relPath,
308
- line: call.line,
309
- severity: 'warn',
310
- rule: 'arg_count_project',
311
- message: `Function '${fnName}' called with ${call.count} arguments but defined with ${defArgCount}`,
312
- });
313
- }
314
- }
315
- }
316
- }
323
+ const argIssues = checkArgCountProject(filepath, defunArgs, callArgCounts, symbols, relPath);
324
+ allIssues.push(...argIssues);
325
+ }
326
+ }
327
+ // Module cycle check: run DFS once (not per file)
328
+ const cycleCheckFile = fileContents.keys().next().value;
329
+ if (cycleCheckFile) {
330
+ const override = findProjectOverride(cycleCheckFile);
331
+ const checks = override?.checks || config.checks;
332
+ if (checks['module_cycle'] !== 'off') {
333
+ const relPath = path.relative(rootDir, cycleCheckFile);
334
+ checkModuleCycle(moduleDeps, rootDir, cycleCheckFile, relPath, allIssues);
317
335
  }
318
336
  }
319
337
  return allIssues;
package/dist/rules.js CHANGED
@@ -179,6 +179,12 @@ exports.RULES = [
179
179
  { name: 'shadow_builtin', defaultSeverity: 'warn', description: '检测覆盖内置函数的 defun', category: '最佳实践' },
180
180
  { name: 'single_arg_and_or', defaultSeverity: 'info', description: '检测单参数 and/or 建议简化', category: '风格' },
181
181
  { name: 'startapp', defaultSeverity: 'warn', description: '检测 startapp 调用(启动外部程序)', category: '安全' },
182
+ {
183
+ name: 'strcat_or_arg',
184
+ defaultSeverity: 'error',
185
+ description: '检测 strcat 参数中使用 or(or 返回 T/nil,非字符串)',
186
+ category: '正确性',
187
+ },
182
188
  {
183
189
  name: 'strcat_usage',
184
190
  defaultSeverity: 'info',
package/dist/types.d.ts CHANGED
@@ -42,6 +42,10 @@ export interface SbclConfig {
42
42
  walk_exclude: string[];
43
43
  defmacro_allow_files: string[];
44
44
  }
45
+ export interface ProjectAnalysisConfig {
46
+ maxFiles: number;
47
+ batchSize: number;
48
+ }
45
49
  export interface SourceConfig {
46
50
  globs: string[];
47
51
  exclude: string[];
@@ -66,6 +70,7 @@ export interface LintConfig {
66
70
  namespace_header: NamespaceHeaderConfig;
67
71
  bare_function_names: BareFunctionNamesConfig;
68
72
  sbcl: SbclConfig;
73
+ project_analysis: ProjectAnalysisConfig;
69
74
  preset?: string;
70
75
  }
71
76
  export interface FormattedResult {
package/dist/validate.js CHANGED
@@ -62,6 +62,7 @@ const VALID_RULES = [
62
62
  'shadow_builtin',
63
63
  'single_arg_and_or',
64
64
  'startapp',
65
+ 'strcat_or_arg',
65
66
  'strcat_usage',
66
67
  'token_in_url',
67
68
  'trailing_paren',
@@ -664,6 +664,29 @@ function runChecksWithVisitor(ast, file, config, disableMap) {
664
664
  }
665
665
  });
666
666
  }
667
+ // strcat_or_arg
668
+ if (checks['strcat_or_arg'] !== 'off') {
669
+ visitor.on('*', node => {
670
+ if (node.type === 'list' &&
671
+ node.children &&
672
+ node.children.length > 1 &&
673
+ node.children[0].type === 'symbol' &&
674
+ node.children[0].name === 'strcat') {
675
+ if (isInQuote(node))
676
+ return;
677
+ for (let i = 1; i < node.children.length; i++) {
678
+ const arg = node.children[i];
679
+ if (arg.type === 'list' &&
680
+ arg.children &&
681
+ arg.children.length > 0 &&
682
+ arg.children[0].type === 'symbol' &&
683
+ arg.children[0].name === 'or') {
684
+ addIssue('strcat_or_arg', arg.pos.line, (0, locale_1.t)('strcat_or_arg'));
685
+ }
686
+ }
687
+ }
688
+ });
689
+ }
667
690
  // redundant_list
668
691
  if (checks['redundant_list'] !== 'off') {
669
692
  visitor.on('list', node => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlisp/lint",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "AutoLISP static analysis tool — parens, security, conventions, SBCL syntax validation",
5
5
  "keywords": [
6
6
  "CAD",