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.
- package/LICENSE +21 -0
- package/README.md +467 -0
- package/bin/devassist.js +220 -0
- package/package.json +44 -0
- package/src/ai/adapter.js +464 -0
- package/src/ai/providers/claude.js +80 -0
- package/src/ai/providers/hunyuan.js +87 -0
- package/src/ai/providers/openai.js +74 -0
- package/src/ai/providers/qwen.js +81 -0
- package/src/cli/commands/ai.js +944 -0
- package/src/cli/commands/ask.js +79 -0
- package/src/cli/commands/backup.js +30 -0
- package/src/cli/commands/check.js +327 -0
- package/src/cli/commands/clean.js +130 -0
- package/src/cli/commands/comparison-report.js +326 -0
- package/src/cli/commands/convention.js +91 -0
- package/src/cli/commands/debt.js +49 -0
- package/src/cli/commands/deploy.js +88 -0
- package/src/cli/commands/diff.js +193 -0
- package/src/cli/commands/doctor.js +186 -0
- package/src/cli/commands/fix.js +195 -0
- package/src/cli/commands/init.js +431 -0
- package/src/cli/commands/inject.js +254 -0
- package/src/cli/commands/report.js +310 -0
- package/src/cli/commands/restore.js +78 -0
- package/src/cli/commands/schema.js +93 -0
- package/src/cli/commands/watch.js +212 -0
- package/src/cli/shared/code-context.js +51 -0
- package/src/cli/shared/config-loader.js +89 -0
- package/src/cli/shared/file-collector.js +116 -0
- package/src/cli/shared/inline-ignore.js +142 -0
- package/src/cli/shared/watch-list.js +281 -0
- package/src/core/event-bus.js +83 -0
- package/src/core/fsm.js +103 -0
- package/src/core/logger.js +54 -0
- package/src/core/rule-engine.js +117 -0
- package/src/index.js +64 -0
- package/src/modules/dev-time/arch-risk-assessor.js +250 -0
- package/src/modules/dev-time/code-quality-guard.js +1340 -0
- package/src/modules/dev-time/convention-store.js +201 -0
- package/src/modules/dev-time/impact-analyzer.js +292 -0
- package/src/modules/dev-time/pre-deploy-guard.js +284 -0
- package/src/modules/dev-time/schema-registry.js +284 -0
- package/src/modules/dev-time/tech-debt-tracker.js +225 -0
- package/src/modules/dev-time/version-manager.js +280 -0
- package/src/modules/runtime/channel-agent.js +404 -0
- package/src/modules/runtime/channel-middleware.js +316 -0
- package/src/modules/runtime/index.js +64 -0
- package/src/modules/runtime/infrastructure-guard.js +582 -0
- package/src/modules/runtime/notification-center.js +443 -0
- package/src/modules/runtime/schema-gatekeeper.js +664 -0
- package/src/modules/runtime/tool-registry.js +329 -0
- package/templates/ci/github-actions.yml +60 -0
- package/templates/ci/gitlab-ci.yml +30 -0
- package/templates/ci/pre-commit-hook.sh +18 -0
- package/templates/git-hooks/pre-commit +61 -0
- package/tests/run-all.js +434 -0
- package/tests/test-layer2.js +461 -0
- 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 };
|