devassist-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -0
  3. package/bin/devassist.js +220 -0
  4. package/package.json +44 -0
  5. package/src/ai/adapter.js +464 -0
  6. package/src/ai/providers/claude.js +80 -0
  7. package/src/ai/providers/hunyuan.js +87 -0
  8. package/src/ai/providers/openai.js +74 -0
  9. package/src/ai/providers/qwen.js +81 -0
  10. package/src/cli/commands/ai.js +944 -0
  11. package/src/cli/commands/ask.js +79 -0
  12. package/src/cli/commands/backup.js +30 -0
  13. package/src/cli/commands/check.js +327 -0
  14. package/src/cli/commands/clean.js +130 -0
  15. package/src/cli/commands/comparison-report.js +326 -0
  16. package/src/cli/commands/convention.js +91 -0
  17. package/src/cli/commands/debt.js +49 -0
  18. package/src/cli/commands/deploy.js +88 -0
  19. package/src/cli/commands/diff.js +193 -0
  20. package/src/cli/commands/doctor.js +186 -0
  21. package/src/cli/commands/fix.js +195 -0
  22. package/src/cli/commands/init.js +431 -0
  23. package/src/cli/commands/inject.js +254 -0
  24. package/src/cli/commands/report.js +310 -0
  25. package/src/cli/commands/restore.js +78 -0
  26. package/src/cli/commands/schema.js +93 -0
  27. package/src/cli/commands/watch.js +212 -0
  28. package/src/cli/shared/code-context.js +51 -0
  29. package/src/cli/shared/config-loader.js +89 -0
  30. package/src/cli/shared/file-collector.js +116 -0
  31. package/src/cli/shared/inline-ignore.js +142 -0
  32. package/src/cli/shared/watch-list.js +281 -0
  33. package/src/core/event-bus.js +83 -0
  34. package/src/core/fsm.js +103 -0
  35. package/src/core/logger.js +54 -0
  36. package/src/core/rule-engine.js +117 -0
  37. package/src/index.js +64 -0
  38. package/src/modules/dev-time/arch-risk-assessor.js +250 -0
  39. package/src/modules/dev-time/code-quality-guard.js +1340 -0
  40. package/src/modules/dev-time/convention-store.js +201 -0
  41. package/src/modules/dev-time/impact-analyzer.js +292 -0
  42. package/src/modules/dev-time/pre-deploy-guard.js +284 -0
  43. package/src/modules/dev-time/schema-registry.js +284 -0
  44. package/src/modules/dev-time/tech-debt-tracker.js +225 -0
  45. package/src/modules/dev-time/version-manager.js +280 -0
  46. package/src/modules/runtime/channel-agent.js +404 -0
  47. package/src/modules/runtime/channel-middleware.js +316 -0
  48. package/src/modules/runtime/index.js +64 -0
  49. package/src/modules/runtime/infrastructure-guard.js +582 -0
  50. package/src/modules/runtime/notification-center.js +443 -0
  51. package/src/modules/runtime/schema-gatekeeper.js +664 -0
  52. package/src/modules/runtime/tool-registry.js +329 -0
  53. package/templates/ci/github-actions.yml +60 -0
  54. package/templates/ci/gitlab-ci.yml +30 -0
  55. package/templates/ci/pre-commit-hook.sh +18 -0
  56. package/templates/git-hooks/pre-commit +61 -0
  57. package/tests/run-all.js +434 -0
  58. package/tests/test-layer2.js +461 -0
  59. package/tests/test-new-rules.js +157 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * devassist watch - Real-time file monitoring
3
+ *
4
+ * Watches project files for changes and runs checks on modified files.
5
+ * Provides instant feedback during AI-assisted development.
6
+ *
7
+ * Usage:
8
+ * devassist watch # Watch all source files
9
+ * devassist watch --ai # Watch + AI analysis on findings
10
+ * devassist watch --dir src # Watch specific directory
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { execSync } = require('child_process');
16
+
17
+ // Core
18
+ const { engine, RuleEngine } = require('../../core/rule-engine');
19
+
20
+ // Modules
21
+ const { ConventionStore } = require('../../modules/dev-time/convention-store');
22
+ const { codeQualityRules } = require('../../modules/dev-time/code-quality-guard');
23
+ const { ArchRiskAssessor } = require('../../modules/dev-time/arch-risk-assessor');
24
+
25
+ // Shared utilities
26
+ const { loadConfig } = require('../shared/config-loader');
27
+ const { filterIgnored } = require('../shared/inline-ignore');
28
+
29
+ const CODE_EXTENSIONS = /\.(js|ts|jsx|tsx|py|java)$/;
30
+ const SKIP_DIRS = ['node_modules', '.git', '.devassist', 'dist', 'build', 'vendor', '.gradle', '__pycache__'];
31
+
32
+ module.exports = async function watch(projectRoot, args, ui) {
33
+ const { printHeader, printFinding, c } = ui;
34
+ const useAI = args.includes('--ai');
35
+ const watchDir = args.includes('--dir') ? args[args.indexOf('--dir') + 1] : null;
36
+
37
+ printHeader('监控模式');
38
+
39
+ console.log(` ${c.gray('监控目录:')} ${watchDir || '整个项目'}`);
40
+ console.log(` ${c.gray('AI 分析:')} ${useAI ? '已启用' : '已禁用'}`);
41
+ console.log(` ${c.gray('按 Ctrl+C 停止')}\n`);
42
+
43
+ // Initialize convention store
44
+ const conventionStore = new ConventionStore(projectRoot);
45
+ const archAssessor = new ArchRiskAssessor(projectRoot);
46
+
47
+ // Register rules
48
+ engine.registerBatch(codeQualityRules);
49
+ engine.register({
50
+ id: 'convention-check',
51
+ severity: 'warn',
52
+ category: 'convention',
53
+ check(ctx) {
54
+ const allFindings = [];
55
+ for (const file of ctx.files) {
56
+ const findings = conventionStore.check(file.path, file.content);
57
+ allFindings.push(...findings);
58
+ }
59
+ return allFindings.length > 0 ? allFindings : null;
60
+ }
61
+ });
62
+
63
+ // Track watchers for cleanup
64
+ const watchers = [];
65
+ const debounceTimers = new Map();
66
+
67
+ // Watch a single file
68
+ function watchFile(filePath) {
69
+ try {
70
+ const watcher = fs.watch(filePath, { persistent: true }, (eventType) => {
71
+ if (eventType !== 'change') return;
72
+
73
+ // Debounce: wait 300ms after last change before checking
74
+ if (debounceTimers.has(filePath)) {
75
+ clearTimeout(debounceTimers.get(filePath));
76
+ }
77
+ debounceTimers.set(filePath, setTimeout(() => {
78
+ debounceTimers.delete(filePath);
79
+ checkFile(filePath);
80
+ }, 300));
81
+ });
82
+ watcher.on('error', () => {});
83
+ watchers.push(watcher);
84
+ } catch (_) {}
85
+ }
86
+
87
+ // Recursively set up watchers on directories
88
+ function watchDirRecursive(dir) {
89
+ let entries;
90
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
91
+
92
+ for (const entry of entries) {
93
+ if (SKIP_DIRS.includes(entry.name)) continue;
94
+ const full = path.join(dir, entry.name);
95
+ if (entry.isDirectory()) {
96
+ watchDirRecursive(full);
97
+ } else if (CODE_EXTENSIONS.test(entry.name) || /\.html?$/.test(entry.name)) {
98
+ watchFile(full);
99
+ }
100
+ }
101
+ }
102
+
103
+ // Check a single file
104
+ function checkFile(filePath) {
105
+ const relPath = path.relative(projectRoot, filePath);
106
+ const timestamp = new Date().toLocaleTimeString();
107
+
108
+ let content = '';
109
+ try { content = fs.readFileSync(filePath, 'utf-8'); } catch (_) { return; }
110
+
111
+ // Quick skip for HTML without script tags
112
+ if (/\.html?$/.test(filePath) && !/<script[\s>]/i.test(content)) return;
113
+
114
+ const ctx = {
115
+ files: [{ path: relPath, content, fullPath: filePath }],
116
+ projectRoot,
117
+ config: loadConfig(projectRoot),
118
+ };
119
+
120
+ const { findings: ruleFindings } = engine.run(ctx);
121
+ const archFindings = archAssessor.analyzeFile(relPath, content);
122
+ let allFindings = [...ruleFindings, ...archFindings];
123
+
124
+ // Apply inline ignores
125
+ allFindings = filterIgnored(allFindings, [{ path: relPath, content }]);
126
+
127
+ // Save findings for ai --analyze
128
+ saveWatchFindings(projectRoot, allFindings);
129
+
130
+ // Print header
131
+ console.log(`\n ${c.gray(`[${timestamp}]`)} ${c.bold(relPath)}`);
132
+
133
+ if (allFindings.length === 0) {
134
+ console.log(` ${c.green('OK')} — 未发现问题`);
135
+ return;
136
+ }
137
+
138
+ // Print findings
139
+ const blocks = allFindings.filter(f => f.severity === 'block');
140
+ const warns = allFindings.filter(f => f.severity === 'warn');
141
+ const infos = allFindings.filter(f => f.severity === 'info');
142
+
143
+ for (const f of allFindings) {
144
+ const icon = f.severity === 'block' ? c.red('[阻断]') :
145
+ f.severity === 'warn' ? c.yellow('[警告]') :
146
+ c.gray('[提示]');
147
+ console.log(` ${icon} ${f.message}`);
148
+ if (f.line) console.log(` ${c.gray(`第 ${f.line} 行`)}`);
149
+ if (f.suggestion) console.log(` ${c.gray(f.suggestion)}`);
150
+ }
151
+
152
+ // Summary
153
+ const parts = [];
154
+ if (blocks.length > 0) parts.push(c.red(`${blocks.length} 阻断`));
155
+ if (warns.length > 0) parts.push(c.yellow(`${warns.length} 警告`));
156
+ if (infos.length > 0) parts.push(c.gray(`${infos.length} 提示`));
157
+ console.log(` ${c.gray('—')} ${parts.join(c.gray(' · '))}`);
158
+ }
159
+
160
+ // Start watching
161
+ const targetDir = watchDir ? path.join(projectRoot, watchDir) : projectRoot;
162
+ watchDirRecursive(targetDir);
163
+
164
+ console.log(` ${c.cyan(`正在监控 ${watchers.length} 个文件...`)}\n`);
165
+
166
+ // Initial full check
167
+ console.log(` ${c.gray('正在执行初始检查...')}`);
168
+ const checkCmd = require('./check');
169
+ await checkCmd(projectRoot, useAI ? ['--ai'] : [], ui);
170
+ console.log(`\n ${c.cyan('开始监控变更...')}`);
171
+
172
+ // Handle new files being added (watch parent dirs for new entries)
173
+ const dirWatcher = fs.watch(targetDir, { recursive: true }, (eventType, filename) => {
174
+ if (!filename) return;
175
+ const fullPath = path.join(targetDir, filename);
176
+
177
+ // If a new file was added that we're not watching yet
178
+ if (eventType === 'rename' && fs.existsSync(fullPath)) {
179
+ try {
180
+ const stat = fs.statSync(fullPath);
181
+ if (stat.isFile() && CODE_EXTENSIONS.test(filename) && !watchers.some(w => w._path === fullPath)) {
182
+ watchFile(fullPath);
183
+ }
184
+ } catch (_) {}
185
+ }
186
+ });
187
+ watchers.push(dirWatcher);
188
+
189
+ // Cleanup on exit
190
+ process.on('SIGINT', () => {
191
+ console.log(`\n ${c.gray('正在停止监控...')}`);
192
+ watchers.forEach(w => w.close());
193
+ process.exit(0);
194
+ });
195
+ };
196
+
197
+ function saveWatchFindings(projectRoot, findings) {
198
+ const reportDir = path.join(projectRoot, '.devassist', 'reports');
199
+ if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
200
+ const reportPath = path.join(reportDir, 'latest.json');
201
+ fs.writeFileSync(reportPath, JSON.stringify({
202
+ generatedAt: new Date().toISOString(),
203
+ source: 'watch',
204
+ findings,
205
+ stats: {
206
+ total: findings.length,
207
+ blocks: findings.filter(f => f.severity === 'block').length,
208
+ warnings: findings.filter(f => f.severity === 'warn').length,
209
+ infos: findings.filter(f => f.severity === 'info').length,
210
+ },
211
+ }, null, 2), 'utf-8');
212
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Code context extractor for AI analysis.
3
+ *
4
+ * When sending findings to AI, we include the surrounding code lines
5
+ * so the AI can see the actual code it's analyzing — not just a message.
6
+ */
7
+
8
+ /**
9
+ * Extract code context (surrounding lines) for a finding.
10
+ * @param {string} content - full file content
11
+ * @param {number} line - 1-indexed line number
12
+ * @param {number} radius - lines before and after (default 5)
13
+ * @returns {string} - the code snippet with line numbers
14
+ */
15
+ function extractContext(content, line, radius = 5) {
16
+ if (!content || !line) return '';
17
+ const lines = content.split('\n');
18
+ const start = Math.max(0, line - radius - 1);
19
+ const end = Math.min(lines.length, line + radius);
20
+ const snippet = [];
21
+ for (let i = start; i < end; i++) {
22
+ const lineNum = i + 1;
23
+ const marker = lineNum === line ? ' >>> ' : ' ';
24
+ snippet.push(`${String(lineNum).padStart(4)}${marker}${lines[i]}`);
25
+ }
26
+ return snippet.join('\n');
27
+ }
28
+
29
+ /**
30
+ * Enrich findings with code context from the files that produced them.
31
+ * @param {Array} findings
32
+ * @param {Array<{ path: string, content: string }>} fileContents
33
+ * @returns {Array} - findings with added `codeContext` field
34
+ */
35
+ function enrichWithContext(findings, fileContents) {
36
+ const contentMap = new Map();
37
+ for (const f of fileContents) {
38
+ contentMap.set(f.path, f.content);
39
+ }
40
+
41
+ return findings.map(f => {
42
+ const content = contentMap.get(f.file);
43
+ if (!content || !f.line) return f;
44
+ return {
45
+ ...f,
46
+ codeContext: extractContext(content, f.line),
47
+ };
48
+ });
49
+ }
50
+
51
+ module.exports = { extractContext, enrichWithContext };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Project-level configuration loader.
3
+ *
4
+ * Reads .devassist/config.json for:
5
+ * - Custom thresholds (maxFileLines, todoThreshold, consoleLogThreshold, etc.)
6
+ * - Rule enable/disable overrides
7
+ * - Severity overrides (downgrade block -> warn, etc.)
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const DEFAULT_CONFIG = {
14
+ // Thresholds
15
+ maxFileLines: 800,
16
+ todoThreshold: 3,
17
+ consoleLogThreshold: 5,
18
+ magicNumberMinDigits: 3,
19
+ maxEventListenerFiles: 100,
20
+
21
+ // Rule overrides: { 'rule-id': { enabled: false } } or { 'rule-id': { severity: 'warn' } }
22
+ rules: {},
23
+
24
+ // AI settings
25
+ ai: {
26
+ autoAnalyze: false,
27
+ maxFindingsToAI: 20,
28
+ },
29
+ };
30
+
31
+ /**
32
+ * Load project config from .devassist/config.json.
33
+ * Falls back to defaults if not found.
34
+ * @param {string} projectRoot
35
+ * @returns {object} merged config
36
+ */
37
+ function loadConfig(projectRoot) {
38
+ const configPath = path.join(projectRoot, '.devassist', 'config.json');
39
+ if (!fs.existsSync(configPath)) return { ...DEFAULT_CONFIG };
40
+ try {
41
+ const userConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
42
+ return {
43
+ ...DEFAULT_CONFIG,
44
+ ...userConfig,
45
+ rules: { ...DEFAULT_CONFIG.rules, ...(userConfig.rules || {}) },
46
+ ai: { ...DEFAULT_CONFIG.ai, ...(userConfig.ai || {}) },
47
+ };
48
+ } catch (err) {
49
+ return { ...DEFAULT_CONFIG, _error: err.message };
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get the effective severity for a rule, applying config overrides.
55
+ * @param {object} config - from loadConfig()
56
+ * @param {string} ruleId
57
+ * @param {string} defaultSeverity
58
+ * @returns {string|null} - 'block'|'warn'|'info'|null (null = disabled)
59
+ */
60
+ function getEffectiveSeverity(config, ruleId, defaultSeverity) {
61
+ if (!config || !config.rules) return defaultSeverity;
62
+ const override = config.rules[ruleId];
63
+ if (!override) return defaultSeverity;
64
+ if (override.enabled === false) return null; // disabled
65
+ if (override.severity) return override.severity;
66
+ return defaultSeverity;
67
+ }
68
+
69
+ /**
70
+ * Filter an array of findings by config-based rule overrides.
71
+ * Removes findings from disabled rules and adjusts severity.
72
+ * @param {object} config
73
+ * @param {Array} findings
74
+ * @returns {Array}
75
+ */
76
+ function applyConfigToFindings(config, findings) {
77
+ if (!config || !config.rules) return findings;
78
+ return findings
79
+ .map(f => {
80
+ const override = config.rules[f.ruleId];
81
+ if (!override) return f;
82
+ if (override.enabled === false) return null; // skip
83
+ if (override.severity) return { ...f, severity: override.severity };
84
+ return f;
85
+ })
86
+ .filter(Boolean);
87
+ }
88
+
89
+ module.exports = { loadConfig, getEffectiveSeverity, applyConfigToFindings, DEFAULT_CONFIG };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Shared file collection utilities.
3
+ *
4
+ * Extracted from check.js / report.js / diff.js to eliminate ~180 lines
5
+ * of duplicated walkDir/collectFiles/loadIgnorePatterns code.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const DEFAULT_SKIP_DIRS = ['node_modules', '.git', '.devassist', 'dist', 'build', 'vendor', '.gradle', '__pycache__'];
12
+ const DEFAULT_SKIP_FILES = [/\.min\.(js|css)$/, /\.map$/, /^vendor\./];
13
+ const CODE_EXTENSIONS = /\.(js|ts|jsx|tsx|py|java)$/;
14
+
15
+ /**
16
+ * Load .devassistignore patterns from the project root.
17
+ * @param {string} projectRoot
18
+ * @returns {{ dirs: string[], globs: RegExp[] }}
19
+ */
20
+ function loadIgnorePatterns(projectRoot) {
21
+ const patterns = { dirs: [], globs: [] };
22
+ const ignorePath = path.join(projectRoot, '.devassistignore');
23
+ if (!fs.existsSync(ignorePath)) return patterns;
24
+ try {
25
+ const content = fs.readFileSync(ignorePath, 'utf-8');
26
+ for (const line of content.split('\n')) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith('#')) continue;
29
+ if (trimmed.endsWith('/')) {
30
+ patterns.dirs.push(trimmed.slice(0, -1));
31
+ } else if (trimmed.includes('*')) {
32
+ patterns.globs.push(new RegExp('^' + trimmed.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'));
33
+ } else {
34
+ patterns.dirs.push(trimmed);
35
+ }
36
+ }
37
+ } catch (_) {}
38
+ return patterns;
39
+ }
40
+
41
+ /**
42
+ * Recursively collect all source files from a project root.
43
+ * @param {string} projectRoot
44
+ * @returns {string[]} absolute file paths
45
+ */
46
+ function collectFiles(projectRoot) {
47
+ const results = [];
48
+ const ignorePatterns = loadIgnorePatterns(projectRoot);
49
+ const allSkipDirs = [...DEFAULT_SKIP_DIRS, ...ignorePatterns.dirs];
50
+ _walkDir(projectRoot, results, allSkipDirs, DEFAULT_SKIP_FILES, projectRoot, ignorePatterns);
51
+ return results;
52
+ }
53
+
54
+ /**
55
+ * Collect files and return them as { path, content, fullPath } objects.
56
+ * @param {string} projectRoot
57
+ * @returns {Array<{ path: string, content: string, fullPath: string }>}
58
+ */
59
+ function collectFileContents(projectRoot) {
60
+ const files = collectFiles(projectRoot);
61
+ return files.map(f => {
62
+ let content = '';
63
+ try { content = fs.readFileSync(f, 'utf-8'); } catch (_) {}
64
+ return { path: path.relative(projectRoot, f), content, fullPath: f };
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Filter a list of changed files (from git diff) by ignore patterns.
70
+ * @param {string[]} changedFiles
71
+ * @param {string} projectRoot
72
+ * @returns {string[]}
73
+ */
74
+ function filterByIgnorePatterns(changedFiles, projectRoot) {
75
+ const ignorePatterns = loadIgnorePatterns(projectRoot);
76
+ return changedFiles.filter(f => {
77
+ for (const dir of ignorePatterns.dirs) {
78
+ if (f.startsWith(dir + '/') || f.startsWith(dir + '\\')) return false;
79
+ }
80
+ for (const re of ignorePatterns.globs) {
81
+ if (re.test(f)) return false;
82
+ }
83
+ return true;
84
+ });
85
+ }
86
+
87
+ function _walkDir(dir, results, skipDirs, skipFiles, projectRoot, ignorePatterns) {
88
+ let entries;
89
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
90
+ for (const entry of entries) {
91
+ if (skipDirs.includes(entry.name)) continue;
92
+ const full = path.join(dir, entry.name);
93
+ const relPath = path.relative(projectRoot, full);
94
+ if (ignorePatterns.globs.some(re => re.test(relPath) || re.test(entry.name))) continue;
95
+ if (entry.isDirectory()) {
96
+ _walkDir(full, results, skipDirs, skipFiles, projectRoot, ignorePatterns);
97
+ } else if (entry.isFile()) {
98
+ if (skipFiles.some(re => re.test(entry.name))) continue;
99
+ if (CODE_EXTENSIONS.test(entry.name)) {
100
+ results.push(full);
101
+ continue;
102
+ }
103
+ if (/\.html?$/.test(entry.name)) {
104
+ try {
105
+ const content = fs.readFileSync(full, 'utf-8');
106
+ if (/<script[\s>]/i.test(content)) {
107
+ results.push(full);
108
+ }
109
+ } catch (_) {}
110
+ continue;
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ module.exports = { collectFiles, collectFileContents, loadIgnorePatterns, filterByIgnorePatterns, DEFAULT_SKIP_DIRS };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Inline ignore comment processor.
3
+ *
4
+ * Supports:
5
+ * // devassist-ignore-next-line — skip the next line's findings
6
+ * // devassist-ignore-next-line cq-rule-id — skip specific rule on next line
7
+ * /* devassist-disable *​/ — disable all rules for the rest of the file
8
+ * /* devassist-disable cq-rule-id *​/ — disable specific rule for the rest of the file
9
+ * /* devassist-enable *​/ — re-enable all rules
10
+ * /* devassist-enable cq-rule-id *​/ — re-enable specific rule
11
+ *
12
+ * Also supports HTML: <!-- devassist-ignore-next-line -->
13
+ */
14
+
15
+ /**
16
+ * Parse a file's content and build an ignore map.
17
+ * @param {string} content
18
+ * @returns {{ lineIgnores: Map<number, Set<string>>, rangeIgnores: Array<{start:number, end:number, rules:Set<string>}> }}
19
+ */
20
+ function parseIgnores(content) {
21
+ const lines = content.split('\n');
22
+ const lineIgnores = new Map(); // line number -> Set of rule IDs (or '*' for all)
23
+ const rangeIgnores = []; // { start, end: Infinity, rules: Set }
24
+
25
+ // Patterns for different comment styles
26
+ const ignoreNextRe = /(\/\/|<!--|\/\*)\s*devassist-ignore-next-line(?:\s+(\S+))?\s*(-->|\*\/)?/;
27
+ const disableRe = /\/\*\s*devassist-disable(?:\s+([\w,\s-]+))?\s*\*\/|<!--\s*devassist-disable(?:\s+([\w,\s-]+))?\s*-->/;
28
+ const enableRe = /\/\*\s*devassist-enable(?:\s+([\w,\s-]+))?\s*\*\/|<!--\s*devassist-enable(?:\s+([\w,\s-]+))?\s*-->/;
29
+
30
+ for (let i = 0; i < lines.length; i++) {
31
+ const line = lines[i];
32
+
33
+ // Check for ignore-next-line
34
+ const ignoreMatch = line.match(ignoreNextRe);
35
+ if (ignoreMatch) {
36
+ const ruleId = ignoreMatch[2] || '*';
37
+ const targetLine = i + 2; // next line (1-indexed)
38
+ if (!lineIgnores.has(targetLine)) lineIgnores.set(targetLine, new Set());
39
+ lineIgnores.get(targetLine).add(ruleId);
40
+ }
41
+
42
+ // Check for devassist-disable
43
+ const disableMatch = line.match(disableRe);
44
+ if (disableMatch) {
45
+ const ruleStr = (disableMatch[1] || disableMatch[2] || '').trim();
46
+ const rules = ruleStr ? new Set(ruleStr.split(/[\s,]+/)) : new Set(['*']);
47
+ rangeIgnores.push({ start: i + 1, end: Infinity, rules });
48
+ }
49
+
50
+ // Check for devassist-enable
51
+ const enableMatch = line.match(enableRe);
52
+ if (enableMatch) {
53
+ const ruleStr = (enableMatch[1] || enableMatch[2] || '').trim();
54
+ const rules = ruleStr ? new Set(ruleStr.split(/[\s,]+/)) : new Set(['*']);
55
+ // Close the most recent open range
56
+ for (let j = rangeIgnores.length - 1; j >= 0; j--) {
57
+ if (rangeIgnores[j].end === Infinity) {
58
+ // If enabling specific rules, only close those
59
+ if (rules.has('*')) {
60
+ rangeIgnores[j].end = i + 1;
61
+ } else {
62
+ // Partial: just remove those rules from the active range
63
+ for (const r of rules) rangeIgnores[j].rules.delete(r);
64
+ if (rangeIgnores[j].rules.size === 0) rangeIgnores[j].end = i + 1;
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ return { lineIgnores, rangeIgnores };
73
+ }
74
+
75
+ /**
76
+ * Check if a specific finding should be ignored based on inline comments.
77
+ * @param {object} finding - { file, line, ruleId }
78
+ * @param {Map} fileContentsMap - Map<filePath, content>
79
+ * @returns {boolean} true if should be ignored
80
+ */
81
+ function shouldIgnore(finding, fileContentsMap) {
82
+ if (!finding.line || !finding.file) return false;
83
+ const content = fileContentsMap.get(finding.file);
84
+ if (!content) return false;
85
+
86
+ const { lineIgnores, rangeIgnores } = parseIgnores(content);
87
+ const line = finding.line;
88
+ const ruleId = finding.ruleId || '*';
89
+
90
+ // Check line-level ignores
91
+ if (lineIgnores.has(line)) {
92
+ const rules = lineIgnores.get(line);
93
+ if (rules.has('*') || rules.has(ruleId)) return true;
94
+ }
95
+
96
+ // Check range ignores
97
+ for (const range of rangeIgnores) {
98
+ if (line >= range.start && line <= range.end) {
99
+ if (range.rules.has('*') || range.rules.has(ruleId)) return true;
100
+ }
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Filter an array of findings, removing those covered by inline ignore comments.
108
+ * @param {Array} findings
109
+ * @param {Array<{ path: string, content: string }>} fileContents
110
+ * @returns {Array}
111
+ */
112
+ function filterIgnored(findings, fileContents) {
113
+ const contentMap = new Map();
114
+ for (const f of fileContents) {
115
+ contentMap.set(f.path, f.content);
116
+ }
117
+
118
+ return findings.filter(f => {
119
+ const content = contentMap.get(f.file);
120
+ if (!content) return true; // can't check, keep it
121
+ const { lineIgnores, rangeIgnores } = parseIgnores(content);
122
+ const line = f.line;
123
+ const ruleId = f.ruleId || '*';
124
+
125
+ // Check line-level ignores
126
+ if (line && lineIgnores.has(line)) {
127
+ const rules = lineIgnores.get(line);
128
+ if (rules.has('*') || rules.has(ruleId)) return false;
129
+ }
130
+
131
+ // Check range ignores
132
+ for (const range of rangeIgnores) {
133
+ if (line && line >= range.start && line <= range.end) {
134
+ if (range.rules.has('*') || range.rules.has(ruleId)) return false;
135
+ }
136
+ }
137
+
138
+ return true;
139
+ });
140
+ }
141
+
142
+ module.exports = { parseIgnores, shouldIgnore, filterIgnored };