@tomjerrygit/dsh-workspace-audit 0.1.0 → 0.2.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/README.md +11 -1
- package/client.js +45 -1
- package/index.js +173 -9
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -6,9 +6,19 @@ Local-first workspace health audit for DeepSeek Harness. Read-only by design; no
|
|
|
6
6
|
|
|
7
7
|
- Git: repository, remote, branch, dirty count, last commit, tags
|
|
8
8
|
- Project: README, LICENSE, .gitignore, manifests, lockfiles, tests, docs, CI
|
|
9
|
+
- Dependencies: manifests/lockfiles, unpinned requirements, `npm audit`/`pip-audit` when available
|
|
10
|
+
- GitHub: latest release vs local tag, open issues, archived state, branch protection (public repos)
|
|
9
11
|
- Size: source/scan size and known build/cache directories
|
|
10
12
|
- Security hotspots: self-hosted runner triggered by `pull_request`, suspected key filenames (names only), `shell=True`/`exec`/`pickle.load` patterns
|
|
11
|
-
- Risks: P0/P1/P2 list and a Markdown handover report
|
|
13
|
+
- Risks: P0/P1/P2 list, risk filters, project comparison table, score trend history, and a Markdown handover report
|
|
14
|
+
|
|
15
|
+
## Tests
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
node --test test/audit.test.mjs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The test suite uses a self-contained fixture workspace (`test/fixtures/basic`) and checks Git scanning, secret filename detection, workflow risk detection, dependency health, report rendering, GitHub remote parsing, and grading.
|
|
12
22
|
|
|
13
23
|
## Install
|
|
14
24
|
|
package/client.js
CHANGED
|
@@ -34,6 +34,15 @@ window.__ModuleLoader__.load({
|
|
|
34
34
|
todo: 'TODO',
|
|
35
35
|
noRisk: '未发现阻塞风险',
|
|
36
36
|
commands: '启动/测试入口见各项目 README 与报告。',
|
|
37
|
+
filterAll: '全部风险',
|
|
38
|
+
comparison: '项目对比',
|
|
39
|
+
trend: '趋势',
|
|
40
|
+
deps: '依赖',
|
|
41
|
+
vulns: '漏洞',
|
|
42
|
+
github: 'GitHub',
|
|
43
|
+
release: 'Release',
|
|
44
|
+
branch: '分支保护',
|
|
45
|
+
noLock: '无锁文件',
|
|
37
46
|
};
|
|
38
47
|
const en = {
|
|
39
48
|
panel: 'Workspace Audit',
|
|
@@ -64,6 +73,15 @@ window.__ModuleLoader__.load({
|
|
|
64
73
|
todo: 'TODO',
|
|
65
74
|
noRisk: 'No blocking risks found',
|
|
66
75
|
commands: 'See each project README and report for run/test commands.',
|
|
76
|
+
filterAll: 'All risks',
|
|
77
|
+
comparison: 'Workspace comparison',
|
|
78
|
+
trend: 'Trend',
|
|
79
|
+
deps: 'Deps',
|
|
80
|
+
vulns: 'Vulns',
|
|
81
|
+
github: 'GitHub',
|
|
82
|
+
release: 'Release',
|
|
83
|
+
branch: 'Branch protection',
|
|
84
|
+
noLock: 'No lockfile',
|
|
67
85
|
};
|
|
68
86
|
const page = { display: 'flex', flexDirection: 'column', gap: '14px', padding: '24px 32px', minHeight: '100%', overflow: 'auto', color: 'var(--dsw-alias-label-primary)' };
|
|
69
87
|
const card = { border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '14px', padding: '14px', background: 'var(--dsw-alias-bg-layer-1)' };
|
|
@@ -82,6 +100,7 @@ window.__ModuleLoader__.load({
|
|
|
82
100
|
const [scan, setScan] = React.useState(null);
|
|
83
101
|
const [state, setState] = React.useState('loading');
|
|
84
102
|
const [rootDraft, setRootDraft] = React.useState('');
|
|
103
|
+
const [riskFilter, setRiskFilter] = React.useState('all');
|
|
85
104
|
const [copied, setCopied] = React.useState(false);
|
|
86
105
|
const auth = () => token.length > 0 ? Promise.resolve(token) : fetch('/api/workspace-audit/auth').then((res) => res.json()).then((payload) => { setToken(payload.token); return payload.token; });
|
|
87
106
|
const headers = (value) => ({ 'x-workspace-audit-token': value, accept: 'application/json' });
|
|
@@ -110,6 +129,7 @@ window.__ModuleLoader__.load({
|
|
|
110
129
|
if (state === 'loading') return h('div', { style: page }, h('h1', { style: { margin: 0, fontSize: '22px' } }, t('title')), h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('scanning')));
|
|
111
130
|
if (state === 'failed' || scan === null) return h('div', { style: page }, h('h1', { style: { margin: 0, fontSize: '22px' } }, t('title')), h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('failed')), h('button', { type: 'button', onClick: load }, t('scan')));
|
|
112
131
|
const workspaces = Array.isArray(scan.workspaces) ? scan.workspaces : [];
|
|
132
|
+
const filteredWorkspaces = workspaces.filter((workspace) => riskFilter === 'all' || (workspace.risks || []).some((risk) => risk.severity === riskFilter));
|
|
113
133
|
const avg = workspaces.length > 0 ? Math.round(workspaces.reduce((sum, workspace) => sum + workspace.score, 0) / workspaces.length) : 0;
|
|
114
134
|
const p0 = workspaces.reduce((sum, workspace) => sum + workspace.risks.filter((risk) => risk.severity === 'P0').length, 0);
|
|
115
135
|
return h('div', { style: page },
|
|
@@ -125,15 +145,31 @@ window.__ModuleLoader__.load({
|
|
|
125
145
|
h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } },
|
|
126
146
|
h('input', { value: rootDraft, placeholder: t('rootPlaceholder'), onChange: (event) => setRootDraft(event.target.value), style: { flex: '1 1 260px', padding: '6px 8px' } }),
|
|
127
147
|
h('button', { type: 'button', onClick: addRoot, disabled: rootDraft.trim().length === 0 }, t('add')),
|
|
148
|
+
['all', 'P0', 'P1', 'P2'].map((severity) => h('button', { key: severity, type: 'button', onClick: () => setRiskFilter(severity), style: { fontWeight: riskFilter === severity ? 700 : 400 } }, severity === 'all' ? t('filterAll') : severity)),
|
|
128
149
|
h('button', { type: 'button', onClick: copy }, copied ? t('copied') : t('copy')),
|
|
129
150
|
h('button', { type: 'button', onClick: download }, t('download')),
|
|
130
151
|
),
|
|
131
152
|
workspaces.length === 0 ? h('p', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t('empty')) : null,
|
|
132
|
-
|
|
153
|
+
workspaces.length > 0 ? h('section', { style: card },
|
|
154
|
+
h('h2', { style: { margin: 0, fontSize: '16px' } }, t('comparison')),
|
|
155
|
+
h('div', { style: { overflowX: 'auto', marginTop: '8px' } },
|
|
156
|
+
h('table', { style: { width: '100%', borderCollapse: 'collapse', fontSize: '11px' } },
|
|
157
|
+
h('thead', null, h('tr', null, ['workspace', 'score', 'documentation', 'development', 'debugging', 'performance', 'functionality', 'security', 'github', 'trend'].map((key) => h('th', { key, style: { textAlign: 'left', padding: '4px 6px', borderBottom: '1px solid var(--dsw-alias-border-l1)' } }, key)))),
|
|
158
|
+
h('tbody', null, workspaces.map((workspace) => h('tr', { key: workspace.path },
|
|
159
|
+
h('td', { style: { padding: '4px 6px' } }, workspace.title),
|
|
160
|
+
h('td', { style: { padding: '4px 6px', fontWeight: 700, color: color(workspace.score) } }, workspace.score),
|
|
161
|
+
...['documentation', 'development', 'debugging', 'performance', 'functionality', 'security', 'github'].map((key) => h('td', { key, style: { padding: '4px 6px' } }, workspace.dimensions[key])),
|
|
162
|
+
h('td', { key: 'trend', style: { padding: '4px 6px', color: workspace.trend && workspace.trend.delta > 0 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)' } }, workspace.trend && workspace.trend.delta !== null ? (workspace.trend.delta >= 0 ? '+' : '') + workspace.trend.delta : '—'),
|
|
163
|
+
))),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
) : null,
|
|
167
|
+
filteredWorkspaces.map((workspace) => {
|
|
133
168
|
const risks = workspace.risks || [];
|
|
134
169
|
return h('section', { key: workspace.path, style: card },
|
|
135
170
|
h('div', { style: { display: 'flex', gap: '10px', alignItems: 'baseline', flexWrap: 'wrap' } },
|
|
136
171
|
h('h2', { style: { margin: 0, fontSize: '16px', flex: '1 1 auto' } }, workspace.title),
|
|
172
|
+
workspace.trend && workspace.trend.delta !== null ? h('span', { style: { fontSize: '12px', color: workspace.trend.delta >= 0 ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)' } }, (workspace.trend.delta >= 0 ? '+' : '') + workspace.trend.delta) : null,
|
|
137
173
|
h('strong', { style: { fontSize: '20px', color: color(workspace.score) } }, workspace.score + ' ' + grade(workspace.score)),
|
|
138
174
|
),
|
|
139
175
|
h('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', margin: '4px 0 8px' } }, t('path') + ':' + workspace.path),
|
|
@@ -143,6 +179,14 @@ window.__ModuleLoader__.load({
|
|
|
143
179
|
h('span', null, t('tests') + ': ' + workspace.testFiles),
|
|
144
180
|
h('span', null, t('todo') + ': ' + workspace.todo),
|
|
145
181
|
),
|
|
182
|
+
h('div', { style: { display: 'flex', gap: '14px', flexWrap: 'wrap', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginTop: '4px' } },
|
|
183
|
+
workspace.dependencies && workspace.dependencies.manifests.length > 0
|
|
184
|
+
? h('span', null, t('deps') + ': ' + (workspace.dependencies.lockfiles.length > 0 ? 'lock ✓' : t('noLock')) + ' · ' + t('vulns') + ' ' + workspace.dependencies.vulnerabilities.total + (workspace.dependencies.vulnerabilities.status === 'skipped' ? ' (skipped)' : ''))
|
|
185
|
+
: null,
|
|
186
|
+
workspace.github
|
|
187
|
+
? h('span', null, t('github') + ': ' + workspace.github.owner + '/' + workspace.github.repo + ' · ' + t('release') + ' ' + (workspace.github.latestRelease || '—') + ' · ' + t('branch') + ' ' + String(workspace.github.branchProtected))
|
|
188
|
+
: null,
|
|
189
|
+
),
|
|
146
190
|
h('div', { style: { display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '8px' } },
|
|
147
191
|
Object.entries(workspace.dimensions || {}).map(([key, value]) => h('span', { key, style: { fontSize: '10px', padding: '1px 6px', borderRadius: '999px', background: 'var(--dsw-alias-bg-layer-2)' } }, key + ' ' + value)),
|
|
148
192
|
),
|
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const SETTINGS_PATH = ROUTE + '/settings';
|
|
|
17
17
|
const MAX_WORKSPACES = 20;
|
|
18
18
|
const SKIP_NAMES = new Set(['.git', 'node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache', 'dist', 'build', 'target', '.next', '.nuxt', 'coverage']);
|
|
19
19
|
const ARTIFACT_NAMES = new Set(['node_modules', '.venv', 'venv', '__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache', 'dist', 'build', 'target', '.next', '.nuxt', 'coverage', 'release-archive', 'test+files', 'BeeOffice-v5.2.3-windows', 'BeeOffice_logs']);
|
|
20
|
+
const GITHUB_CACHE = new Map();
|
|
20
21
|
let authToken = null;
|
|
21
22
|
|
|
22
23
|
function resolveDshHome() {
|
|
@@ -37,6 +38,24 @@ async function loadSettings() {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function historyFile() {
|
|
42
|
+
return path.join(resolveDshHome(), 'workspace-audit', 'history.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadHistory() {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(await fs.readFile(historyFile(), 'utf8'));
|
|
48
|
+
return Array.isArray(raw.entries) ? raw.entries.slice(-30) : [];
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function saveHistory(entries) {
|
|
55
|
+
await fs.mkdir(path.dirname(historyFile()), { recursive: true });
|
|
56
|
+
await fs.writeFile(historyFile(), JSON.stringify({ entries: entries.slice(-30) }, null, 2) + '\n', 'utf8');
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
async function saveSettings(settings) {
|
|
41
60
|
await fs.mkdir(path.dirname(settingsFile()), { recursive: true });
|
|
42
61
|
await fs.writeFile(settingsFile(), JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
@@ -180,6 +199,122 @@ async function gitInfo(root) {
|
|
|
180
199
|
};
|
|
181
200
|
}
|
|
182
201
|
|
|
202
|
+
async function commandExists(command) {
|
|
203
|
+
try {
|
|
204
|
+
await execFileAsync(command, ['--version'], { timeout: 5000, windowsHide: true });
|
|
205
|
+
return true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function dependencyHealth(root) {
|
|
212
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
213
|
+
const names = entries.map((entry) => entry.name);
|
|
214
|
+
const manifests = names.filter((name) => /^(package\.json|pyproject\.toml|requirements.*\.txt|Cargo\.toml|go\.mod|pom\.xml|build\.gradle)$/i.test(name));
|
|
215
|
+
const lockfiles = names.filter((name) => /^(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|poetry\.lock|uv\.lock|Cargo\.lock|go\.sum|gradle\.lockfile)$/i.test(name));
|
|
216
|
+
let unpinned = 0;
|
|
217
|
+
const requirementFile = names.find((name) => /^requirements.*\.txt$/i.test(name));
|
|
218
|
+
if (requirementFile) {
|
|
219
|
+
const text = await fs.readFile(path.join(root, requirementFile), 'utf8').catch(() => '');
|
|
220
|
+
unpinned += (text.match(/^[A-Za-z0-9_.-]+\s*(>=|~=|>)/gm) || []).length;
|
|
221
|
+
}
|
|
222
|
+
const vulnerabilities = { status: 'skipped', total: 0, tool: '' };
|
|
223
|
+
if (names.includes('package-lock.json')) {
|
|
224
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
225
|
+
if (await commandExists(npmCommand)) {
|
|
226
|
+
try {
|
|
227
|
+
const result = await execFileAsync(npmCommand, ['audit', '--json', '--package-lock-only', '--omit=dev'], { cwd: root, timeout: 30000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
|
|
228
|
+
const parsed = JSON.parse(String(result.stdout || '{}'));
|
|
229
|
+
vulnerabilities.status = 'checked';
|
|
230
|
+
vulnerabilities.total = Number(parsed.metadata && parsed.metadata.vulnerabilities && parsed.metadata.vulnerabilities.total) || 0;
|
|
231
|
+
vulnerabilities.tool = 'npm audit';
|
|
232
|
+
} catch (error) {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(String(error.stdout || '{}'));
|
|
235
|
+
vulnerabilities.status = 'checked';
|
|
236
|
+
vulnerabilities.total = Number(parsed.metadata && parsed.metadata.vulnerabilities && parsed.metadata.vulnerabilities.total) || 0;
|
|
237
|
+
vulnerabilities.tool = 'npm audit';
|
|
238
|
+
} catch (inner) {
|
|
239
|
+
vulnerabilities.status = 'error';
|
|
240
|
+
vulnerabilities.tool = 'npm audit';
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
} else if (requirementFile) {
|
|
245
|
+
const pipAudit = process.platform === 'win32' ? 'pip-audit.exe' : 'pip-audit';
|
|
246
|
+
if (await commandExists(pipAudit)) {
|
|
247
|
+
try {
|
|
248
|
+
const result = await execFileAsync(pipAudit, ['-r', requirementFile, '--format', 'json'], { cwd: root, timeout: 30000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
|
|
249
|
+
const parsed = JSON.parse(String(result.stdout || '[]'));
|
|
250
|
+
vulnerabilities.status = 'checked';
|
|
251
|
+
vulnerabilities.total = Array.isArray(parsed) ? parsed.reduce((sum, entry) => sum + (Array.isArray(entry.vulns) ? entry.vulns.length : 0), 0) : 0;
|
|
252
|
+
vulnerabilities.tool = 'pip-audit';
|
|
253
|
+
} catch (error) {
|
|
254
|
+
vulnerabilities.status = 'error';
|
|
255
|
+
vulnerabilities.tool = 'pip-audit';
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { manifests, lockfiles, unpinned, vulnerabilities };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function parseGitHubRemote(remote) {
|
|
263
|
+
const match = String(remote || '').match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
264
|
+
return match ? { owner: match[1], repo: match[2] } : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function githubHealth(git) {
|
|
268
|
+
const parsed = parseGitHubRemote(git.remote);
|
|
269
|
+
if (!parsed) return null;
|
|
270
|
+
const key = parsed.owner.toLowerCase() + '/' + parsed.repo.toLowerCase();
|
|
271
|
+
const cached = GITHUB_CACHE.get(key);
|
|
272
|
+
if (cached && Date.now() - cached.at < 10 * 60 * 1000) return cached.value;
|
|
273
|
+
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
|
|
274
|
+
const headers = { accept: 'application/vnd.github+json', 'user-agent': 'dsh-workspace-audit' };
|
|
275
|
+
if (token) headers.authorization = 'Bearer ' + token;
|
|
276
|
+
const request = async (url) => {
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
279
|
+
try {
|
|
280
|
+
return await fetch(url, { headers, signal: controller.signal });
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const value = { provider: 'github', owner: parsed.owner, repo: parsed.repo };
|
|
286
|
+
try {
|
|
287
|
+
const repoRes = await request('https://api.github.com/repos/' + parsed.owner + '/' + parsed.repo);
|
|
288
|
+
if (repoRes.ok) {
|
|
289
|
+
const repo = await repoRes.json();
|
|
290
|
+
value.openIssues = repo.open_issues_count;
|
|
291
|
+
value.stars = repo.stargazers_count;
|
|
292
|
+
value.archived = repo.archived === true;
|
|
293
|
+
value.pushedAt = repo.pushed_at;
|
|
294
|
+
value.defaultBranch = repo.default_branch;
|
|
295
|
+
value.permissions = repo.permissions || null;
|
|
296
|
+
} else {
|
|
297
|
+
value.status = repoRes.status;
|
|
298
|
+
}
|
|
299
|
+
const releaseRes = await request('https://api.github.com/repos/' + parsed.owner + '/' + parsed.repo + '/releases/latest');
|
|
300
|
+
if (releaseRes.ok) {
|
|
301
|
+
const release = await releaseRes.json();
|
|
302
|
+
value.latestRelease = release.tag_name;
|
|
303
|
+
value.releaseName = release.name;
|
|
304
|
+
value.releasePublishedAt = release.published_at;
|
|
305
|
+
} else {
|
|
306
|
+
value.latestRelease = '';
|
|
307
|
+
}
|
|
308
|
+
const branch = git.branch || value.defaultBranch || 'main';
|
|
309
|
+
const protectionRes = await request('https://api.github.com/repos/' + parsed.owner + '/' + parsed.repo + '/branches/' + encodeURIComponent(branch) + '/protection');
|
|
310
|
+
value.branchProtected = protectionRes.ok ? true : protectionRes.status === 403 ? 'unknown' : false;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
value.status = 'error';
|
|
313
|
+
}
|
|
314
|
+
GITHUB_CACHE.set(key, { at: Date.now(), value });
|
|
315
|
+
return value;
|
|
316
|
+
}
|
|
317
|
+
|
|
183
318
|
async function fileSize(file) {
|
|
184
319
|
try {
|
|
185
320
|
return (await fs.stat(file)).size;
|
|
@@ -226,6 +361,8 @@ function testFilePattern(name) {
|
|
|
226
361
|
async function scanWorkspace(workspace) {
|
|
227
362
|
const root = workspace.path;
|
|
228
363
|
const git = await gitInfo(root);
|
|
364
|
+
const dependencies = await dependencyHealth(root);
|
|
365
|
+
const github = await githubHealth(git);
|
|
229
366
|
const tracked = Array.isArray(git.tracked) ? git.tracked : [];
|
|
230
367
|
const rootEntries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
231
368
|
const markers = {
|
|
@@ -250,12 +387,10 @@ async function scanWorkspace(workspace) {
|
|
|
250
387
|
const sourceFiles = tracked.filter((file) => isTextFile(file) && !file.includes('node_modules/')).slice(0, 400);
|
|
251
388
|
let todo = 0;
|
|
252
389
|
let dangerous = 0;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
for (const file of sourceFiles) {
|
|
256
|
-
const full = path.join(root, file);
|
|
257
|
-
const basename = path.basename(file).toLowerCase();
|
|
390
|
+
const secretFiles = [];
|
|
391
|
+
for (const file of tracked.slice(0, 3000)) {
|
|
258
392
|
const sourceLike = /\.(py|js|mjs|cjs|ts|tsx|jsx|go|rs|c|h|cpp|java|kt|rb|php|sql)$/i.test(file);
|
|
393
|
+
const basename = path.basename(file).toLowerCase();
|
|
259
394
|
const secretLike = !sourceLike && (
|
|
260
395
|
/(^|\/)\.env(\.|$)/i.test(file) ||
|
|
261
396
|
/\.(pem|key|p12|pfx|jks|keystore)$/i.test(file) ||
|
|
@@ -263,12 +398,17 @@ async function scanWorkspace(workspace) {
|
|
|
263
398
|
basename === '.npmrc' || basename === '.pypirc'
|
|
264
399
|
);
|
|
265
400
|
if (secretLike) secretFiles.push(file);
|
|
401
|
+
}
|
|
402
|
+
for (const file of sourceFiles) {
|
|
403
|
+
const full = path.join(root, file);
|
|
266
404
|
if (!/\.(py|js|mjs|cjs|ts|tsx|jsx|sh|ps1|bat|cmd|yml|yaml)$/i.test(file)) continue;
|
|
267
405
|
const text = await fs.readFile(full, 'utf8').catch(() => '');
|
|
268
406
|
if (text.length === 0) continue;
|
|
269
407
|
todo += (text.match(/\b(TODO|FIXME|XXX|HACK)\b/g) || []).length;
|
|
270
408
|
dangerous += (text.match(/shell\s*=\s*True|\bexec\s*\(|pickle\.load\s*\(/g) || []).length;
|
|
271
409
|
}
|
|
410
|
+
const readmeEntry = rootEntries.find((entry) => /^readme/i.test(entry.name));
|
|
411
|
+
const readmeText = readmeEntry ? await fs.readFile(path.join(root, readmeEntry.name), 'utf8').catch(() => '') : '';
|
|
272
412
|
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
273
413
|
const workflowFiles = await fs.readdir(workflowsDir, { withFileTypes: true }).catch(() => []);
|
|
274
414
|
const workflows = [];
|
|
@@ -285,11 +425,11 @@ async function scanWorkspace(workspace) {
|
|
|
285
425
|
const testFiles = tracked.filter((file) => testFilePattern(file)).length;
|
|
286
426
|
const dimensions = {
|
|
287
427
|
documentation: (markers.readme ? 35 : 0) + (markers.docs ? 25 : 0) + (readmeText.length > 4000 ? 20 : readmeText.length > 1000 ? 10 : 0) + (markers.license ? 20 : 0),
|
|
288
|
-
development: (markers.manifest ? 25 : 0) + (
|
|
428
|
+
development: (markers.manifest ? 25 : 0) + (dependencies.lockfiles.length > 0 ? 20 : 0) + (markers.gitignore ? 20 : 0) + (markers.ci ? 25 : 0) + (markers.docker ? 10 : 0),
|
|
289
429
|
debugging: Math.min(100, testFiles * 6 + (markers.tests ? 20 : 0) + (markers.ci ? 20 : 0)),
|
|
290
430
|
performance: Math.max(0, 100 - Math.min(80, Math.round(size.total / (1024 * 1024 * 50) * 10)) - (artifacts.length > 0 ? 20 : 0)),
|
|
291
431
|
functionality: (markers.readme ? 30 : 0) + (tracked.length > 50 ? 35 : tracked.length > 10 ? 20 : 0) + (markers.tests ? 20 : 0) + (markers.ci ? 15 : 0),
|
|
292
|
-
security: Math.max(0, 100 - (git.isRepo && git.dirty > 0 ? 15 : 0) - Math.min(40, dangerous * 5) - (workflows.some((wf) => wf.selfHosted && wf.pullRequest) ? 40 : 0) - (secretFiles.length > 0 ? 20 : 0) - (
|
|
432
|
+
security: Math.max(0, 100 - (git.isRepo && git.dirty > 0 ? 15 : 0) - Math.min(40, dangerous * 5) - (workflows.some((wf) => wf.selfHosted && wf.pullRequest) ? 40 : 0) - (secretFiles.length > 0 ? 20 : 0) - (dependencies.vulnerabilities.total > 0 ? 30 : 0)),
|
|
293
433
|
github: (git.isRepo ? 30 : 0) + (git.remote ? 30 : 0) + (git.dirty === 0 ? 20 : 0) + (git.tags && git.tags.length > 0 ? 20 : 0),
|
|
294
434
|
};
|
|
295
435
|
const score = Math.round(Object.values(dimensions).reduce((sum, value) => sum + value, 0) / 7);
|
|
@@ -308,14 +448,22 @@ async function scanWorkspace(workspace) {
|
|
|
308
448
|
const huge = significantArtifacts.some((item) => item.bytes >= 1024 * 1024 * 1024);
|
|
309
449
|
risks.push({ severity: huge ? 'P1' : 'P2', text: `发现构建/缓存目录:${significantArtifacts.map((item) => item.name + ' ' + Math.round(item.bytes / 1024 / 1024) + 'MB').join(', ')}。` });
|
|
310
450
|
}
|
|
311
|
-
if (
|
|
451
|
+
if (dependencies.vulnerabilities.total > 0) risks.push({ severity: dependencies.vulnerabilities.total >= 5 ? 'P0' : 'P1', text: `${dependencies.vulnerabilities.tool || '依赖扫描'} 发现 ${dependencies.vulnerabilities.total} 个依赖漏洞。` });
|
|
452
|
+
if (dependencies.lockfiles.length === 0 && dependencies.manifests.length > 0) risks.push({ severity: 'P2', text: '依赖清单存在但没有检测到锁文件。' });
|
|
453
|
+
if (dependencies.unpinned > 5) risks.push({ severity: 'P2', text: `requirements 中有 ${dependencies.unpinned} 条非精确版本约束。` });
|
|
454
|
+
if (github && github.latestRelease && git.tags && git.tags.length > 0 && github.latestRelease !== git.tags[0]) risks.push({ severity: 'P2', text: `Git tag ${git.tags[0]} 与 GitHub latest release ${github.latestRelease} 不一致。` });
|
|
455
|
+
if (github && github.archived === true) risks.push({ severity: 'P2', text: 'GitHub 仓库已归档(archived)。' });
|
|
456
|
+
if (github && github.branchProtected === false) risks.push({ severity: 'P2', text: 'GitHub 默认分支未检测到保护规则。' });
|
|
312
457
|
if (todo > 20) risks.push({ severity: 'P2', text: `源码中约 ${todo} 处 TODO/FIXME,建议建立清理计划。` });
|
|
313
458
|
return {
|
|
314
459
|
path: root,
|
|
315
460
|
title: workspace.title,
|
|
316
461
|
git,
|
|
317
462
|
markers,
|
|
463
|
+
workflowFiles,
|
|
318
464
|
workflows,
|
|
465
|
+
dependencies,
|
|
466
|
+
github,
|
|
319
467
|
size: { bytes: size.total, scannedEntries: size.entries, artifacts },
|
|
320
468
|
testFiles,
|
|
321
469
|
todo,
|
|
@@ -340,6 +488,11 @@ function renderReport(workspaces) {
|
|
|
340
488
|
lines.push(`## ${workspace.title}`, '', `- Path: \`${workspace.path}\``, `- Score: **${workspace.score}/100 (${grade(workspace.score)})**`, `- Git: ${workspace.git.isRepo ? workspace.git.branch + ' · ' + (workspace.git.remote || 'no remote') : 'not a repository'}`, `- Size: ${Math.round(workspace.size.bytes / 1024 / 1024)}MB`, `- Test files: ${workspace.testFiles}`, `- TODO/FIXME: ${workspace.todo}`, '');
|
|
341
489
|
lines.push('### Dimensions', '');
|
|
342
490
|
for (const [key, value] of Object.entries(workspace.dimensions)) lines.push(`- ${key}: ${value}/100`);
|
|
491
|
+
if (workspace.trend && workspace.trend.previousScore !== null) lines.push(`- Trend: ${workspace.trend.delta >= 0 ? '+' : ''}${workspace.trend.delta} from ${workspace.trend.previousScore}`);
|
|
492
|
+
if (workspace.dependencies && (workspace.dependencies.manifests.length > 0 || workspace.dependencies.lockfiles.length > 0)) {
|
|
493
|
+
lines.push(`- Dependencies: manifests=${workspace.dependencies.manifests.join(',') || 'none'}; lockfiles=${workspace.dependencies.lockfiles.join(',') || 'none'}; vulnerabilities=${workspace.dependencies.vulnerabilities.total} (${workspace.dependencies.vulnerabilities.status})`);
|
|
494
|
+
}
|
|
495
|
+
if (workspace.github) lines.push(`- GitHub: ${workspace.github.owner}/${workspace.github.repo}; release=${workspace.github.latestRelease || 'none'}; issues=${workspace.github.openIssues ?? '—'}; branchProtected=${workspace.github.branchProtected ?? '—'}`);
|
|
343
496
|
lines.push('', '### Risks', '');
|
|
344
497
|
if (workspace.risks.length === 0) lines.push('- No blocking risks found.');
|
|
345
498
|
for (const risk of workspace.risks) lines.push(`- **${risk.severity}** ${risk.text}`);
|
|
@@ -353,6 +506,8 @@ async function runScan() {
|
|
|
353
506
|
const discovered = await discoverWorkspaces(settings);
|
|
354
507
|
const workspaces = [];
|
|
355
508
|
const failures = [];
|
|
509
|
+
const previousEntries = await loadHistory();
|
|
510
|
+
const previous = previousEntries.length > 0 ? previousEntries[previousEntries.length - 1] : null;
|
|
356
511
|
for (const workspace of discovered) {
|
|
357
512
|
try {
|
|
358
513
|
workspaces.push(await scanWorkspace(workspace));
|
|
@@ -361,6 +516,15 @@ async function runScan() {
|
|
|
361
516
|
}
|
|
362
517
|
}
|
|
363
518
|
workspaces.sort((left, right) => left.score - right.score);
|
|
519
|
+
for (const workspace of workspaces) {
|
|
520
|
+
const before = previous && Array.isArray(previous.workspaces) ? previous.workspaces.find((entry) => entry.path === workspace.path) : null;
|
|
521
|
+
workspace.trend = { previousScore: before ? before.score : null, delta: before ? workspace.score - before.score : null };
|
|
522
|
+
}
|
|
523
|
+
const currentEntry = {
|
|
524
|
+
at: Date.now(),
|
|
525
|
+
workspaces: workspaces.map((workspace) => ({ path: workspace.path, score: workspace.score, dimensions: workspace.dimensions })),
|
|
526
|
+
};
|
|
527
|
+
await saveHistory([...previousEntries, currentEntry]).catch(() => {});
|
|
364
528
|
return { generatedAt: Date.now(), workspaces, failures, discovered: discovered.length, report: renderReport(workspaces) };
|
|
365
529
|
}
|
|
366
530
|
|
|
@@ -406,5 +570,5 @@ function apply(ctx) {
|
|
|
406
570
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: SETTINGS_PATH, handler: createSettingsRoute() }), 'workspace-audit: settings');
|
|
407
571
|
}
|
|
408
572
|
|
|
409
|
-
export { apply };
|
|
573
|
+
export { apply, scanWorkspace, discoverWorkspaces, dependencyHealth, githubHealth, parseGitHubRemote, renderReport, grade };
|
|
410
574
|
export const inject = ['webServer'];
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomjerrygit/dsh-workspace-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test test/audit.test.mjs"
|
|
7
|
+
},
|
|
5
8
|
"description": "Local-first workspace health audit for DeepSeek Harness: Git, tests, CI, size, security hotspots, and a Markdown handover report.",
|
|
6
9
|
"type": "module",
|
|
7
10
|
"engines": {
|