@aionlabsai/aion 0.2.0 → 0.2.1

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 (118) hide show
  1. package/README.md +224 -9
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +461 -20
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/chat.d.ts +3 -0
  6. package/dist/cli/commands/chat.d.ts.map +1 -0
  7. package/dist/cli/commands/chat.js +128 -0
  8. package/dist/cli/commands/chat.js.map +1 -0
  9. package/dist/cli/commands/diff.d.ts +3 -0
  10. package/dist/cli/commands/diff.d.ts.map +1 -0
  11. package/dist/cli/commands/diff.js +144 -0
  12. package/dist/cli/commands/diff.js.map +1 -0
  13. package/dist/cli/commands/health.d.ts.map +1 -1
  14. package/dist/cli/commands/health.js +17 -0
  15. package/dist/cli/commands/health.js.map +1 -1
  16. package/dist/cli/commands/init.d.ts +3 -0
  17. package/dist/cli/commands/init.d.ts.map +1 -0
  18. package/dist/cli/commands/init.js +52 -0
  19. package/dist/cli/commands/init.js.map +1 -0
  20. package/dist/cli/commands/report.d.ts.map +1 -1
  21. package/dist/cli/commands/report.js +8 -0
  22. package/dist/cli/commands/report.js.map +1 -1
  23. package/dist/cli/menu.d.ts.map +1 -1
  24. package/dist/cli/menu.js +43 -1
  25. package/dist/cli/menu.js.map +1 -1
  26. package/dist/core/orchestrator.d.ts +2 -1
  27. package/dist/core/orchestrator.d.ts.map +1 -1
  28. package/dist/core/orchestrator.js +2 -2
  29. package/dist/core/orchestrator.js.map +1 -1
  30. package/dist/core/pipelines/audit-pipeline.d.ts +9 -1
  31. package/dist/core/pipelines/audit-pipeline.d.ts.map +1 -1
  32. package/dist/core/pipelines/audit-pipeline.js +98 -9
  33. package/dist/core/pipelines/audit-pipeline.js.map +1 -1
  34. package/dist/index.js +7 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/infra/aion-config.d.ts +26 -0
  37. package/dist/infra/aion-config.d.ts.map +1 -0
  38. package/dist/infra/aion-config.js +60 -0
  39. package/dist/infra/aion-config.js.map +1 -0
  40. package/dist/infra/aion-ignore.d.ts +3 -0
  41. package/dist/infra/aion-ignore.d.ts.map +1 -0
  42. package/dist/infra/aion-ignore.js +35 -0
  43. package/dist/infra/aion-ignore.js.map +1 -0
  44. package/dist/infra/audit-cache.d.ts +39 -0
  45. package/dist/infra/audit-cache.d.ts.map +1 -0
  46. package/dist/infra/audit-cache.js +82 -0
  47. package/dist/infra/audit-cache.js.map +1 -0
  48. package/dist/infra/audit-trend.d.ts +17 -0
  49. package/dist/infra/audit-trend.d.ts.map +1 -0
  50. package/dist/infra/audit-trend.js +113 -0
  51. package/dist/infra/audit-trend.js.map +1 -0
  52. package/dist/infra/db/store.d.ts.map +1 -1
  53. package/dist/infra/db/store.js +14 -7
  54. package/dist/infra/db/store.js.map +1 -1
  55. package/dist/infra/embeddings.d.ts.map +1 -1
  56. package/dist/infra/embeddings.js +32 -14
  57. package/dist/infra/embeddings.js.map +1 -1
  58. package/dist/prompts/scanner.d.ts +1 -0
  59. package/dist/prompts/scanner.d.ts.map +1 -1
  60. package/dist/prompts/scanner.js +4 -0
  61. package/dist/prompts/scanner.js.map +1 -1
  62. package/package.json +24 -19
  63. package/dist/agents/planner.test.d.ts +0 -2
  64. package/dist/agents/planner.test.d.ts.map +0 -1
  65. package/dist/agents/planner.test.js +0 -21
  66. package/dist/agents/planner.test.js.map +0 -1
  67. package/dist/core/cost-tracker.test.d.ts +0 -2
  68. package/dist/core/cost-tracker.test.d.ts.map +0 -1
  69. package/dist/core/cost-tracker.test.js +0 -36
  70. package/dist/core/cost-tracker.test.js.map +0 -1
  71. package/dist/core/pipelines/audit-pipeline.test.d.ts +0 -2
  72. package/dist/core/pipelines/audit-pipeline.test.d.ts.map +0 -1
  73. package/dist/core/pipelines/audit-pipeline.test.js +0 -135
  74. package/dist/core/pipelines/audit-pipeline.test.js.map +0 -1
  75. package/dist/core/repo-context.d.ts +0 -2
  76. package/dist/core/repo-context.d.ts.map +0 -1
  77. package/dist/core/repo-context.js +0 -12
  78. package/dist/core/repo-context.js.map +0 -1
  79. package/dist/core/repo-context.test.d.ts +0 -2
  80. package/dist/core/repo-context.test.d.ts.map +0 -1
  81. package/dist/core/repo-context.test.js +0 -40
  82. package/dist/core/repo-context.test.js.map +0 -1
  83. package/dist/core/runtime-policy.test.d.ts +0 -2
  84. package/dist/core/runtime-policy.test.d.ts.map +0 -1
  85. package/dist/core/runtime-policy.test.js +0 -27
  86. package/dist/core/runtime-policy.test.js.map +0 -1
  87. package/dist/infra/bm25.test.d.ts +0 -2
  88. package/dist/infra/bm25.test.d.ts.map +0 -1
  89. package/dist/infra/bm25.test.js +0 -17
  90. package/dist/infra/bm25.test.js.map +0 -1
  91. package/dist/infra/chunker.test.d.ts +0 -2
  92. package/dist/infra/chunker.test.d.ts.map +0 -1
  93. package/dist/infra/chunker.test.js +0 -33
  94. package/dist/infra/chunker.test.js.map +0 -1
  95. package/dist/infra/db/database.d.ts +0 -4
  96. package/dist/infra/db/database.d.ts.map +0 -1
  97. package/dist/infra/db/database.js +0 -25
  98. package/dist/infra/db/database.js.map +0 -1
  99. package/dist/infra/evidence-gate.test.d.ts +0 -2
  100. package/dist/infra/evidence-gate.test.d.ts.map +0 -1
  101. package/dist/infra/evidence-gate.test.js +0 -36
  102. package/dist/infra/evidence-gate.test.js.map +0 -1
  103. package/dist/infra/repo-index.test.d.ts +0 -2
  104. package/dist/infra/repo-index.test.d.ts.map +0 -1
  105. package/dist/infra/repo-index.test.js +0 -53
  106. package/dist/infra/repo-index.test.js.map +0 -1
  107. package/dist/infra/repo-query.test.d.ts +0 -2
  108. package/dist/infra/repo-query.test.d.ts.map +0 -1
  109. package/dist/infra/repo-query.test.js +0 -34
  110. package/dist/infra/repo-query.test.js.map +0 -1
  111. package/dist/infra/semgrep.test.d.ts +0 -2
  112. package/dist/infra/semgrep.test.d.ts.map +0 -1
  113. package/dist/infra/semgrep.test.js +0 -39
  114. package/dist/infra/semgrep.test.js.map +0 -1
  115. package/dist/schemas/audit.test.d.ts +0 -2
  116. package/dist/schemas/audit.test.d.ts.map +0 -1
  117. package/dist/schemas/audit.test.js +0 -41
  118. package/dist/schemas/audit.test.js.map +0 -1
@@ -20,6 +20,14 @@ const SEVERITY_ICON = {
20
20
  low: '⚪',
21
21
  info: '🔵',
22
22
  };
23
+ const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
24
+ function esc(value) {
25
+ return String(value ?? '')
26
+ .replace(/&/g, '&')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;');
30
+ }
23
31
  function renderAuditReport(report, durationMs) {
24
32
  console.log('\n' + chalk.bold('═'.repeat(60)));
25
33
  console.log(chalk.bold.cyan(' AUDIT REPORT') + chalk.gray(` — ${report.totalFiles} files — ${(durationMs / 1000).toFixed(1)}s`));
@@ -83,15 +91,380 @@ function saveAuditReport(report, durationMs) {
83
91
  const dir = join(process.cwd(), '.ai-runtime', 'reports');
84
92
  mkdirSync(dir, { recursive: true });
85
93
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
94
+ const createdAt = new Date().toISOString();
95
+ const fullReport = { ...report, durationMs, createdAt };
86
96
  const file = join(dir, `audit-${stamp}.json`);
87
- writeFileSync(file, JSON.stringify({ ...report, durationMs, createdAt: new Date().toISOString() }, null, 2), 'utf8');
88
- return file;
97
+ writeFileSync(file, JSON.stringify(fullReport, null, 2), 'utf8');
98
+ const runDir = join(dir, 'audits', stamp);
99
+ mkdirSync(runDir, { recursive: true });
100
+ const html = join(runDir, 'index.html');
101
+ const summary = join(runDir, 'summary.md');
102
+ const actionPlan = join(runDir, 'action-plan.md');
103
+ writeFileSync(join(runDir, 'report.json'), JSON.stringify(fullReport, null, 2), 'utf8');
104
+ writeFileSync(join(runDir, 'findings-by-persona.json'), JSON.stringify(groupFindings(report.findings, (f) => f.persona ?? 'local'), null, 2), 'utf8');
105
+ writeFileSync(join(runDir, 'findings-by-severity.json'), JSON.stringify(groupFindings(report.findings, (f) => f.severity), null, 2), 'utf8');
106
+ writeFileSync(join(runDir, 'findings-by-category.json'), JSON.stringify(groupFindings(report.findings, (f) => f.category), null, 2), 'utf8');
107
+ writeFileSync(join(runDir, 'files-hotspots.json'), JSON.stringify(buildFileHotspots(report.findings), null, 2), 'utf8');
108
+ writeFileSync(join(runDir, 'action-items.json'), JSON.stringify(buildActionItems(report.findings), null, 2), 'utf8');
109
+ writeFileSync(join(runDir, 'README.md'), renderAuditIndex(fullReport), 'utf8');
110
+ writeFileSync(summary, renderAuditSummary(fullReport), 'utf8');
111
+ writeFileSync(actionPlan, renderActionPlan(fullReport), 'utf8');
112
+ writeFileSync(html, renderAuditHtml(fullReport), 'utf8');
113
+ writeFileSync(join(dir, 'latest-audit.json'), JSON.stringify({ report: file, runDir, html, summary, actionPlan, createdAt }, null, 2), 'utf8');
114
+ return { runDir, html, summary, actionPlan, report: file };
115
+ }
116
+ function groupFindings(findings, keyFn) {
117
+ const grouped = {};
118
+ for (const finding of findings) {
119
+ const key = keyFn(finding);
120
+ grouped[key] ??= [];
121
+ grouped[key].push(finding);
122
+ }
123
+ return grouped;
124
+ }
125
+ function compact(value, max = 120) {
126
+ const normalized = value.replace(/\s+/g, ' ').trim();
127
+ if (normalized.length <= max)
128
+ return normalized;
129
+ return `${normalized.slice(0, max - 3)}...`;
130
+ }
131
+ function uniqueSorted(values) {
132
+ return [...new Set(values.filter(Boolean))].sort();
133
+ }
134
+ function maxSeverity(findings) {
135
+ return findings
136
+ .map((f) => f.severity)
137
+ .sort((a, b) => (SEVERITY_RANK[b] ?? 0) - (SEVERITY_RANK[a] ?? 0))[0] ?? 'info';
138
+ }
139
+ function actionKey(finding) {
140
+ const normalized = finding.finding
141
+ .toLowerCase()
142
+ .replace(/src\/[^\s:]+(:\d+)?/g, '')
143
+ .replace(/\d+/g, '#')
144
+ .replace(/[^a-z0-9]+/g, ' ')
145
+ .trim()
146
+ .slice(0, 90);
147
+ return `${finding.category}:${normalized}`;
148
+ }
149
+ function buildActionItems(findings) {
150
+ return Object.values(groupFindings(findings, actionKey))
151
+ .map((items) => {
152
+ const files = Object.entries(items.reduce((acc, finding) => {
153
+ acc[finding.file] ??= [];
154
+ if (typeof finding.line === 'number')
155
+ acc[finding.file].push(finding.line);
156
+ return acc;
157
+ }, {})).map(([file, lines]) => ({ file, lines: [...new Set(lines)].sort((a, b) => a - b) }));
158
+ const severity = maxSeverity(items);
159
+ return {
160
+ id: 0,
161
+ title: compact(items[0]?.finding ?? 'Review finding', 90),
162
+ severity,
163
+ category: items[0]?.category ?? 'maintainability',
164
+ personas: uniqueSorted(items.map((f) => f.persona ?? 'local')),
165
+ files,
166
+ findings: items.sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0)),
167
+ recommendation: compact(items[0]?.recommendation ?? 'Review and remediate.', 220),
168
+ };
169
+ })
170
+ .sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0) || b.findings.length - a.findings.length)
171
+ .map((item, index) => ({ ...item, id: index + 1 }));
172
+ }
173
+ function buildFileHotspots(findings) {
174
+ return Object.entries(groupFindings(findings, (f) => f.file))
175
+ .map(([file, items]) => ({
176
+ file,
177
+ findings: items.length,
178
+ maxSeverity: maxSeverity(items),
179
+ categories: uniqueSorted(items.map((f) => f.category)),
180
+ personas: uniqueSorted(items.map((f) => f.persona ?? 'local')),
181
+ }))
182
+ .sort((a, b) => (SEVERITY_RANK[b.maxSeverity] ?? 0) - (SEVERITY_RANK[a.maxSeverity] ?? 0) || b.findings - a.findings);
183
+ }
184
+ function markdownLocation(finding) {
185
+ return finding.line ? `${finding.file}:${finding.line}` : finding.file;
89
186
  }
90
- function renderDryRun(stats) {
187
+ function renderAuditSummary(report) {
188
+ const actions = buildActionItems(report.findings).slice(0, 10);
189
+ const hotspots = buildFileHotspots(report.findings).slice(0, 10);
190
+ const lines = [
191
+ '# Audit Summary',
192
+ '',
193
+ `Generated: ${report.createdAt}`,
194
+ `Duration: ${(report.durationMs / 1000).toFixed(1)}s`,
195
+ `Files covered by local scan: ${report.totalFiles}`,
196
+ `Findings: ${report.findings.length}`,
197
+ `Critical: ${report.criticalCount}`,
198
+ `High: ${report.highCount}`,
199
+ '',
200
+ '## Summary',
201
+ '',
202
+ report.summary,
203
+ '',
204
+ '## Top Priorities',
205
+ '',
206
+ ...(report.topPriorities.length > 0 ? report.topPriorities.map((p, i) => `${i + 1}. ${p}`) : ['No top priorities.']),
207
+ '',
208
+ '## Consolidated Actions',
209
+ '',
210
+ ...(actions.length > 0 ? actions.map((item) => `${item.id}. [${item.severity}] ${item.title} (${item.files.length} file(s))`) : ['No action items.']),
211
+ '',
212
+ '## File Hotspots',
213
+ '',
214
+ ...(hotspots.length > 0 ? hotspots.map((h) => `- ${h.file}: ${h.findings} finding(s), max ${h.maxSeverity}`) : ['No file hotspots.']),
215
+ '',
216
+ '## Generated Files',
217
+ '',
218
+ '- `index.html`: navigable report',
219
+ '- `action-plan.md`: consolidated remediation plan',
220
+ '- `report.json`: raw machine-readable audit',
221
+ '- `findings-by-persona.json`: findings grouped by persona',
222
+ '- `findings-by-severity.json`: findings grouped by severity',
223
+ '- `findings-by-category.json`: findings grouped by category',
224
+ ];
225
+ return lines.join('\n');
226
+ }
227
+ function renderActionPlan(report) {
228
+ const actions = buildActionItems(report.findings);
229
+ const lines = [
230
+ '# Audit Action Plan',
231
+ '',
232
+ `Generated: ${report.createdAt}`,
233
+ `Findings consolidated into ${actions.length} action item(s).`,
234
+ '',
235
+ ];
236
+ if (actions.length === 0) {
237
+ lines.push('No remediation actions were produced by this audit.');
238
+ lines.push('');
239
+ return lines.join('\n');
240
+ }
241
+ for (const item of actions) {
242
+ lines.push(`## ${item.id}. [${item.severity.toUpperCase()}] ${item.title}`);
243
+ lines.push('');
244
+ lines.push(`Category: ${item.category}`);
245
+ lines.push(`Personas: ${item.personas.join(', ') || 'local'}`);
246
+ lines.push('');
247
+ lines.push('Files:');
248
+ for (const file of item.files) {
249
+ const suffix = file.lines.length > 0 ? `:${file.lines.slice(0, 8).join(',')}` : '';
250
+ lines.push(`- ${file.file}${suffix}`);
251
+ }
252
+ lines.push('');
253
+ lines.push('Recommendation:');
254
+ lines.push(item.recommendation);
255
+ lines.push('');
256
+ lines.push('Evidence:');
257
+ for (const finding of item.findings.slice(0, 5)) {
258
+ lines.push(`- ${markdownLocation(finding)}: ${finding.finding}`);
259
+ }
260
+ if (item.findings.length > 5)
261
+ lines.push(`- ... ${item.findings.length - 5} more related finding(s)`);
262
+ lines.push('');
263
+ }
264
+ return lines.join('\n');
265
+ }
266
+ function renderAuditHtml(report) {
267
+ const actions = buildActionItems(report.findings);
268
+ const hotspots = buildFileHotspots(report.findings);
269
+ const byPersona = Object.entries(groupFindings(report.findings, (f) => f.persona ?? 'local'))
270
+ .sort((a, b) => b[1].length - a[1].length);
271
+ const bySeverity = Object.entries(groupFindings(report.findings, (f) => f.severity))
272
+ .sort((a, b) => (SEVERITY_RANK[b[0]] ?? 0) - (SEVERITY_RANK[a[0]] ?? 0));
273
+ const byCategory = Object.entries(groupFindings(report.findings, (f) => f.category))
274
+ .sort((a, b) => b[1].length - a[1].length);
275
+ const actionCards = actions.map((item) => `
276
+ <article class="card action" data-severity="${esc(item.severity)}" data-category="${esc(item.category)}">
277
+ <div class="row">
278
+ <span class="badge sev-${esc(item.severity)}">${esc(item.severity)}</span>
279
+ <span class="muted">#${item.id} · ${item.findings.length} finding(s) · ${item.files.length} file(s)</span>
280
+ </div>
281
+ <h3>${esc(item.title)}</h3>
282
+ <p>${esc(item.recommendation)}</p>
283
+ <div class="meta">Category: ${esc(item.category)} · Personas: ${esc(item.personas.join(', ') || 'local')}</div>
284
+ <details>
285
+ <summary>Evidence and files</summary>
286
+ <ul>
287
+ ${item.files.map((file) => `<li><code>${esc(file.file)}${file.lines.length ? ':' + esc(file.lines.slice(0, 8).join(',')) : ''}</code></li>`).join('')}
288
+ </ul>
289
+ <ol>
290
+ ${item.findings.slice(0, 6).map((finding) => `<li><code>${esc(markdownLocation(finding))}</code> ${esc(finding.finding)}</li>`).join('')}
291
+ </ol>
292
+ </details>
293
+ </article>`).join('');
294
+ const findingRows = report.findings.map((finding) => `
295
+ <tr data-severity="${esc(finding.severity)}" data-category="${esc(finding.category)}" data-persona="${esc(finding.persona ?? 'local')}" data-file="${esc(finding.file)}">
296
+ <td><span class="badge sev-${esc(finding.severity)}">${esc(finding.severity)}</span></td>
297
+ <td>${esc(finding.category)}</td>
298
+ <td>${esc(finding.persona ?? 'local')}</td>
299
+ <td><code>${esc(markdownLocation(finding))}</code></td>
300
+ <td>${esc(finding.finding)}<div class="recommendation">${esc(finding.recommendation)}</div></td>
301
+ </tr>`).join('');
302
+ const statList = (items, label) => items.map(([key, findings]) => {
303
+ const severity = maxSeverity(findings);
304
+ return `<li><span>${esc(key)}</span><strong class="sev-text-${esc(severity)}">${findings.length}</strong><small>${esc(label)} · max ${esc(severity)}</small></li>`;
305
+ }).join('');
306
+ return `<!doctype html>
307
+ <html lang="en">
308
+ <head>
309
+ <meta charset="utf-8">
310
+ <meta name="viewport" content="width=device-width, initial-scale=1">
311
+ <title>Aion Audit Report</title>
312
+ <style>
313
+ :root{color-scheme:dark;--bg:#0d1117;--panel:#161b22;--line:#30363d;--text:#e6edf3;--muted:#8b949e;--blue:#79c0ff;--green:#3fb950;--yellow:#e3b341;--red:#f85149;--gray:#6e7681}
314
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
315
+ a{color:var(--blue);text-decoration:none}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#c9d1d9}
316
+ .layout{display:grid;grid-template-columns:250px 1fr;min-height:100vh}.sidebar{position:sticky;top:0;height:100vh;border-right:1px solid var(--line);background:#010409;padding:22px;overflow:auto}
317
+ .sidebar h1{font-size:18px;margin:0 0 4px}.sidebar p{margin:0 0 18px;color:var(--muted);font-size:12px}.sidebar a{display:block;padding:7px 0;color:var(--muted)}.sidebar a:hover{color:var(--text)}
318
+ main{padding:28px;max-width:1300px}.hero{display:flex;justify-content:space-between;gap:20px;align-items:flex-start;border-bottom:1px solid var(--line);padding-bottom:22px;margin-bottom:22px}
319
+ h2{font-size:20px;margin:34px 0 14px;color:var(--blue)}h3{font-size:15px;margin:10px 0}.muted,.meta{color:var(--muted);font-size:12px}.cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
320
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px}.num{font-size:32px;font-weight:700}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
321
+ .badge{display:inline-flex;align-items:center;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:700;text-transform:uppercase}.sev-critical{background:#f8514920;color:var(--red)}.sev-high{background:#e3b34120;color:var(--yellow)}.sev-medium{background:#79c0ff20;color:var(--blue)}.sev-low{background:#8b949e20;color:var(--muted)}.sev-info{background:#6e768120;color:var(--gray)}
322
+ .sev-text-critical{color:var(--red)}.sev-text-high{color:var(--yellow)}.sev-text-medium{color:var(--blue)}.sev-text-low,.sev-text-info{color:var(--muted)}
323
+ .action{margin-bottom:12px}.action p{margin:6px 0 8px}.action details{margin-top:10px}.action summary{cursor:pointer;color:var(--blue)}
324
+ .stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.stats ul{list-style:none;margin:0;padding:0}.stats li{display:grid;grid-template-columns:1fr auto;gap:3px;border-bottom:1px solid #21262d;padding:8px 0}.stats small{grid-column:1/-1;color:var(--muted)}
325
+ .filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.filters input,.filters select{background:#010409;color:var(--text);border:1px solid var(--line);border-radius:6px;padding:8px}
326
+ table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #21262d;padding:9px}th{color:var(--muted);font-size:12px}.recommendation{color:var(--muted);font-size:12px;margin-top:4px}
327
+ .hotspots{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.hotspot{display:grid;grid-template-columns:1fr auto;gap:4px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px}
328
+ @media (max-width:900px){.layout{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.cards,.stats,.hotspots{grid-template-columns:1fr}.hero{display:block}}
329
+ </style>
330
+ </head>
331
+ <body>
332
+ <div class="layout">
333
+ <nav class="sidebar">
334
+ <h1>Aion Audit</h1>
335
+ <p>${esc(report.createdAt)}</p>
336
+ <a href="#overview">Overview</a>
337
+ <a href="#actions">Action Plan</a>
338
+ <a href="#findings">Findings</a>
339
+ <a href="#personas">Personas</a>
340
+ <a href="#files">Files</a>
341
+ <a href="#raw">Raw Data</a>
342
+ </nav>
343
+ <main>
344
+ <section class="hero" id="overview">
345
+ <div>
346
+ <h1>Audit Report</h1>
347
+ <p class="muted">${esc(report.summary)}</p>
348
+ </div>
349
+ <div class="meta">Duration ${(report.durationMs / 1000).toFixed(1)}s · ${report.totalFiles} files covered</div>
350
+ </section>
351
+ <section class="cards">
352
+ <div class="card"><div class="num">${report.findings.length}</div><div class="muted">Findings</div></div>
353
+ <div class="card"><div class="num sev-text-critical">${report.criticalCount}</div><div class="muted">Critical</div></div>
354
+ <div class="card"><div class="num sev-text-high">${report.highCount}</div><div class="muted">High</div></div>
355
+ <div class="card"><div class="num">${actions.length}</div><div class="muted">Action items</div></div>
356
+ </section>
357
+ <section>
358
+ <h2>Top Priorities</h2>
359
+ <ol>${report.topPriorities.length ? report.topPriorities.map((p) => `<li>${esc(p)}</li>`).join('') : '<li>No top priorities.</li>'}</ol>
360
+ </section>
361
+ <section id="actions">
362
+ <h2>Action Plan</h2>
363
+ ${actionCards || '<p class="muted">No action items.</p>'}
364
+ </section>
365
+ <section id="findings">
366
+ <h2>Findings</h2>
367
+ <div class="filters">
368
+ <input id="q" placeholder="Search findings, files, recommendations">
369
+ <select id="severity"><option value="">All severities</option>${bySeverity.map(([s]) => `<option value="${esc(s)}">${esc(s)}</option>`).join('')}</select>
370
+ <select id="persona"><option value="">All personas</option>${byPersona.map(([p]) => `<option value="${esc(p)}">${esc(p)}</option>`).join('')}</select>
371
+ <select id="category"><option value="">All categories</option>${byCategory.map(([c]) => `<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>
372
+ </div>
373
+ <table id="findingsTable"><thead><tr><th>Severity</th><th>Category</th><th>Persona</th><th>Location</th><th>Finding</th></tr></thead><tbody>${findingRows}</tbody></table>
374
+ </section>
375
+ <section id="personas">
376
+ <h2>Personas And Categories</h2>
377
+ <div class="stats">
378
+ <div class="card"><h3>By Persona</h3><ul>${statList(byPersona, 'persona')}</ul></div>
379
+ <div class="card"><h3>By Severity</h3><ul>${statList(bySeverity, 'severity')}</ul></div>
380
+ <div class="card"><h3>By Category</h3><ul>${statList(byCategory, 'category')}</ul></div>
381
+ </div>
382
+ </section>
383
+ <section id="files">
384
+ <h2>File Hotspots</h2>
385
+ <div class="hotspots">${hotspots.slice(0, 30).map((h) => `<div class="hotspot"><code>${esc(h.file)}</code><strong class="sev-text-${esc(h.maxSeverity)}">${h.findings}</strong><small class="muted">${esc(h.categories.join(', '))}</small></div>`).join('') || '<p class="muted">No hotspots.</p>'}</div>
386
+ </section>
387
+ <section id="raw">
388
+ <h2>Raw Data</h2>
389
+ <ul>
390
+ <li><a href="report.json">report.json</a></li>
391
+ <li><a href="action-items.json">action-items.json</a></li>
392
+ <li><a href="findings-by-persona.json">findings-by-persona.json</a></li>
393
+ <li><a href="findings-by-severity.json">findings-by-severity.json</a></li>
394
+ <li><a href="findings-by-category.json">findings-by-category.json</a></li>
395
+ <li><a href="files-hotspots.json">files-hotspots.json</a></li>
396
+ </ul>
397
+ </section>
398
+ </main>
399
+ </div>
400
+ <script>
401
+ const q = document.getElementById('q');
402
+ const severity = document.getElementById('severity');
403
+ const persona = document.getElementById('persona');
404
+ const category = document.getElementById('category');
405
+ const rows = Array.from(document.querySelectorAll('#findingsTable tbody tr'));
406
+ function applyFilters() {
407
+ const text = q.value.toLowerCase();
408
+ for (const row of rows) {
409
+ const okText = !text || row.textContent.toLowerCase().includes(text);
410
+ const okSeverity = !severity.value || row.dataset.severity === severity.value;
411
+ const okPersona = !persona.value || row.dataset.persona === persona.value;
412
+ const okCategory = !category.value || row.dataset.category === category.value;
413
+ row.style.display = okText && okSeverity && okPersona && okCategory ? '' : 'none';
414
+ }
415
+ }
416
+ [q, severity, persona, category].forEach((el) => el.addEventListener('input', applyFilters));
417
+ </script>
418
+ </body>
419
+ </html>`;
420
+ }
421
+ function renderAuditIndex(report) {
422
+ const byPersona = Object.entries(groupFindings(report.findings, (f) => f.persona ?? 'local'))
423
+ .sort((a, b) => b[1].length - a[1].length);
424
+ const bySeverity = Object.entries(groupFindings(report.findings, (f) => f.severity))
425
+ .sort((a, b) => b[1].length - a[1].length);
426
+ const lines = [
427
+ '# Audit Report',
428
+ '',
429
+ `- Created: ${report.createdAt}`,
430
+ `- Duration: ${(report.durationMs / 1000).toFixed(1)}s`,
431
+ `- Files covered by local scan: ${report.totalFiles}`,
432
+ `- Findings: ${report.findings.length}`,
433
+ `- Critical: ${report.criticalCount}`,
434
+ `- High: ${report.highCount}`,
435
+ '',
436
+ '## Summary',
437
+ '',
438
+ report.summary,
439
+ '',
440
+ '## Top Priorities',
441
+ '',
442
+ ...report.topPriorities.map((priority, index) => `${index + 1}. ${priority}`),
443
+ '',
444
+ '## Findings By Persona',
445
+ '',
446
+ ...byPersona.map(([persona, findings]) => `- ${persona}: ${findings.length}`),
447
+ '',
448
+ '## Findings By Severity',
449
+ '',
450
+ ...bySeverity.map(([severity, findings]) => `- ${severity}: ${findings.length}`),
451
+ '',
452
+ '## Files',
453
+ '',
454
+ '- `report.json`: full machine-readable audit report',
455
+ '- `findings-by-persona.json`: findings grouped by scanner persona',
456
+ '- `findings-by-severity.json`: findings grouped by severity',
457
+ '- `findings-by-category.json`: findings grouped by category',
458
+ '',
459
+ ];
460
+ return lines.join('\n');
461
+ }
462
+ function renderDryRun(pipeline, stats, maxFilesForAi) {
91
463
  console.log('\n' + chalk.bold.cyan('Audit dry run'));
92
464
  console.log(chalk.gray('No agents were started and no API tokens were used.'));
93
465
  console.log(` total files seen: ${stats.totalFiles}`);
94
466
  console.log(` audit source files: ${stats.auditFiles.length}`);
467
+ console.log(` AI target files: ${Math.min(stats.auditFiles.length, maxFilesForAi)} prioritized file(s)`);
95
468
  console.log(` ignored directories: ${stats.ignoredDirs}`);
96
469
  console.log(` ignored/non-source files: ${stats.ignoredFiles}`);
97
470
  console.log(` oversized source files: ${stats.oversizedFiles}`);
@@ -101,8 +474,9 @@ function renderDryRun(stats) {
101
474
  exts.forEach(([ext, count]) => console.log(` ${ext}: ${count}`));
102
475
  }
103
476
  if (stats.auditFiles.length > 0) {
104
- console.log('\n' + chalk.bold('Sample:'));
105
- stats.auditFiles.slice(0, 10).forEach((file) => console.log(` ${file}`));
477
+ const prioritized = pipeline.prioritizeFiles(stats.auditFiles, maxFilesForAi);
478
+ console.log('\n' + chalk.bold('AI target sample:'));
479
+ prioritized.slice(0, 15).forEach((file) => console.log(` ${file}`));
106
480
  }
107
481
  }
108
482
  export function registerAudit(program) {
@@ -116,10 +490,15 @@ export function registerAudit(program) {
116
490
  .option('--preset <name>', 'persona preset: security, ai, backend, devops, quality, saas, fintech, full')
117
491
  .option('--domains <list>', 'comma-separated scanner domains, e.g. security,compliance,data')
118
492
  .option('--list-personas', 'show all available personas and presets then exit')
493
+ .option('--local-only', 'run only deterministic local scans; no AI scanners or synthesizer')
494
+ .option('--force-full', 'allow the full preset to start all requested AI scanner domains')
495
+ .option('--max-files <n>', 'max prioritized source files sent to AI scanners (default: 30 low, 60 normal, 120 deep)')
496
+ .option('--scanner-timeout <seconds>', 'timeout per AI scanner in seconds (default: 90 low, 150 normal, 240 deep)')
119
497
  .option('--fix', 'auto-fix critical/high findings after audit')
120
498
  .option('--fix-max <n>', 'max findings to auto-fix (default: 5)', '5')
121
499
  .option('--fix-min-severity <s>', 'minimum severity to fix: critical|high|medium (default: high)', 'high')
122
500
  .option('--dry-run', 'collect audit file stats without starting agents')
501
+ .option('--incremental', 'only scan files changed since last audit (reuse cache for unchanged)')
123
502
  .action(async (target = '.', options) => {
124
503
  if (options.listPersonas) {
125
504
  const { listPresets, BUILT_IN_PRESETS } = await import('../../infra/persona-presets.js');
@@ -132,12 +511,22 @@ export function registerAudit(program) {
132
511
  listPresets();
133
512
  return;
134
513
  }
135
- const explicitN = options.scanners ? Math.max(1, Math.min(15, parseInt(options.scanners, 10) || 5)) : undefined;
136
- const budget = (['low', 'normal', 'deep'].includes(options.budget) ? options.budget : 'low');
137
- const providerName = options.provider === 'openrouter' ? 'openrouter' : undefined;
514
+ // Merge .aionrc.json config under CLI options
515
+ const { loadAionConfig, mergeConfig } = await import('../../infra/aion-config.js');
516
+ const aionConfig = loadAionConfig(process.cwd());
517
+ const mergedOptions = mergeConfig(options, aionConfig);
518
+ const explicitN = mergedOptions.scanners ? Math.max(1, Math.min(15, parseInt(String(mergedOptions.scanners), 10) || 5)) : undefined;
519
+ const budget = (['low', 'normal', 'deep'].includes(mergedOptions.budget) ? mergedOptions.budget : 'low');
520
+ const scannerTimeoutSeconds = mergedOptions.scannerTimeout
521
+ ? Math.max(10, Math.min(600, parseInt(String(mergedOptions.scannerTimeout), 10) || 90))
522
+ : budget === 'deep' ? 240 : budget === 'normal' ? 150 : 90;
523
+ const maxFilesForAi = mergedOptions.maxFiles
524
+ ? Math.max(1, Math.min(500, parseInt(String(mergedOptions.maxFiles), 10) || 30))
525
+ : budget === 'deep' ? 120 : budget === 'normal' ? 60 : 30;
526
+ const providerName = mergedOptions.provider === 'openrouter' ? 'openrouter' : undefined;
138
527
  const policyInput = {
139
528
  budget,
140
- ...(options.model ? { openrouterModel: options.model } : {}),
529
+ ...(mergedOptions.model ? { openrouterModel: mergedOptions.model } : {}),
141
530
  ...(providerName ? {
142
531
  plannerProvider: providerName,
143
532
  investigatorProvider: providerName,
@@ -150,7 +539,7 @@ export function registerAudit(program) {
150
539
  const orch = new Orchestrator(process.cwd(), policyInput);
151
540
  if (options.dryRun) {
152
541
  const pipeline = new AuditPipeline(process.cwd(), policy, new CostTracker(), () => { }, () => { });
153
- renderDryRun(pipeline.collectAuditStats(target));
542
+ renderDryRun(pipeline, pipeline.collectAuditStats(target), maxFilesForAi);
154
543
  return;
155
544
  }
156
545
  orch.on('agent:start', ({ agentName }) => renderer.agentStart(agentName));
@@ -158,25 +547,77 @@ export function registerAudit(program) {
158
547
  orch.on('agent:done', ({ agentName, durationMs }) => renderer.agentDone(agentName, durationMs));
159
548
  // Resolve persona domains
160
549
  const { resolveDomainsFromConfig } = await import('../../infra/persona-presets.js');
161
- const { domains: explicitDomains, source: domainSource } = resolveDomainsFromConfig(process.cwd(), options.preset, options.domains, explicitN);
550
+ const { domains: explicitDomains, source: domainSource } = resolveDomainsFromConfig(process.cwd(), mergedOptions.preset, mergedOptions.domains, explicitN);
551
+ const maxAiScanners = explicitN ?? policy.maxAgents;
552
+ const requestsFullPreset = (mergedOptions.preset === 'full' && explicitN === undefined) || explicitDomains.length > maxAiScanners;
553
+ if (requestsFullPreset && !mergedOptions.forceFull && !mergedOptions.localOnly) {
554
+ console.error(chalk.red.bold('\nRefusing expensive full audit by default.\n'));
555
+ console.error(chalk.gray(`Requested ${explicitDomains.length} domains, but ${budget} budget allows ${maxAiScanners} AI scanner(s).`));
556
+ console.error(chalk.gray('Use one of:'));
557
+ console.error(` ${chalk.cyan('aion audit . --local-only')} no AI tokens`);
558
+ console.error(` ${chalk.cyan(`aion audit . --preset ${mergedOptions.preset ?? 'security'} --scanners ${maxAiScanners}`)} capped AI audit`);
559
+ console.error(` ${chalk.cyan('aion audit . --preset full --force-full')} explicit full-cost run`);
560
+ process.exitCode = 1;
561
+ return;
562
+ }
162
563
  const start = Date.now();
163
- const nLabel = explicitDomains.length > 0
164
- ? `personas: ${explicitDomains.join(', ')} [${domainSource}]`
165
- : explicitN ? `${explicitN} scanners` : `auto scanners (${budget} budget)`;
564
+ const nLabel = mergedOptions.localOnly
565
+ ? 'local-only scan (no AI tokens)'
566
+ : explicitDomains.length > 0
567
+ ? mergedOptions.localOnly
568
+ ? `local-only scan [${domainSource}]`
569
+ : `personas: ${explicitDomains.slice(0, maxAiScanners).join(', ')} [${domainSource}]`
570
+ : explicitN ? `${explicitN} scanners` : `auto scanners (${budget} budget)`;
166
571
  console.log(chalk.bold.cyan(`\nStarting audit with ${nLabel}...\n`));
167
572
  try {
168
- const report = await orch.runAuditPipeline(target, explicitN, explicitDomains.length > 0 ? explicitDomains : undefined);
573
+ const report = await orch.runAuditPipeline(target, explicitN, explicitDomains.length > 0 ? explicitDomains : undefined, {
574
+ incremental: options.incremental,
575
+ localOnly: mergedOptions.localOnly,
576
+ maxAiScanners: mergedOptions.forceFull ? explicitDomains.length || explicitN : maxAiScanners,
577
+ maxFilesForAi,
578
+ scannerTimeoutMs: scannerTimeoutSeconds * 1000,
579
+ });
169
580
  const durationMs = Date.now() - start;
170
581
  renderAuditReport(report, durationMs);
171
- console.log(chalk.gray(`report: ${saveAuditReport(report, durationMs)}`));
582
+ const saved = saveAuditReport(report, durationMs);
583
+ console.log(chalk.gray(`report: ${saved.runDir}`));
584
+ console.log(chalk.gray(`html: ${saved.html}`));
585
+ console.log(chalk.gray(`summary: ${saved.summary}`));
586
+ console.log(chalk.gray(`action plan: ${saved.actionPlan}`));
172
587
  console.log(chalk.dim(orch.costs.summary()));
173
- if (options.fix && (report.criticalCount > 0 || report.highCount > 0)) {
588
+ if (options.fix) {
174
589
  const maxFixes = Math.max(1, Math.min(20, parseInt(options.fixMax, 10) || 5));
175
590
  const minSev = (['critical', 'high', 'medium'].includes(options.fixMinSeverity)
176
591
  ? options.fixMinSeverity : 'high');
177
- console.log(chalk.bold.yellow(`\nAuto-fixing up to ${maxFixes} ${minSev}+ findings...\n`));
178
- const fixReport = await orch.runAuditFixPipeline(report, { maxFixes, minSeverity: minSev });
179
- console.log(chalk.bold(`Fix summary: ${fixReport.succeeded} fixed, ${fixReport.failed} failed, ${fixReport.skipped} skipped`));
592
+ const eligible = report.findings
593
+ .filter((f) => f.file && f.line && ((SEVERITY_RANK[f.severity] ?? 0) >= (SEVERITY_RANK[minSev] ?? 0)))
594
+ .sort((a, b) => (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0))
595
+ .slice(0, maxFixes);
596
+ if (options.dryRun) {
597
+ // Dry-run: show what would be fixed without running
598
+ console.log('\n' + chalk.bold.yellow(`Fix dry-run — ${eligible.length} finding(s) would be targeted (up to ${maxFixes}, severity ≥ ${minSev}):\n`));
599
+ if (eligible.length === 0) {
600
+ console.log(chalk.dim(' No eligible findings (need file+line and severity ≥ ' + minSev + ')'));
601
+ }
602
+ else {
603
+ eligible.forEach((f, i) => {
604
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
605
+ const sev = SEVERITY_COLOR[f.severity]?.(f.severity) ?? chalk.white(f.severity);
606
+ console.log(` ${i + 1}. ${sev} ${chalk.bold(loc)}`);
607
+ console.log(` ${f.finding}`);
608
+ console.log(chalk.dim(` → ${f.recommendation}`));
609
+ console.log();
610
+ });
611
+ }
612
+ const skipped = report.findings.length - eligible.length;
613
+ if (skipped > 0)
614
+ console.log(chalk.dim(` ${skipped} finding(s) skipped (below threshold or missing file+line)`));
615
+ }
616
+ else if (report.criticalCount > 0 || report.highCount > 0 || minSev === 'medium') {
617
+ console.log(chalk.bold.yellow(`\nAuto-fixing up to ${maxFixes} ${minSev}+ findings...\n`));
618
+ const fixReport = await orch.runAuditFixPipeline(report, { maxFixes, minSeverity: minSev });
619
+ console.log(chalk.bold(`Fix summary: ${fixReport.succeeded} fixed, ${fixReport.failed} failed, ${fixReport.skipped} skipped`));
620
+ }
180
621
  }
181
622
  process.exit(report.criticalCount > 0 ? 2 : report.highCount > 0 ? 1 : 0);
182
623
  }