@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.
Files changed (4) hide show
  1. package/README.md +11 -1
  2. package/client.js +45 -1
  3. package/index.js +173 -9
  4. 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
- ...workspaces.map((workspace) => {
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
- let secretFiles = [];
254
- const readmeText = rootEntries.some((entry) => /^readme/i.test(entry.name)) ? await fs.readFile(path.join(root, rootEntries.find((entry) => /^readme/i.test(entry.name)).name), 'utf8').catch(() => '') : '';
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) + (markers.lockfile ? 20 : 0) + (markers.gitignore ? 20 : 0) + (markers.ci ? 25 : 0) + (markers.docker ? 10 : 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) - (git.isRepo && !git.remote ? 10 : 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 (!markers.lockfile && markers.manifest) risks.push({ severity: 'P2', text: '有依赖清单但没有锁文件,构建不可复现。' });
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.1.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": {