diff --git a/README.md b/README.md
index 3da7c28..dd4b06b 100755
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@ CoreTrace orchestrates multiple industry-standard analyzers and unifies their ou
- **Dynamic & Stack Analysis**: Identifies stack overflows, infinite recursion risks, uninitialized variables, and large stack frame allocations (`ctrace_stack_analyzer`).
- **Compiler Warnings**: Intercepts Clang/GCC compiler diagnostics and maps them into standard editor problems.
- **Rich Dashboard**: Filter findings by severity (Errors, Warnings, Info), search by keyword, and click any item to jump directly to the source file and line.
+- **Problems Panel Integration**: Every finding is also a native VS Code diagnostic, tagged with the analyzer that produced it (e.g. `CoreTrace (cppcheck)`). Findings reported into a header by several source files are all kept.
+- **Cancellable Analyses**: Cancelling a run, or hitting the per-file timeout, stops `ctrace` and every analyzer it started β no orphaned processes.
### 2. π‘οΈ In-Editor CodeLens: "Audit Function"
Perform targeted security checks without scanning an entire file or project:
@@ -47,8 +49,35 @@ Precise C/C++ static analysis requires knowledge of compiler flags, include dire
> cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
> ```
-### 6. Smart Caching
-Save time on repetitive scans. CoreTrace computes file content hashes and only re-analyzes C/C++ files that have changed since your last run, speeding up iterative development.
+### 6. Incremental Workspace Scans
+Save time on repetitive scans. CoreTrace computes file content hashes and only re-analyzes C/C++ files that have changed since their last analysis; the results of the other files are reused, even after restarting VS Code.
+- The cache is invalidated for every file when a header, `compile_commands.json`, the `ctrace` binary or the analysis options change, since any of these can change the result of an unmodified source file.
+- Run **Clear Analysis Cache** to force a full scan, or set `ctrace.workspace.incremental` to `false` to disable the cache.
+
+### 7. CWE Links
+When an analyzer maps a finding to a CWE, the diagnostic code in the Problems panel (e.g. `doubleFree Β· CWE-415`) links to its page on cwe.mitre.org, and the sidebar shows a clickable CWE badge. Diagnostics also name the analyzer that produced them and list the other analyzers that reported the same issue.
+
+### 8. Ignoring Findings
+Triage false positives without losing them:
+- Use the **CoreTrace: Ignore this finding** quick fix (lightbulb) on a diagnostic, or the **Ignore** button on a finding in the sidebar. You can give a reason.
+- Ignored findings are stored in `.coretrace-ignore.json` at the workspace root, so a team can commit it. A finding stays ignored when lines are added above it.
+- **CoreTrace: Manage Ignored Findings** lists them and restores the ones you select. Editing or deleting the file also updates the results immediately.
+
+### 9. Stack Tree
+The **Stack Tree** tab shows how much stack your functions use, in a layout made for the narrow sidebar:
+- **Call tree**: an expandable tree of who calls what, heaviest callees first. A bar behind each value shows its share of the highest peak, and the heaviest call path is highlighted in red. The flame button jumps straight to it.
+- **Ranking**: every function sorted by peak stack.
+- **Filter** by function or file name: matches stay in place in the tree with the calls leading to them.
+- **Details**: selecting a function shows its local and peak stack, its callers and callees (click one to jump to it), and a link to the source.
+- **Keyboard**: arrow keys navigate and open rows, `Enter` opens the source (double-click works too).
+- Recursive calls end on a row marked `β»` instead of expanding forever.
+
+### 10. Settings Panel
+The gear icon opens the analysis settings, grouped by purpose:
+- **Search** across every setting; matches inside a switched-off group lead you to its switch.
+- A **counter** on the gear and on each section shows how many settings are changed, and a bar marks each changed row, so a forgotten filter cannot silently narrow your results.
+- Options that do not apply to the current scan mode say so on a single line.
+- **Reset to defaults** restores everything in two clicks. Settings stored in `settings.json` (timeout, incremental scans, analysis modelsβ¦) are one click away.
---
@@ -84,7 +113,69 @@ Access these commands from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
| `ctrace.runAnalysis` | **Run Analysis** | Runs analysis on the currently active C/C++ file. |
| `ctrace.runWorkspaceAnalysis` | **Run Workspace Analysis** | Analyzes all modified C/C++ files in the workspace. |
| `ctrace.installDependencies` | **CoreTrace: Install Analyzers & Dependencies** | Installs or repairs external static analyzers (`cppcheck`, `flawfinder`, `ikos`, `tscancode`). |
-| `ctrace.clearAnalysisCache` | **Clear Analysis Cache** | Clears cached analysis hashes to force a clean re-scan. |
+| `ctrace.clearAnalysisCache` | **Clear Analysis Cache** | Clears cached analysis results to force a full re-scan. |
+| `ctrace.focusStackFunction` | **CoreTrace: View Function in Stack Visualizer** | Opens the Stack Visualizer on a function. |
+| `ctrace.ignoreFinding` | **CoreTrace: Ignore Finding at Cursor** | Adds the finding under the cursor to `.coretrace-ignore.json`. |
+| `ctrace.manageIgnoredFindings` | **CoreTrace: Manage Ignored Findings** | Lists ignored findings and restores the selected ones. |
+
+---
+
+## π§ Configuration
+
+All settings live under `ctrace.*` and can be edited in the Settings UI (search for **CoreTrace**) or in `.vscode/settings.json`.
+
+| Setting | Default | Description |
+|---|---|---|
+| `ctrace.workspace.incremental` | `true` | Reuse the results of unchanged files in workspace scans. |
+| `ctrace.analysis.timeoutSeconds` | `60` | Maximum time to analyse one file before `ctrace` is stopped. Raise it for large files or when IKOS is enabled. |
+| `ctrace.compileCommandsPath` | `""` | Path to `compile_commands.json`. Auto-detected when empty. |
+| `ctrace.analysis.autoEntryPoints` | `false` | In single-file mode, pass every function of the file as an entry point. |
+| `ctrace.analysis.stackLimit` | `""` | Stack size limit passed as `--stack-limit` (e.g. `8192`). |
+| `ctrace.analysis.resourceCrossTu` | `"default"` | Inter-procedural resource analysis across translation units (`default`, `on`, `off`). |
+| `ctrace.models.escapeModel` / `bufferModel` / `resourceModel` | `""` | Custom analysis models passed to `ctrace`. |
+| `ctrace.performance.compdbFast` | `false` | Fast compilation-database mode of the stack analyzer. |
+| `ctrace.performance.compileIrCacheDir` | `""` | Directory where the stack analyzer caches compiled IR. |
+| `ctrace.cache.resourceSummaryDir` | `""` | Directory used to cache resource summaries between runs. |
+| `ctrace.cache.memoryOnly` | `false` | Keep resource summaries in memory only. |
+
+Example `.coretrace-ignore.json` (created by the **Ignore** actions):
+
+```json
+{
+ "version": 1,
+ "ignored": [
+ {
+ "fingerprint": "2e1cde0b6dadad83fea0",
+ "ruleId": "doubleFree",
+ "file": "src/double_free.c",
+ "line": 10,
+ "message": "Memory pointed to by 'ptr' is freed twice.",
+ "reason": "false positive: ptr is reset by the macro",
+ "addedAt": "2026-10-29T11:16:21.971Z"
+ }
+ ]
+}
+```
+
+---
+
+## π§βπ» Development
+
+```bash
+npm install
+```
+
+```bash
+npm run compile
+```
+
+```bash
+npm test
+```
+
+The test suite runs inside a downloaded VS Code instance (`@vscode/test-cli`). Press `F5` in VS Code to start an Extension Development Host with the extension loaded.
+
+`src/test/params.integration.test.ts` runs every analysis parameter through a real `ctrace`. It uses the binary named by `CTRACE_BIN`, or a `ctrace` file at the repository root, and is skipped when there is none, as on CI.
---
diff --git a/media/main.js b/media/main.js
index 649d309..078b8eb 100755
--- a/media/main.js
+++ b/media/main.js
@@ -19,23 +19,24 @@
const stackPeakVal = document.getElementById('stack-peak-val');
const stackRecursionVal = document.getElementById('stack-recursion-val');
const stackFunctionsVal = document.getElementById('stack-functions-val');
- const stackGraphContainer = document.getElementById('stack-graph-container');
- const stackGraphInspector = document.getElementById('stack-graph-inspector');
- const stackGraphCount = document.getElementById('stack-graph-count');
- const stackGraphModeChains = document.getElementById('stack-graph-mode-chains');
- const stackGraphModeAll = document.getElementById('stack-graph-mode-all');
- let stackGraphMode = 'chains';
- const stackZoomOut = document.getElementById('stack-zoom-out');
- const stackZoomFit = document.getElementById('stack-zoom-fit');
- const stackZoomIn = document.getElementById('stack-zoom-in');
- const stackZoomLabel = document.getElementById('stack-zoom-label');
- const stackGraphResizer = document.getElementById('stack-graph-resizer');
- const stackFnList = document.getElementById('stack-fn-list');
+ const stackTreeEl = document.getElementById('stack-tree');
+ const stackDetails = document.getElementById('stack-details');
+ const stackTreeCount = document.getElementById('stack-tree-count');
+ const stackViewTree = document.getElementById('stack-view-tree');
+ const stackViewRanking = document.getElementById('stack-view-ranking');
+ const stackTreeActions = document.getElementById('stack-tree-actions');
+ const stackHotPath = document.getElementById('stack-hot-path');
+ const stackExpandAll = document.getElementById('stack-expand-all');
+ const stackCollapseAll = document.getElementById('stack-collapse-all');
const stackSearch = document.getElementById('stack-search');
- let currentStackReport = null;
- let graphSelectedId = null;
- let graphZoom = 0.85;
- let graphPan = null;
+ const stackTreeLib = window.CoreTraceStackTree;
+ let stackModel = null;
+ let stackView = 'tree';
+ let stackExpanded = new Set();
+ // Until the user opens or closes a row, each report of a workspace scan
+ // gets the default layout; afterwards their layout is kept.
+ let stackTreeTouched = false;
+ let stackSelectedKey = null;
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const statusDetail = document.getElementById('status-detail');
@@ -68,14 +69,25 @@
const crossTuCb = document.getElementById('resource-cross-tu');
const autoEntryGroup = document.getElementById('auto-entry-points-group');
const autoEntryCb = document.getElementById('auto-entry-points');
- const autoEntryHint = document.getElementById('auto-entry-points-hint');
const smtSection = document.getElementById('smt-section');
const profileToggle = document.getElementById('analysis-profile-toggle');
const profileLabel = document.getElementById('analysis-profile-label');
const ikosCb = document.querySelector('.static-tool[value="ikos"]');
+ const settingsSearch = document.getElementById('settings-search');
+ const settingsBadge = document.getElementById('settings-badge');
+ const settingsNoMatch = document.getElementById('settings-no-match');
+ const resetSettingsBtn = document.getElementById('reset-settings');
+ const resetSettingsLabel = document.getElementById('reset-settings-label');
+ const openVsCodeSettingsBtn = document.getElementById('open-vscode-settings');
+ const smtFields = document.getElementById('smt-fields');
+ const smtSecondaryGroup = document.getElementById('smt-secondary-group');
+ const smtModeSelect = document.getElementById('smt-mode');
const persistedState = vscode.getState() || {};
+ // While a search forces sections open: the ones to reopen afterwards.
+ let settingsSearchRestore = null;
let analysisProfile = 'full';
let findings = [];
+ let ignoredCount = 0;
let currentStatus = 'ready';
let isRunning = false;
let isDownloading = false;
@@ -202,7 +214,9 @@
: (lastFileName || 'Open a C/C++ file to analyse');
}
[onlyDirGroup, excludeDirGroup].forEach(group => {
- if (group) { group.classList.toggle('is-hidden', !workspace); }
+ if (!group) { return; }
+ group.classList.toggle('is-disabled', !workspace);
+ group.querySelector('input').disabled = !workspace;
});
if (!workspace && modeChanged) {
setValue('only-dir', '');
@@ -215,7 +229,6 @@
if (crossTuGroup) { crossTuGroup.classList.toggle('is-disabled', !workspace); }
if (autoEntryCb) { autoEntryCb.disabled = workspace; }
if (autoEntryGroup) { autoEntryGroup.classList.toggle('is-disabled', workspace); }
- if (autoEntryHint) { autoEntryHint.hidden = !workspace; }
saveUiState();
}
@@ -244,6 +257,12 @@
});
}
+ if (openVsCodeSettingsBtn) {
+ openVsCodeSettingsBtn.addEventListener('click', () => {
+ vscode.postMessage({ type: 'open-vscode-settings' });
+ });
+ }
+
if (browseCompileCommands) {
browseCompileCommands.addEventListener('click', () => {
vscode.postMessage({ type: 'pick-compile-commands' });
@@ -254,112 +273,34 @@
if (severityFilter) { severityFilter.addEventListener('change', renderFilteredFindings); }
if (tabFindingsBtn) { tabFindingsBtn.addEventListener('click', () => switchResultTab('findings')); }
if (tabStackBtn) { tabStackBtn.addEventListener('click', () => switchResultTab('stack')); }
- if (stackSearch) { stackSearch.addEventListener('input', renderFilteredStackFunctions); }
- if (stackGraphModeChains) {
- stackGraphModeChains.addEventListener('click', () => {
- stackGraphMode = 'chains';
- stackGraphModeChains.classList.add('active');
- stackGraphModeAll?.classList.remove('active');
- if (currentStackReport) { renderCallGraph(currentStackReport); }
+ if (stackSearch) { stackSearch.addEventListener('input', renderStackView); }
+ if (stackViewTree) { stackViewTree.addEventListener('click', () => setStackView('tree')); }
+ if (stackViewRanking) { stackViewRanking.addEventListener('click', () => setStackView('ranking')); }
+ if (stackHotPath) { stackHotPath.addEventListener('click', showHotPath); }
+ if (stackExpandAll) {
+ stackExpandAll.addEventListener('click', () => {
+ if (!stackModel) { return; }
+ stackExpanded = stackTreeLib.allExpanded(stackModel);
+ stackTreeTouched = true;
+ renderStackView();
});
}
- if (stackGraphModeAll) {
- stackGraphModeAll.addEventListener('click', () => {
- stackGraphMode = 'all';
- stackGraphModeAll.classList.add('active');
- stackGraphModeChains?.classList.remove('active');
- if (currentStackReport) { renderCallGraph(currentStackReport); }
+ if (stackCollapseAll) {
+ stackCollapseAll.addEventListener('click', () => {
+ stackExpanded = new Set();
+ stackTreeTouched = true;
+ renderStackView();
});
}
- if (stackZoomOut) { stackZoomOut.addEventListener('click', () => { setZoomAt(graphZoom - 0.15); }); }
- if (stackZoomIn) { stackZoomIn.addEventListener('click', () => { setZoomAt(graphZoom + 0.15); }); }
- if (stackZoomFit) { stackZoomFit.addEventListener('click', fitGraphToView); }
- if (stackZoomLabel) {
- stackZoomLabel.addEventListener('click', () => { setZoomAt(1.0); });
- stackZoomLabel.addEventListener('dblclick', fitGraphToView);
- }
- const zoomBar = document.querySelector('.stack-graph-zoom');
- if (zoomBar) {
- zoomBar.addEventListener('wheel', event => {
- event.preventDefault();
- const step = event.deltaY < 0 ? 0.12 : -0.12;
- setZoomAt(graphZoom + step);
- }, { passive: false });
- }
- if (stackGraphContainer) {
- // Trackpad pinch gesture (ctrlKey) and mouse wheel (Ctrl+wheel, Alt+wheel)
- stackGraphContainer.addEventListener('wheel', event => {
- if (event.ctrlKey || event.metaKey || event.altKey) {
- event.preventDefault();
- const factor = Math.exp(-event.deltaY * 0.006);
- setZoomAt(graphZoom * factor, event.clientX, event.clientY);
- }
- }, { passive: false });
-
- stackGraphContainer.addEventListener('pointerdown', event => {
- if (event.pointerType !== 'mouse' || event.button !== 0 || event.target.closest('.stack-graph-node')) { return; }
- graphPan = { x: event.clientX, y: event.clientY,
- left: stackGraphContainer.scrollLeft, top: stackGraphContainer.scrollTop };
- stackGraphContainer.setPointerCapture(event.pointerId);
- stackGraphContainer.classList.add('is-panning');
- });
- stackGraphContainer.addEventListener('pointermove', event => {
- if (!graphPan) { return; }
- stackGraphContainer.scrollLeft = graphPan.left - (event.clientX - graphPan.x);
- stackGraphContainer.scrollTop = graphPan.top - (event.clientY - graphPan.y);
- });
- const stopGraphPan = () => { graphPan = null; stackGraphContainer.classList.remove('is-panning'); };
- stackGraphContainer.addEventListener('pointerup', stopGraphPan);
- stackGraphContainer.addEventListener('pointercancel', stopGraphPan);
- }
-
- if (stackGraphResizer && stackGraphContainer) {
- let startY = 0;
- let startHeight = 0;
-
- stackGraphResizer.addEventListener('pointerdown', event => {
- if (event.button !== 0) { return; }
- startY = event.clientY;
- startHeight = stackGraphContainer.offsetHeight;
- stackGraphResizer.setPointerCapture(event.pointerId);
- stackGraphResizer.classList.add('is-resizing');
- document.body.style.userSelect = 'none';
- document.body.style.cursor = 'ns-resize';
- });
-
- stackGraphResizer.addEventListener('pointermove', event => {
- if (!stackGraphResizer.classList.contains('is-resizing')) { return; }
- const deltaY = event.clientY - startY;
- const maxH = Math.max(400, window.innerHeight * 0.85);
- const newHeight = Math.max(160, Math.min(maxH, startHeight + deltaY));
- stackGraphContainer.style.height = `${Math.round(newHeight)}px`;
- });
-
- const stopResize = () => {
- if (stackGraphResizer.classList.contains('is-resizing')) {
- stackGraphResizer.classList.remove('is-resizing');
- document.body.style.userSelect = '';
- document.body.style.cursor = '';
- }
- };
-
- stackGraphResizer.addEventListener('pointerup', stopResize);
- stackGraphResizer.addEventListener('pointercancel', stopResize);
-
- stackGraphResizer.addEventListener('dblclick', () => {
- const h = stackGraphContainer.offsetHeight;
- if (h > 380) {
- stackGraphContainer.style.height = '260px';
- } else {
- stackGraphContainer.style.height = '520px';
- }
+ if (stackTreeEl) {
+ stackTreeEl.addEventListener('click', onStackTreeClick);
+ stackTreeEl.addEventListener('dblclick', event => {
+ const row = event.target.closest('.stack-row');
+ if (row && row.dataset.id && !event.target.closest('.stack-row-twisty')) { openStackFunction(row.dataset.id); }
});
+ stackTreeEl.addEventListener('keydown', onStackTreeKeydown);
}
- window.addEventListener('resize', () => {
- applyGraphZoom();
- });
-
const smtEnabled = document.getElementById('smt-enabled');
const smtControls = ['smt-backend', 'smt-secondary-backend', 'smt-mode', 'smt-timeout-ms', 'smt-rules']
.map(id => document.getElementById(id))
@@ -368,8 +309,14 @@
function updateSmtControls() {
const enabled = Boolean(smtEnabled && smtEnabled.checked);
smtControls.forEach(control => { control.disabled = !enabled; });
+ if (smtFields) { smtFields.classList.toggle('is-hidden', !enabled); }
+ if (smtSecondaryGroup && smtModeSelect) {
+ smtSecondaryGroup.classList.toggle('is-hidden', smtModeSelect.value === 'single');
+ }
}
+ if (smtModeSelect) { smtModeSelect.addEventListener('change', updateSmtControls); }
+
if (smtEnabled) {
smtEnabled.addEventListener('change', updateSmtControls);
updateSmtControls();
@@ -431,6 +378,7 @@
setValue('smt-mode', saved.smtMode || 'single');
setValue('smt-timeout-ms', saved.smtTimeoutMs || '');
setValue('smt-rules', saved.smtRules || '');
+ setValue('compile-commands-path', saved.compileCommandsPath || '');
setChecked('compdb-fast', saved.compdbFast === true);
setChecked('include-compdb-deps', saved.includeCompdbDeps === true);
setValue('compiler-extra-args', saved.compilerExtraArgs || '');
@@ -445,12 +393,142 @@
updateAnalysisModeStyles();
updateSmtControls();
updateProfileDependentUi();
+ // setScanMode above saved the state before the values were restored.
+ saveUiState();
}
function saveUiState() {
vscode.setState({
uiState: readCtraceUiState(),
scanWorkspace,
+ openSections: openSectionKeys(),
+ });
+ refreshSettingsIndicators();
+ }
+
+ // ββ Settings: modified indicators, search, reset βββββββββββββββββββββββββββ
+ function settingSections() {
+ return settingsView ? [...settingsView.querySelectorAll('.config-section')] : [];
+ }
+
+ function openSectionKeys() {
+ return settingsSearchRestore
+ ? [...settingsSearchRestore]
+ : settingSections().filter(section => section.open).map(section => section.dataset.section);
+ }
+
+ function isSettingModified(control) {
+ if (control.type === 'checkbox') { return control.checked !== control.defaultChecked; }
+ if (control.tagName === 'SELECT') {
+ const initial = [...control.options].find(option => option.defaultSelected) || control.options[0];
+ return control.value !== initial.value;
+ }
+ return control.value.trim() !== control.defaultValue;
+ }
+
+ function settingControls(root) {
+ return [...root.querySelectorAll('.setting input, .setting select')];
+ }
+
+ /** A modified setting only counts as active if it applies to this run. */
+ function isSettingActive(control) {
+ return isSettingModified(control) && !control.disabled && !control.closest('.is-hidden');
+ }
+
+ function refreshSettingsIndicators() {
+ if (!settingsView) { return; }
+ let total = 0;
+ settingSections().forEach(section => {
+ const active = settingControls(section).filter(isSettingActive);
+ total += active.length;
+ const count = section.querySelector('.config-count');
+ if (count) {
+ count.textContent = String(active.length);
+ count.hidden = active.length === 0;
+ count.title = `${active.length} changed setting${active.length > 1 ? 's' : ''}`;
+ }
+ });
+ settingControls(settingsView).forEach(control => {
+ control.closest('.setting').classList.toggle('is-modified', isSettingModified(control));
+ });
+ if (settingsBadge) {
+ settingsBadge.textContent = String(total);
+ settingsBadge.hidden = total === 0;
+ }
+ if (settingsBtn) {
+ settingsBtn.title = total > 0 ? `Open settings (${total} changed)` : 'Open settings';
+ settingsBtn.setAttribute('aria-label', settingsBtn.title);
+ }
+ if (resetSettingsBtn) {
+ resetSettingsBtn.disabled = !settingControls(settingsView).some(isSettingModified);
+ }
+ }
+
+ function applySettingsSearch() {
+ const query = settingsSearch ? settingsSearch.value.trim().toLowerCase() : '';
+ if (query && !settingsSearchRestore) {
+ settingsSearchRestore = new Set(settingSections().filter(section => section.open).map(section => section.dataset.section));
+ }
+ let anyVisible = false;
+ settingSections().forEach(section => {
+ const titleMatches = section.querySelector('.config-title').textContent.toLowerCase().includes(query);
+ const rows = [...section.querySelectorAll('.setting')];
+ const matching = rows.filter(row => !query || titleMatches || row.textContent.toLowerCase().includes(query));
+ rows.forEach(row => row.classList.toggle('search-miss', !matching.includes(row)));
+ // A match inside a switched-off group must lead to the switch.
+ if (matching.some(row => row.closest('.is-hidden'))) {
+ section.querySelector('[data-master]')?.classList.remove('search-miss');
+ }
+ const visible = rows.some(row => !row.classList.contains('search-miss') && !row.closest('.is-hidden'));
+ section.classList.toggle('search-miss', !visible);
+ anyVisible = anyVisible || (visible && section.style.display !== 'none');
+ if (query) { section.open = true; }
+ });
+ if (!query && settingsSearchRestore) {
+ const restore = settingsSearchRestore;
+ settingsSearchRestore = null;
+ settingSections().forEach(section => { section.open = restore.has(section.dataset.section); });
+ }
+ if (settingsNoMatch) { settingsNoMatch.hidden = !query || anyVisible; }
+ }
+
+ if (settingsSearch) { settingsSearch.addEventListener('input', applySettingsSearch); }
+ settingSections().forEach(section => {
+ section.addEventListener('toggle', () => { if (!settingsSearchRestore) { saveUiState(); } });
+ });
+ if (Array.isArray(persistedState.openSections)) {
+ settingSections().forEach(section => { section.open = persistedState.openSections.includes(section.dataset.section); });
+ }
+
+ let resetConfirmTimer = null;
+ function disarmReset() {
+ clearTimeout(resetConfirmTimer);
+ resetConfirmTimer = null;
+ if (resetSettingsLabel) { resetSettingsLabel.textContent = 'Reset to defaults'; }
+ resetSettingsBtn?.classList.remove('is-armed');
+ }
+
+ if (resetSettingsBtn) {
+ // Two clicks: a reset wipes values the user may have typed by hand.
+ resetSettingsBtn.addEventListener('click', () => {
+ if (!resetConfirmTimer) {
+ resetSettingsBtn.classList.add('is-armed');
+ if (resetSettingsLabel) { resetSettingsLabel.textContent = 'Click again to confirm'; }
+ resetConfirmTimer = setTimeout(disarmReset, 3000);
+ return;
+ }
+ disarmReset();
+ settingControls(settingsView).forEach(control => {
+ if (control.type === 'checkbox') {
+ control.checked = control.defaultChecked;
+ } else if (control.tagName === 'SELECT') {
+ control.selectedIndex = Math.max(0, [...control.options].findIndex(option => option.defaultSelected));
+ } else {
+ control.value = control.defaultValue;
+ }
+ });
+ updateSmtControls();
+ saveUiState();
});
}
@@ -579,7 +657,7 @@
setRunning(true);
break;
case 'analysis-result':
- handleAnalysisResult(msg.data, msg.restored === true, msg.savedAt);
+ handleAnalysisResult(msg.data, msg.restored === true, msg.savedAt, msg.refresh === true);
break;
case 'clear-results':
clearDisplayedResults();
@@ -607,14 +685,16 @@
break;
case 'compile-commands-selected':
setValue('compile-commands-path', msg.path || '');
+ saveUiState();
break;
case 'stack-data':
handleStackData(msg.data);
break;
case 'focus-stack-tab':
switchResultTab('stack');
- if (msg.functionName) {
- highlightStackFunction(msg.functionName);
+ if (msg.functionName && stackModel) {
+ const id = stackTreeLib.resolveName(stackModel, msg.functionName);
+ if (id) { revealStackFunction(id); }
}
break;
}
@@ -687,17 +767,21 @@
}
}
- function handleAnalysisResult(sarif, restored = false, savedAt) {
- setRunning(false);
- const savedLabel = Number.isFinite(savedAt) ? new Date(savedAt).toLocaleString() : 'Saved results';
- setStatus(restored ? 'Last analysis' : 'Analysis complete', restored ? savedLabel : 'Findings are shown below', 'success');
- setProgress(100);
+ function handleAnalysisResult(sarif, restored = false, savedAt, refresh = false) {
+ // A refresh only re-applies the ignore list: keep the current status.
+ if (!refresh) {
+ setRunning(false);
+ const savedLabel = Number.isFinite(savedAt) ? new Date(savedAt).toLocaleString() : 'Saved results';
+ setStatus(restored ? 'Last analysis' : 'Analysis complete', restored ? savedLabel : 'Findings are shown below', 'success');
+ setProgress(100);
+ }
if (emptyState) { emptyState.style.display = 'none'; }
if (resultsContainer) { resultsContainer.classList.remove('results-hidden'); }
if (vulnList) { vulnList.innerHTML = ''; }
findings = [];
+ ignoredCount = 0;
const seenFindings = new Set();
if (sarif && sarif.runs) {
@@ -707,7 +791,11 @@
const key = `${res.ruleId || ''}|${loc?.artifactLocation?.uri || ''}|${loc?.region?.startLine ?? ''}|${res.message?.text || ''}`;
if (!seenFindings.has(key)) {
seenFindings.add(key);
- findings.push(res);
+ if (res.properties && res.properties.ctraceIgnored === true) {
+ ignoredCount++;
+ } else {
+ findings.push(res);
+ }
}
});
});
@@ -724,6 +812,7 @@
if (count === 0 && vulnList) {
vulnList.innerHTML = '
No issues detected for the selected scope. ';
+ appendIgnoredNotice();
if (typeof lucide !== 'undefined') { lucide.createIcons(); }
}
@@ -734,15 +823,16 @@
function clearDisplayedResults() {
findings = [];
- currentStackReport = null;
- graphSelectedId = null;
+ stackModel = null;
+ stackExpanded = new Set();
+ stackTreeTouched = false;
+ stackSelectedKey = null;
if (vulnList) { vulnList.replaceChildren(); }
- if (stackFnList) { stackFnList.replaceChildren(); }
- if (stackGraphContainer) {
- stackGraphContainer.innerHTML = 'Run the stack analyzer to view functions and calls.
';
+ if (stackTreeEl) {
+ stackTreeEl.innerHTML = 'Run the stack analyzer to view functions and calls.
';
}
- if (stackGraphInspector) { stackGraphInspector.hidden = true; }
- if (stackGraphCount) { stackGraphCount.textContent = '0 functions Β· 0 calls'; }
+ if (stackDetails) { stackDetails.hidden = true; }
+ if (stackTreeCount) { stackTreeCount.textContent = '0 functions'; }
if (vulnCount) { vulnCount.textContent = '0'; vulnCount.classList.add('zero'); }
if (tabVulnBadge) { tabVulnBadge.textContent = '0'; }
if (tabStackBadge) { tabStackBadge.textContent = '0'; }
@@ -784,8 +874,8 @@
function handleStackData(report) {
if (!report) { return; }
- currentStackReport = report;
- graphSelectedId = null;
+ stackModel = stackTreeLib.createModel(report);
+ if (!stackTreeTouched) { stackExpanded = stackTreeLib.defaultExpanded(stackModel); }
if (emptyState) { emptyState.style.display = 'none'; }
if (resultsContainer) { resultsContainer.classList.remove('results-hidden'); }
@@ -809,417 +899,371 @@
stackFunctionsVal.textContent = String(fnCount);
}
- renderCallGraph(report);
- renderFilteredStackFunctions();
+ renderStackView();
}
- function renderCallGraph(report) {
- if (!stackGraphContainer) { return; }
- stackGraphContainer.replaceChildren();
- const allNodes = report.callGraph?.nodes || [];
- const allEdges = report.callGraph?.edges || [];
+ const TWISTY_ICON = ' ';
+
+ function setStackView(view) {
+ stackView = view;
+ stackViewTree?.classList.toggle('active', view === 'tree');
+ stackViewTree?.setAttribute('aria-pressed', String(view === 'tree'));
+ stackViewRanking?.classList.toggle('active', view === 'ranking');
+ stackViewRanking?.setAttribute('aria-pressed', String(view === 'ranking'));
+ if (stackTreeActions) { stackTreeActions.hidden = view !== 'tree'; }
+ stackSelectedKey = null;
+ renderStackView();
+ }
- const connectedIds = new Set();
- allEdges.forEach(e => {
- connectedIds.add(e.from);
- connectedIds.add(e.to);
- });
+ function stackQuery() {
+ return stackSearch ? stackSearch.value.trim() : '';
+ }
- let nodes = allNodes;
- if (stackGraphMode === 'chains' && allEdges.length > 0) {
- const connected = allNodes.filter(n =>
- connectedIds.has(n.id) || n.isRecursive || n.hasInfiniteSelfRecursion || n.exceedsLimit
- );
- if (connected.length > 0) {
- nodes = connected;
- }
+ function renderStackView() {
+ if (!stackTreeEl || !stackModel) { return; }
+ const query = stackQuery();
+ const hadFocus = stackTreeEl.contains(document.activeElement);
+ const result = stackView === 'tree'
+ ? stackTreeLib.flattenTree(stackModel, { expanded: stackExpanded, query })
+ : { rows: stackTreeLib.rankedRows(stackModel, query), truncated: false, matchCount: null };
+
+ if (stackTreeCount) {
+ const total = stackModel.nodes.size;
+ stackTreeCount.textContent = query
+ ? `${stackView === 'tree' ? result.matchCount : result.rows.length} of ${total} functions`
+ : `${total} function${total > 1 ? 's' : ''} Β· ${stackModel.callCount} call${stackModel.callCount > 1 ? 's' : ''}`;
}
- const nodeById = new Map(nodes.map(node => [node.id, node]));
- const edges = allEdges.filter(edge =>
- nodeById.has(edge.from) && nodeById.has(edge.to));
-
- if (stackGraphCount) {
- if (nodes.length < allNodes.length) {
- stackGraphCount.textContent = `${nodes.length} in graph Β· ${edges.length} calls (${allNodes.length} total)`;
- } else {
- stackGraphCount.textContent = `${nodes.length} functions Β· ${edges.length} calls`;
- }
+ stackTreeEl.replaceChildren();
+ if (result.rows.length === 0) {
+ const hint = document.createElement('div');
+ hint.className = 'stack-empty-hint';
+ hint.textContent = query ? 'No functions match the filter.' : 'No function data available.';
+ stackTreeEl.appendChild(hint);
}
- if (!nodes.length) {
- stackGraphContainer.innerHTML = 'No function data available.
';
- if (stackGraphInspector) { stackGraphInspector.hidden = true; }
- return;
+ result.rows.forEach(row => stackTreeEl.appendChild(buildStackRow(row, query)));
+ if (result.truncated) {
+ const notice = document.createElement('div');
+ notice.className = 'stack-tree-notice';
+ notice.textContent = 'Too many rows to show them all. Filter or collapse to narrow the tree.';
+ stackTreeEl.appendChild(notice);
}
- const depth = new Map(nodes.map(node => [node.id, 0]));
- const incoming = new Map(nodes.map(node => [node.id, 0]));
- const outgoing = new Map(nodes.map(node => [node.id, []]));
- edges.filter(edge => edge.from !== edge.to).forEach(edge => {
- incoming.set(edge.to, incoming.get(edge.to) + 1);
- outgoing.get(edge.from).push(edge.to);
- });
- const queue = nodes.filter(node => incoming.get(node.id) === 0)
- .sort((a, b) => (a.name === 'main' ? -1 : b.name === 'main' ? 1 : a.name.localeCompare(b.name)))
- .map(node => node.id);
- const placed = new Set();
- while (queue.length) {
- const id = queue.shift();
- if (placed.has(id)) { continue; }
- placed.add(id);
- outgoing.get(id).forEach(target => {
- depth.set(target, Math.max(depth.get(target), Math.min(depth.get(id) + 1, 9)));
- incoming.set(target, incoming.get(target) - 1);
- if (incoming.get(target) === 0) { queue.push(target); }
- });
- }
- edges.filter(edge => placed.has(edge.from) && !placed.has(edge.to)).forEach(edge => {
- depth.set(edge.to, Math.min(depth.get(edge.from) + 1, 9));
- });
+ const rows = [...stackTreeEl.querySelectorAll('.stack-row')];
+ let current = rows.find(row => row.dataset.key === stackSelectedKey);
+ if (!current) { stackSelectedKey = null; }
+ (current || rows[0])?.setAttribute('tabindex', '0');
+ current?.classList.add('is-selected');
+ current?.setAttribute('aria-selected', 'true');
+ if (hadFocus) { (current || rows[0])?.focus({ preventScroll: true }); }
+ renderStackDetails(current?.dataset.id);
+ }
- const columns = new Map();
- nodes.forEach(node => {
- const level = depth.get(node.id);
- if (!columns.has(level)) { columns.set(level, []); }
- columns.get(level).push(node);
- });
- const levels = [...columns.keys()].sort((a, b) => a - b);
- levels.forEach(level => columns.get(level).sort((a, b) => {
- const parents = id => edges.filter(edge => edge.to === id).map(edge => edge.from);
- const rank = id => {
- const values = parents(id).map(parent => {
- const parentColumn = columns.get(depth.get(parent)) || [];
- return parentColumn.findIndex(node => node.id === parent);
- }).filter(value => value >= 0);
- return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 999;
- };
- return rank(a.id) - rank(b.id) || a.name.localeCompare(b.name);
- }));
-
- const nodeWidth = 188;
- const nodeHeight = 78;
- const colStep = 280;
- const rowStep = 122;
- const maxRows = Math.max(...[...columns.values()].map(column => column.length));
- const width = 60 + (Math.max(...levels) * colStep) + nodeWidth + 40;
- const height = Math.max(250, 75 + maxRows * rowStep);
- const positions = new Map();
- columns.forEach((column, level) => column.forEach((node, row) => {
- positions.set(node.id, {
- x: 36 + level * colStep,
- y: 36 + (maxRows - column.length) * rowStep / 2 + row * rowStep
- });
- }));
-
- const ns = 'http://www.w3.org/2000/svg';
- const svg = document.createElementNS(ns, 'svg');
- svg.classList.add('stack-graph');
- svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
- svg.setAttribute('role', 'img');
- svg.setAttribute('aria-label', `${nodes.length} functions connected by ${edges.length} calls`);
- svg.dataset.baseWidth = String(width);
- svg.dataset.baseHeight = String(height);
- const make = (tag, attrs, parent) => {
- const element = document.createElementNS(ns, tag);
- Object.entries(attrs).forEach(([key, value]) => element.setAttribute(key, String(value)));
- parent.appendChild(element);
- return element;
- };
- const defs = make('defs', {}, svg);
- const nodeGradient = make('linearGradient', { id: 'stack-node-gradient', x1: '0%', x2: '0%', y1: '0%', y2: '100%' }, defs);
- make('stop', { offset: '0%', class: 'stack-node-top' }, nodeGradient);
- make('stop', { offset: '100%', class: 'stack-node-bottom' }, nodeGradient);
- const gradient = make('linearGradient', { id: 'stack-wire-gradient', x1: '0%', x2: '100%' }, defs);
- make('stop', { offset: '0%', class: 'stack-wire-start' }, gradient);
- make('stop', { offset: '100%', class: 'stack-wire-end' }, gradient);
- const edgeLayer = make('g', { class: 'stack-graph-edges' }, svg);
- edges.forEach(edge => {
- const from = positions.get(edge.from);
- const to = positions.get(edge.to);
- const self = edge.from === edge.to;
- const x1 = from.x + nodeWidth;
- const y1 = from.y + nodeHeight / 2;
- const x2 = to.x;
- const y2 = to.y + nodeHeight / 2;
- let d;
- if (self) {
- d = `M ${from.x + 118} ${from.y} C ${from.x + 118} ${from.y - 40}, ${from.x + 175} ${from.y - 40}, ${from.x + 175} ${from.y}`;
- } else if (x2 > x1) {
- const bend = Math.max(36, (x2 - x1) * 0.45);
- d = `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
- } else {
- const rail = height - 26;
- d = `M ${x1} ${y1} C ${x1 + 48} ${y1}, ${x1 + 48} ${rail}, ${x1} ${rail} L ${x2 - 35} ${rail} C ${x2 - 50} ${rail}, ${x2 - 50} ${y2}, ${x2} ${y2}`;
- }
- const wire = make('g', { class: 'stack-graph-wire' }, edgeLayer);
- wire.dataset.from = edge.from;
- wire.dataset.to = edge.to;
- if (edge.isRecursiveCycle || self) { wire.classList.add('recursive'); }
- make('path', { d, class: 'wire-glow' }, wire);
- make('path', { d, class: 'wire-line' }, wire);
- if (!self) { make('circle', { cx: x2, cy: y2, r: 3.4, class: 'wire-terminal' }, wire); }
- });
- const nodeLayer = make('g', { class: 'stack-graph-nodes' }, svg);
- const maxStack = Math.max(1, ...nodes.map(node => node.maxStack || 0));
- nodes.forEach(node => {
- const pos = positions.get(node.id);
- const group = make('g', {
- class: 'stack-graph-node', transform: `translate(${pos.x} ${pos.y})`,
- tabindex: 0, role: 'button',
- 'aria-label': `${node.name}, local ${formatBytes(node.localStack)}, peak ${formatBytes(node.maxStack)}`
- }, nodeLayer);
- group.dataset.id = node.id;
- if (node.isRecursive || node.hasInfiniteSelfRecursion) { group.classList.add('recursive'); }
- if (node.exceedsLimit) { group.classList.add('over-limit'); }
- make('rect', { x: 0, y: 0, width: nodeWidth, height: nodeHeight, rx: 11, class: 'node-shell' }, group);
- make('rect', { x: 0, y: 14, width: 3, height: 50, rx: 1.5, class: 'node-accent' }, group);
- make('rect', { x: 12, y: 12, width: 26, height: 26, rx: 7, class: 'node-icon-box' }, group);
- const icon = make('text', { x: 25, y: 30, class: 'node-icon', 'text-anchor': 'middle' }, group);
- icon.textContent = 'Ζ';
- const title = make('text', { x: 47, y: 29, class: 'node-title' }, group);
- title.textContent = node.name.length > 20 ? `${node.name.slice(0, 18)}β¦` : node.name;
- const tooltip = make('title', {}, group);
- tooltip.textContent = node.name;
- const localLabel = make('text', { x: 13, y: 55, class: 'node-meta-label' }, group);
- localLabel.textContent = 'LOCAL';
- const localValue = make('text', { x: 55, y: 55, class: 'node-meta-value' }, group);
- localValue.textContent = formatBytes(node.localStack);
- const peakLabel = make('text', { x: 112, y: 55, class: 'node-meta-label' }, group);
- peakLabel.textContent = 'PEAK';
- const peakValue = make('text', { x: 176, y: 55, class: 'node-meta-value', 'text-anchor': 'end' }, group);
- peakValue.textContent = formatBytes(node.maxStack);
- make('rect', { x: 12, y: 68, width: 164, height: 2, rx: 1, class: 'node-bar-track' }, group);
- make('rect', { x: 12, y: 68, width: Math.max(3, Math.round(164 * (node.maxStack || 0) / maxStack)),
- height: 2, rx: 1, class: 'node-bar-fill' }, group);
- make('circle', { cx: 0, cy: nodeHeight / 2, r: 3.5, class: 'node-port' }, group);
- make('circle', { cx: nodeWidth, cy: nodeHeight / 2, r: 3.5, class: 'node-port' }, group);
- group.addEventListener('click', () => selectGraphNode(svg, node.id));
- group.addEventListener('dblclick', () => openGraphFunction(node.name));
- group.addEventListener('keydown', event => {
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault();
- selectGraphNode(svg, node.id);
- }
- });
- });
- stackGraphContainer.appendChild(svg);
- applyGraphZoom();
- if (!edges.length) {
- const hint = document.createElement('div');
- hint.className = 'stack-graph-empty-links';
- hint.textContent = 'Call links are not available for this report.';
- stackGraphContainer.appendChild(hint);
- }
- if (graphSelectedId && nodeById.has(graphSelectedId)) {
- selectGraphNode(svg, graphSelectedId, false);
- } else if (stackGraphInspector) {
- stackGraphInspector.hidden = true;
+ function buildStackRow(row, query) {
+ const el = document.createElement('div');
+ el.className = 'stack-row';
+ el.setAttribute('role', 'treeitem');
+ el.setAttribute('tabindex', '-1');
+ el.setAttribute('aria-level', String(row.depth + 1));
+ el.setAttribute('aria-selected', 'false');
+ if (row.expandable) { el.setAttribute('aria-expanded', String(row.expanded)); }
+ el.dataset.key = row.key;
+ // Through the CSSOM: the webview CSP blocks inline style attributes.
+ el.style.setProperty('--depth', String(row.depth));
+
+ const guides = document.createElement('span');
+ guides.className = 'stack-row-guides';
+ const twisty = document.createElement('span');
+ twisty.className = `stack-row-twisty${row.expandable ? '' : ' is-leaf'}`;
+ if (row.expandable) { twisty.innerHTML = TWISTY_ICON; }
+ el.append(guides, twisty);
+
+ if (row.kind === 'group') {
+ el.classList.add('is-group');
+ const label = document.createElement('span');
+ label.className = 'stack-row-name';
+ label.textContent = 'No call links';
+ const count = document.createElement('span');
+ count.className = 'stack-row-value';
+ count.textContent = String(row.count);
+ el.append(label, count);
+ el.title = 'Functions that neither call nor are called by another analysed function';
+ return el;
}
- }
- function openGraphFunction(name) {
- const fn = currentStackReport?.functions?.find(item => item.name === name);
- if (fn?.file) {
- vscode.postMessage({ type: 'open-file', path: fn.file, line: fn.line ? fn.line - 1 : 0 });
+ const node = stackModel.nodes.get(row.id);
+ el.dataset.id = row.id;
+ const pct = Math.min(100, Math.round(((node.maxStack || 0) / stackModel.peak) * 100));
+ el.style.setProperty('--pct', `${pct}%`);
+ if (row.hot) { el.classList.add('is-hot'); }
+ if (row.isCycleRef) { el.classList.add('is-cycle-ref'); }
+
+ const name = document.createElement('span');
+ name.className = 'stack-row-name';
+ appendHighlighted(name, node.name, query);
+
+ const badges = document.createElement('span');
+ badges.className = 'stack-row-badges';
+ const isRecursive = node.isRecursive || node.hasInfiniteSelfRecursion;
+ if (row.isCycleRef) {
+ badges.appendChild(stackBadge('β»', 'badge-danger', 'Recursive call: this function is already on the path above'));
+ } else if (isRecursive) {
+ badges.appendChild(stackBadge('β»', 'badge-danger', 'Part of a recursive cycle: its real stack use can be higher'));
}
+ if (node.exceedsLimit) { badges.appendChild(stackBadge('limit', 'badge-danger', 'Exceeds the stack limit')); }
+ if (node.hasDynamicAlloca) { badges.appendChild(stackBadge('alloca', 'badge-warning', 'Dynamic stack allocation')); }
+
+ const value = document.createElement('span');
+ value.className = 'stack-row-value';
+ value.textContent = row.isCycleRef ? '' : formatPeak(node);
+
+ el.append(name, badges, value);
+ el.title = [
+ node.name,
+ `Local ${formatBytes(node.localStack)} Β· Peak ${formatPeak(node)}`,
+ node.file ? `${node.file}${node.line ? `:${node.line}` : ''}` : '',
+ ].filter(Boolean).join('\n');
+ return el;
}
- function setZoomAt(newZoom, clientX, clientY) {
- const clamped = Math.max(0.25, Math.min(3.0, newZoom));
- if (Math.abs(clamped - graphZoom) < 0.001) { return; }
+ function stackBadge(text, className, title) {
+ const badge = document.createElement('span');
+ badge.className = `stack-badge ${className}`;
+ badge.textContent = text;
+ badge.title = title;
+ return badge;
+ }
- const svg = stackGraphContainer?.querySelector('.stack-graph');
- if (!svg || !stackGraphContainer) {
- graphZoom = clamped;
- applyGraphZoom();
+ function appendHighlighted(parent, text, query) {
+ const index = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
+ if (index < 0) {
+ parent.textContent = text;
return;
}
+ const mark = document.createElement('mark');
+ mark.textContent = text.slice(index, index + query.length);
+ parent.append(text.slice(0, index), mark, text.slice(index + query.length));
+ }
- if (clientX !== undefined && clientY !== undefined) {
- const rect = stackGraphContainer.getBoundingClientRect();
- const cursorX = clientX - rect.left;
- const cursorY = clientY - rect.top;
-
- const contentX = (stackGraphContainer.scrollLeft + cursorX) / graphZoom;
- const contentY = (stackGraphContainer.scrollTop + cursorY) / graphZoom;
+ function formatPeak(node) {
+ if (node.maxStackUnknown) {
+ return node.maxStackLowerBound ? `β₯ ${formatBytes(node.maxStackLowerBound)}` : '?';
+ }
+ return formatBytes(node.maxStack);
+ }
- graphZoom = Math.round(clamped * 1000) / 1000;
- applyGraphZoom();
+ function onStackTreeClick(event) {
+ const row = event.target.closest('.stack-row');
+ if (!row) { return; }
+ const key = row.dataset.key;
+ if (event.target.closest('.stack-row-twisty') || !row.dataset.id) {
+ if (row.hasAttribute('aria-expanded')) { toggleStackRow(key); }
+ return;
+ }
+ selectStackRow(row);
+ // A click opens but never closes: selecting a parent to read its
+ // details must not hide its callees.
+ if (row.getAttribute('aria-expanded') === 'false') { toggleStackRow(key); }
+ }
- stackGraphContainer.scrollLeft = contentX * graphZoom - cursorX;
- stackGraphContainer.scrollTop = contentY * graphZoom - cursorY;
+ function toggleStackRow(key) {
+ if (stackExpanded.has(key)) {
+ stackExpanded.delete(key);
} else {
- graphZoom = Math.round(clamped * 1000) / 1000;
- applyGraphZoom();
+ stackExpanded.add(key);
}
+ stackTreeTouched = true;
+ renderStackView();
}
- function fitGraphToView() {
- const svg = stackGraphContainer?.querySelector('.stack-graph');
- if (!svg || !stackGraphContainer) { return; }
- const baseWidth = Number(svg.dataset.baseWidth) || 500;
- const availableWidth = stackGraphContainer.clientWidth - 28;
- if (availableWidth > 60 && baseWidth > 0) {
- const targetZoom = Math.max(0.25, Math.min(1.5, availableWidth / baseWidth));
- setZoomAt(targetZoom);
- stackGraphContainer.scrollLeft = 0;
- stackGraphContainer.scrollTop = 0;
- }
+ function selectStackRow(row, focus = true) {
+ if (!stackTreeEl) { return; }
+ stackTreeEl.querySelectorAll('.stack-row').forEach(other => {
+ const selected = other === row;
+ other.classList.toggle('is-selected', selected);
+ other.setAttribute('aria-selected', String(selected));
+ other.setAttribute('tabindex', selected ? '0' : '-1');
+ });
+ stackSelectedKey = row.dataset.key;
+ if (focus) { row.focus({ preventScroll: true }); }
+ renderStackDetails(row.dataset.id);
}
- function applyGraphZoom() {
- const svg = stackGraphContainer?.querySelector('.stack-graph');
- if (svg) {
- const baseW = Number(svg.dataset.baseWidth) || 500;
- const baseH = Number(svg.dataset.baseHeight) || 300;
- svg.setAttribute('width', Math.round(baseW * graphZoom));
- svg.setAttribute('height', Math.round(baseH * graphZoom));
+ function onStackTreeKeydown(event) {
+ const row = event.target.closest('.stack-row');
+ if (!row || !stackTreeEl) { return; }
+ const rows = [...stackTreeEl.querySelectorAll('.stack-row')];
+ const index = rows.indexOf(row);
+ const move = target => {
+ if (!target) { return; }
+ if (target.dataset.id) {
+ selectStackRow(target);
+ } else {
+ rows.forEach(other => other.setAttribute('tabindex', other === target ? '0' : '-1'));
+ target.focus({ preventScroll: true });
+ }
+ target.scrollIntoView({ block: 'nearest' });
+ };
+ const expanded = row.getAttribute('aria-expanded');
+ switch (event.key) {
+ case 'ArrowDown': move(rows[index + 1]); break;
+ case 'ArrowUp': move(rows[index - 1]); break;
+ case 'Home': move(rows[0]); break;
+ case 'End': move(rows[rows.length - 1]); break;
+ case 'ArrowRight':
+ if (expanded === 'false') { toggleStackRow(row.dataset.key); }
+ else if (expanded === 'true') { move(rows[index + 1]); }
+ break;
+ case 'ArrowLeft':
+ if (expanded === 'true') {
+ toggleStackRow(row.dataset.key);
+ } else {
+ const parent = stackTreeLib.parentKey(row.dataset.key);
+ move(rows.find(other => other.dataset.key === parent));
+ }
+ break;
+ case 'Enter':
+ if (row.dataset.id) { openStackFunction(row.dataset.id); }
+ else { toggleStackRow(row.dataset.key); }
+ break;
+ case ' ':
+ if (row.dataset.id) { selectStackRow(row); }
+ else { toggleStackRow(row.dataset.key); }
+ break;
+ default:
+ return;
}
- if (stackZoomLabel) { stackZoomLabel.textContent = `${Math.round(graphZoom * 100)}%`; }
- if (stackZoomOut) { stackZoomOut.disabled = graphZoom <= 0.25; }
- if (stackZoomIn) { stackZoomIn.disabled = graphZoom >= 3.0; }
+ event.preventDefault();
}
- function selectGraphNode(svg, nodeId, scroll = true) {
- graphSelectedId = nodeId;
- const related = new Set([nodeId]);
- svg.querySelectorAll('.stack-graph-wire').forEach(wire => {
- const active = wire.dataset.from === nodeId || wire.dataset.to === nodeId;
- wire.classList.toggle('is-selected', active);
- wire.classList.toggle('is-dimmed', !active);
- if (active) { related.add(wire.dataset.from); related.add(wire.dataset.to); }
- });
- svg.querySelectorAll('.stack-graph-node').forEach(group => {
- group.classList.toggle('is-selected', group.dataset.id === nodeId);
- group.classList.toggle('is-dimmed', !related.has(group.dataset.id));
- });
- const group = [...svg.querySelectorAll('.stack-graph-node')].find(el => el.dataset.id === nodeId);
- if (scroll && group) { group.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }
- if (!stackGraphInspector || !currentStackReport) { return; }
- const node = currentStackReport.callGraph?.nodes.find(item => item.id === nodeId);
- if (!node) { return; }
- const edges = currentStackReport.callGraph?.edges || [];
- const callers = edges.filter(edge => edge.to === nodeId && edge.from !== nodeId).length;
- const callees = edges.filter(edge => edge.from === nodeId && edge.to !== nodeId).length;
- stackGraphInspector.replaceChildren();
- const info = document.createElement('div');
- info.className = 'stack-inspector-main';
+ function renderStackDetails(id) {
+ if (!stackDetails) { return; }
+ const node = id && stackModel ? stackModel.nodes.get(id) : null;
+ if (!node) {
+ stackDetails.hidden = true;
+ stackDetails.replaceChildren();
+ return;
+ }
+ const header = document.createElement('div');
+ header.className = 'stack-details-header';
const name = document.createElement('strong');
name.textContent = node.name;
- const fileBasename = node.file ? node.file.split(/[\\/]/).pop() : '';
- const meta = document.createElement('span');
- meta.textContent = `${fileBasename ? `${fileBasename} Β· ` : ''}${callers} callers Β· ${callees} calls Β· ${formatBytes(node.maxStack)} peak`;
- info.append(name, meta);
- stackGraphInspector.appendChild(info);
- if (currentStackReport.functions.some(fn => fn.name === nodeId && fn.file)) {
- const button = document.createElement('button');
- button.type = 'button';
- button.title = 'Open source';
- button.setAttribute('aria-label', `Open ${node.name} in source`);
- button.textContent = 'Open';
- button.addEventListener('click', () => openGraphFunction(nodeId));
- stackGraphInspector.appendChild(button);
- }
- stackGraphInspector.hidden = false;
- }
-
- function renderFilteredStackFunctions() {
- if (!stackFnList || !currentStackReport) { return; }
- stackFnList.innerHTML = '';
-
- const query = stackSearch ? stackSearch.value.trim().toLowerCase() : '';
- const list = (currentStackReport.functions || []).filter(fn => {
- if (!query) { return true; }
- if (fn.name.toLowerCase().includes(query)) { return true; }
- if (fn.file && fn.file.toLowerCase().includes(query)) { return true; }
- return false;
+ name.title = node.name;
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'stack-details-close';
+ close.textContent = 'Γ';
+ close.title = 'Close';
+ close.setAttribute('aria-label', 'Close details');
+ close.addEventListener('click', () => {
+ stackSelectedKey = null;
+ renderStackView();
});
-
- if (list.length === 0) {
- stackFnList.innerHTML = 'No functions match the search filter. ';
- return;
+ header.append(name, close);
+
+ const stats = document.createElement('div');
+ stats.className = 'stack-details-stats';
+ const stat = (label, value) => {
+ const cell = document.createElement('div');
+ const strong = document.createElement('strong');
+ strong.textContent = value;
+ const span = document.createElement('span');
+ span.textContent = label;
+ cell.append(strong, span);
+ return cell;
+ };
+ stats.append(
+ stat('Local', formatBytes(node.localStack)),
+ stat('Peak', formatPeak(node)),
+ stat('Of max', `${Math.min(100, Math.round(((node.maxStack || 0) / stackModel.peak) * 100))}%`)
+ );
+ stackDetails.replaceChildren(header, stats);
+
+ const callers = stackModel.parents.get(id).filter(other => other !== id);
+ const callees = stackModel.children.get(id).filter(other => other !== id);
+ if (callers.length > 0) { stackDetails.appendChild(stackLinkRow('Called by', callers)); }
+ if (callees.length > 0) { stackDetails.appendChild(stackLinkRow('Calls', callees)); }
+
+ if (node.file) {
+ const open = document.createElement('button');
+ open.type = 'button';
+ open.className = 'stack-details-open';
+ const basename = String(node.file).split(/[\\/]/).pop();
+ open.textContent = `${basename}${node.line ? `:${node.line}` : ''}`;
+ open.title = `Open ${node.file}`;
+ open.addEventListener('click', () => openStackFunction(id));
+ stackDetails.appendChild(open);
}
+ stackDetails.hidden = false;
+ }
- const peak = currentStackReport.peakStack || 1;
-
- // Sort by maxStack descending
- list.sort((a, b) => b.maxStack - a.maxStack);
-
- list.forEach(fn => {
- const card = document.createElement('li');
- const isRec = fn.isRecursive || fn.hasInfiniteSelfRecursion;
- card.className = `stack-fn-card ${isRec ? 'is-recursive' : 'is-safe'}`;
- card.id = `stack-fn-${escapeId(fn.name)}`;
-
- const pct = Math.max(4, Math.min(100, Math.round((fn.maxStack / peak) * 100)));
- const fileBasename = fn.file ? fn.file.split(/[\\/]/).pop() : '';
- const fileTooltip = fn.file ? `${fn.file}${fn.line ? `:${fn.line}` : ''}` : '';
- const filePill = fileBasename
- ? `${escapeHtml(fileBasename)}${fn.line ? `:${fn.line}` : ''} `
- : '';
-
- card.innerHTML = `
-
-
-
- Local: ${formatBytes(fn.localStack)}
- Peak: ${formatBytes(fn.maxStack)} (${pct}%)
-
- `;
-
- card.addEventListener('click', () => {
- highlightStackFunction(fn.name);
- if (fn.file) {
- vscode.postMessage({
- type: 'open-file',
- path: fn.file,
- line: fn.line ? fn.line - 1 : 0
- });
- }
- });
-
- stackFnList.appendChild(card);
+ function stackLinkRow(label, ids) {
+ const MAX_LINKS = 4;
+ const row = document.createElement('div');
+ row.className = 'stack-details-links';
+ const title = document.createElement('span');
+ title.className = 'stack-details-label';
+ title.textContent = label;
+ row.appendChild(title);
+ ids.slice(0, MAX_LINKS).forEach(id => {
+ const link = document.createElement('button');
+ link.type = 'button';
+ link.className = 'stack-link';
+ link.textContent = id;
+ link.title = `Show ${id} in the tree`;
+ link.addEventListener('click', () => revealStackFunction(id));
+ row.appendChild(link);
});
-
- if (typeof lucide !== 'undefined') { lucide.createIcons(); }
+ if (ids.length > MAX_LINKS) {
+ const more = document.createElement('span');
+ more.className = 'stack-details-label';
+ more.textContent = `+${ids.length - MAX_LINKS}`;
+ more.title = ids.slice(MAX_LINKS).join('\n');
+ row.appendChild(more);
+ }
+ return row;
}
- function highlightStackFunction(funcName) {
- if (!funcName) { return; }
- if (currentStackReport && stackGraphMode === 'chains') {
- const inChains = currentStackReport.callGraph?.edges?.some(e => e.from === funcName || e.to === funcName);
- const fnObj = currentStackReport.functions?.find(f => f.name === funcName);
- if (!inChains && !fnObj?.isRecursive && !fnObj?.exceedsLimit) {
- stackGraphMode = 'all';
- stackGraphModeAll?.classList.add('active');
- stackGraphModeChains?.classList.remove('active');
- renderCallGraph(currentStackReport);
- }
+ function revealStackFunction(id) {
+ if (!stackModel) { return; }
+ const target = stackTreeLib.pathTo(stackModel, id);
+ if (!target) { return; }
+ if (stackSearch) { stackSearch.value = ''; }
+ if (stackView !== 'tree') { setStackView('tree'); }
+ target.open.forEach(key => stackExpanded.add(key));
+ stackTreeTouched = true;
+ stackSelectedKey = target.key;
+ renderStackView();
+ const row = [...stackTreeEl.querySelectorAll('.stack-row')].find(item => item.dataset.key === target.key);
+ if (row) {
+ selectStackRow(row);
+ row.scrollIntoView({ block: 'center' });
}
- const graph = stackGraphContainer?.querySelector('.stack-graph');
- if (graph) { selectGraphNode(graph, funcName); }
- const card = document.getElementById(`stack-fn-${escapeId(funcName)}`);
- if (card) {
- if (!graph) { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
- card.classList.add('is-focused');
- setTimeout(() => card.classList.remove('is-focused'), 2000);
+ }
+
+ function showHotPath() {
+ if (!stackModel || stackModel.hotPath.length === 0) { return; }
+ stackExpanded = new Set();
+ const hottest = stackModel.hotPath[stackModel.hotPath.length - 1];
+ // The hot path is not always the shortest route to its last function.
+ const keys = [...stackModel.hotKeys];
+ keys.slice(0, -1).forEach(key => stackExpanded.add(key));
+ if (stackSearch) { stackSearch.value = ''; }
+ stackTreeTouched = true;
+ stackSelectedKey = keys[keys.length - 1] ?? null;
+ renderStackView();
+ const row = [...stackTreeEl.querySelectorAll('.stack-row')].find(item => item.dataset.key === stackSelectedKey);
+ if (row && row.dataset.id === hottest) {
+ selectStackRow(row);
+ row.scrollIntoView({ block: 'nearest' });
}
}
- function escapeId(name) {
- return name.replace(/[^a-zA-Z0-9_-]/g, '_');
+ function openStackFunction(id) {
+ const node = stackModel?.nodes.get(id);
+ if (node?.file) {
+ vscode.postMessage({ type: 'open-file', path: node.file, line: node.line ? node.line - 1 : 0 });
+ }
}
function renderFilteredFindings() {
@@ -1238,6 +1282,44 @@
if (findings.length > 0 && filtered.length === 0 && vulnList) {
vulnList.innerHTML = 'No findings match the current filters. ';
}
+ if (findings.length > 0) { appendIgnoredNotice(); }
+ }
+
+ function appendIgnoredNotice() {
+ if (!vulnList || ignoredCount === 0) { return; }
+ const li = document.createElement('li');
+ li.className = 'info-item ignored-notice';
+ const text = document.createElement('span');
+ text.textContent = `${ignoredCount} ignored finding${ignoredCount > 1 ? 's' : ''}`;
+ const manage = document.createElement('button');
+ manage.type = 'button';
+ manage.className = 'link-btn';
+ manage.textContent = 'Manage';
+ manage.addEventListener('click', () => {
+ vscode.postMessage({ type: 'execute-command', command: 'ctrace.manageIgnoredFindings' });
+ });
+ li.append(text, manage);
+ vulnList.appendChild(li);
+ }
+
+ /** Same normalisation as cweIds() in DiagnosticsManager.ts. */
+ function cweIds(properties) {
+ if (!properties) { return []; }
+ const raw = [].concat(properties.cwe === undefined ? [] : properties.cwe, Array.isArray(properties.tags) ? properties.tags : []);
+ const ids = [];
+ raw.forEach(value => {
+ let match = null;
+ if (typeof value === 'number') {
+ match = [String(value), String(value)];
+ } else if (typeof value === 'string') {
+ match = value.match(/^(?:external\/cwe\/)?cwe[-/]?0*(\d{1,5})$/i) || value.match(/^0*(\d{1,5})$/);
+ }
+ if (match) {
+ const id = `CWE-${Number(match[1])}`;
+ if (!ids.includes(id)) { ids.push(id); }
+ }
+ });
+ return ids;
}
function updateSummary(results) {
@@ -1314,6 +1396,46 @@
${escapeHtml(message)}
`;
+ const props = res.properties || {};
+ const cwes = cweIds(props);
+ const fingerprint = typeof props.ctraceFingerprint === 'string' ? props.ctraceFingerprint : '';
+ if (cwes.length > 0 || fingerprint || props.ctraceTool) {
+ const footer = document.createElement('div');
+ footer.className = 'vuln-footer';
+ cwes.forEach(cwe => {
+ const pill = document.createElement('button');
+ pill.type = 'button';
+ pill.className = 'vuln-cwe';
+ pill.textContent = cwe;
+ pill.title = `Open ${cwe} on cwe.mitre.org`;
+ pill.addEventListener('click', event => {
+ event.stopPropagation();
+ vscode.postMessage({ type: 'open-cwe', cwe });
+ });
+ footer.appendChild(pill);
+ });
+ if (typeof props.ctraceTool === 'string') {
+ const tool = document.createElement('span');
+ tool.className = 'vuln-tool';
+ tool.textContent = props.ctraceTool;
+ footer.appendChild(tool);
+ }
+ if (fingerprint) {
+ const ignore = document.createElement('button');
+ ignore.type = 'button';
+ ignore.className = 'vuln-ignore';
+ ignore.title = 'Ignore this finding (stored in .coretrace-ignore.json)';
+ ignore.setAttribute('aria-label', 'Ignore this finding');
+ ignore.innerHTML = 'Ignore ';
+ ignore.addEventListener('click', event => {
+ event.stopPropagation();
+ vscode.postMessage({ type: 'ignore-finding', fingerprint, ruleId, path: filePath, line, message });
+ });
+ footer.appendChild(ignore);
+ }
+ li.appendChild(footer);
+ }
+
li.addEventListener('click', () => {
const safeLine = (typeof line === 'number' && isFinite(line)) ? Math.max(0, line - 1) : 0;
vscode.postMessage({ type: 'open-file', path: filePath, line: safeLine });
diff --git a/media/stackTree.js b/media/stackTree.js
new file mode 100644
index 0000000..367dd59
--- /dev/null
+++ b/media/stackTree.js
@@ -0,0 +1,275 @@
+// Pure call-tree model of the Stack tab: no DOM, so that it can be unit-tested
+// under Node. Loaded by the webview before main.js.
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) {
+ module.exports = factory();
+ } else {
+ root.CoreTraceStackTree = factory();
+ }
+}(typeof self !== 'undefined' ? self : this, function () {
+ const SEPARATOR = '\n';
+ /** Key of the collapsible group holding functions without any call link. */
+ const STANDALONE_KEY = '\u0001standalone';
+ const DEFAULT_MAX_ROWS = 800;
+
+ function byPeakThenName(nodes) {
+ return (a, b) => {
+ const left = nodes.get(a);
+ const right = nodes.get(b);
+ return (right.maxStack || 0) - (left.maxStack || 0) || left.name.localeCompare(right.name);
+ };
+ }
+
+ /**
+ * Normalises a stack report. Roots are functions nobody calls (main first);
+ * a cycle nobody enters gets its heaviest member as an extra root, so every
+ * connected function is reachable from a root.
+ */
+ function createModel(report) {
+ const nodes = new Map();
+ for (const node of report?.callGraph?.nodes ?? []) { nodes.set(node.id, { ...node }); }
+ for (const fn of report?.functions ?? []) {
+ nodes.set(fn.name, { ...(nodes.get(fn.name) ?? {}), ...fn, id: fn.name, name: fn.name });
+ }
+
+ const children = new Map();
+ const parents = new Map();
+ for (const id of nodes.keys()) {
+ children.set(id, []);
+ parents.set(id, []);
+ }
+ let callCount = 0;
+ const linked = new Set();
+ for (const edge of report?.callGraph?.edges ?? []) {
+ if (!nodes.has(edge.from) || !nodes.has(edge.to) || children.get(edge.from).includes(edge.to)) { continue; }
+ children.get(edge.from).push(edge.to);
+ parents.get(edge.to).push(edge.from);
+ linked.add(edge.from);
+ linked.add(edge.to);
+ callCount++;
+ }
+ const compare = byPeakThenName(nodes);
+ children.forEach(list => list.sort(compare));
+ parents.forEach(list => list.sort(compare));
+
+ const hasCaller = id => parents.get(id).some(parent => parent !== id);
+ const mainFirst = (a, b) => (a === 'main' ? -1 : b === 'main' ? 1 : compare(a, b));
+ const roots = [...linked].filter(id => !hasCaller(id)).sort(mainFirst);
+
+ const reached = new Set();
+ const visit = start => {
+ const stack = [start];
+ while (stack.length) {
+ const id = stack.pop();
+ if (reached.has(id)) { continue; }
+ reached.add(id);
+ stack.push(...children.get(id));
+ }
+ };
+ roots.forEach(visit);
+ for (const id of [...linked].sort(compare)) {
+ if (!reached.has(id)) {
+ roots.push(id);
+ visit(id);
+ }
+ }
+
+ const standalone = [...nodes.keys()].filter(id => !linked.has(id)).sort(compare);
+ const peak = Math.max(1, report?.peakStack || 0, ...[...nodes.values()].map(node => node.maxStack || 0));
+ const model = { nodes, children, parents, roots, standalone, peak, callCount, hotKeys: new Set(), hotPath: [] };
+ model.hotPath = computeHotPath(model);
+ // Prefixes of the path are hot too: they are the rows leading to it.
+ model.hotPath.forEach((_, index) => model.hotKeys.add(keyOf(topLevelPrefix(model, model.hotPath[0]), model.hotPath.slice(0, index + 1))));
+ return model;
+ }
+
+ function keyOf(prefix, ids) {
+ return [...prefix, ...ids].join(SEPARATOR);
+ }
+
+ /** Standalone rows live under the group row, so their keys start with it. */
+ function topLevelPrefix(model, id) {
+ return model.roots.length > 0 && model.standalone.includes(id) ? [STANDALONE_KEY] : [];
+ }
+
+ /** The call path that uses the most stack, following the heaviest callee at each step. */
+ function computeHotPath(model) {
+ const candidates = model.roots.length > 0 ? model.roots : model.standalone;
+ if (candidates.length === 0) { return []; }
+ let current = [...candidates].sort(byPeakThenName(model.nodes))[0];
+ const path = [];
+ const seen = new Set();
+ while (current !== undefined && !seen.has(current)) {
+ path.push(current);
+ seen.add(current);
+ current = model.children.get(current).find(child => !seen.has(child));
+ }
+ return path;
+ }
+
+ /** Roots and their first level open, plus the rows leading to the hottest function. */
+ function defaultExpanded(model) {
+ const expanded = new Set();
+ for (const root of model.roots) { expanded.add(root); }
+ if (model.hotPath.length > 0 && model.roots.length > 0) {
+ model.hotPath.slice(0, -1).forEach((_, index) => expanded.add(keyOf([], model.hotPath.slice(0, index + 1))));
+ }
+ return expanded;
+ }
+
+ /** Every expandable row down to `maxDepth`, for "Expand all". */
+ function allExpanded(model, maxDepth = 8, maxKeys = DEFAULT_MAX_ROWS) {
+ const expanded = new Set();
+ const walk = (id, path) => {
+ if (expanded.size >= maxKeys || path.length >= maxDepth) { return; }
+ const next = [...path, id];
+ const callees = model.children.get(id).filter(child => !next.includes(child));
+ if (callees.length === 0 && model.children.get(id).length === 0) { return; }
+ expanded.add(keyOf([], next));
+ callees.forEach(child => walk(child, next));
+ };
+ model.roots.forEach(root => walk(root, []));
+ return expanded;
+ }
+
+ function matchesQuery(node, query) {
+ return node.name.toLowerCase().includes(query) || Boolean(node.file && String(node.file).toLowerCase().includes(query));
+ }
+
+ /** Matching functions and everything that leads to them. */
+ function visibleForQuery(model, query) {
+ const matches = new Set([...model.nodes.values()].filter(node => matchesQuery(node, query)).map(node => node.id));
+ const visible = new Set(matches);
+ const stack = [...matches];
+ while (stack.length) {
+ for (const parent of model.parents.get(stack.pop())) {
+ if (!visible.has(parent)) {
+ visible.add(parent);
+ stack.push(parent);
+ }
+ }
+ }
+ return { matches, visible };
+ }
+
+ /**
+ * Flattens the tree into the rows to display. With a query every visible
+ * row is open, since the user wants to see where the matches are.
+ * @returns {{ rows: any[], matchCount: number, truncated: boolean }}
+ */
+ function flattenTree(model, options = {}) {
+ const query = (options.query ?? '').trim().toLowerCase();
+ const expanded = options.expanded ?? new Set();
+ const maxRows = options.maxRows ?? DEFAULT_MAX_ROWS;
+ const filter = query ? visibleForQuery(model, query) : null;
+ const rows = [];
+ let truncated = false;
+
+ const push = row => {
+ if (rows.length >= maxRows) {
+ truncated = true;
+ return false;
+ }
+ rows.push(row);
+ return true;
+ };
+
+ const walk = (id, depth, ancestors, prefix) => {
+ const path = [...ancestors, id];
+ const key = keyOf(prefix, path);
+ const isCycleRef = ancestors.includes(id);
+ const callees = isCycleRef ? [] : model.children.get(id).filter(child => !filter || filter.visible.has(child));
+ const isExpanded = callees.length > 0 && (filter !== null || expanded.has(key));
+ if (!push({
+ kind: 'function',
+ key,
+ id,
+ depth,
+ isCycleRef,
+ expandable: callees.length > 0,
+ expanded: isExpanded,
+ hot: model.hotKeys.has(key),
+ match: filter?.matches.has(id) === true,
+ })) { return; }
+ if (isExpanded) { callees.forEach(child => walk(child, depth + 1, path, prefix)); }
+ };
+
+ const roots = filter ? model.roots.filter(id => filter.visible.has(id)) : model.roots;
+ roots.forEach(root => walk(root, 0, [], []));
+
+ const standalone = filter ? model.standalone.filter(id => filter.matches.has(id)) : model.standalone;
+ if (standalone.length > 0) {
+ if (model.roots.length === 0) {
+ // Without any call link there is nothing to group apart from.
+ standalone.forEach(id => walk(id, 0, [], []));
+ } else {
+ const open = filter !== null || expanded.has(STANDALONE_KEY);
+ if (push({ kind: 'group', key: STANDALONE_KEY, depth: 0, count: standalone.length, expandable: true, expanded: open })) {
+ if (open) { standalone.forEach(id => walk(id, 1, [], [STANDALONE_KEY])); }
+ }
+ }
+ }
+ return { rows, matchCount: filter ? filter.matches.size : model.nodes.size, truncated };
+ }
+
+ /** All functions, heaviest first, for the Ranking view. */
+ function rankedRows(model, queryText = '') {
+ const query = queryText.trim().toLowerCase();
+ return [...model.nodes.values()]
+ .filter(node => !query || matchesQuery(node, query))
+ .sort((a, b) => byPeakThenName(model.nodes)(a.id, b.id))
+ .map(node => ({
+ kind: 'function',
+ key: node.id,
+ id: node.id,
+ depth: 0,
+ isCycleRef: false,
+ expandable: false,
+ expanded: false,
+ hot: false,
+ match: Boolean(query),
+ }));
+ }
+
+ /** Key of the shortest path from a root to `id`, with the keys to open on the way. */
+ function pathTo(model, id) {
+ if (!model.nodes.has(id)) { return null; }
+ if (model.standalone.includes(id)) {
+ const prefix = topLevelPrefix(model, id);
+ return { key: keyOf(prefix, [id]), open: prefix.length > 0 ? [STANDALONE_KEY] : [] };
+ }
+ const queue = model.roots.map(root => [root]);
+ const seen = new Set(model.roots);
+ while (queue.length) {
+ const path = queue.shift();
+ const last = path[path.length - 1];
+ if (last === id) {
+ return { key: keyOf([], path), open: path.slice(0, -1).map((_, index) => keyOf([], path.slice(0, index + 1))) };
+ }
+ for (const child of model.children.get(last)) {
+ if (!seen.has(child)) {
+ seen.add(child);
+ queue.push([...path, child]);
+ }
+ }
+ }
+ return null;
+ }
+
+ /** CodeLens passes unqualified names: `run` must find `Worker::run`. */
+ function resolveName(model, name) {
+ if (!name) { return null; }
+ if (model.nodes.has(name)) { return name; }
+ const suffix = `::${name}`;
+ const match = [...model.nodes.keys()].filter(id => id.endsWith(suffix)).sort(byPeakThenName(model.nodes));
+ return match[0] ?? null;
+ }
+
+ /** Key of the row a nested row hangs from, or null at the top level. */
+ function parentKey(key) {
+ const index = key.lastIndexOf(SEPARATOR);
+ return index < 0 ? null : key.slice(0, index);
+ }
+
+ return { STANDALONE_KEY, createModel, defaultExpanded, allExpanded, flattenTree, rankedRows, pathTo, resolveName, parentKey };
+}));
diff --git a/media/style.css b/media/style.css
index 4047311..42ffba0 100755
--- a/media/style.css
+++ b/media/style.css
@@ -137,26 +137,28 @@ body {
transform: translateY(-50%) rotate(25deg);
}
-.settings-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- min-height: 34px;
- margin-bottom: 10px;
- padding-bottom: 10px;
- border-bottom: 1px solid var(--coretrace-border);
+/* Stays reachable while scrolling a long list of settings. */
+.settings-top {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ padding-bottom: 8px;
+ background: var(--vscode-sideBar-background, var(--coretrace-surface));
}
-.settings-header-actions {
+.settings-top .search-field { width: 100%; }
+
+.settings-header {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 4px;
+ min-height: 28px;
+ margin-bottom: 6px;
}
.settings-title {
color: var(--coretrace-text);
- font-size: 0.84em;
+ font-size: 0.9em;
font-weight: 600;
}
@@ -685,7 +687,13 @@ body {
.tool-dropdown label:hover { background: var(--vscode-list-hoverBackground, var(--coretrace-red-soft)); }
.tool-dropdown input { accent-color: var(--coretrace-red); }
-/* ββ Compact configuration sections βββββββββββββββββββββββββββββββββββββββββ */
+/* ββ Settings sections βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.settings-sections {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
.config-section {
border: 1px solid var(--coretrace-border);
border-radius: 7px;
@@ -698,72 +706,221 @@ body {
align-items: center;
gap: 7px;
padding: 8px 10px;
- color: var(--coretrace-muted);
- font-size: 0.78em;
+ color: var(--coretrace-text);
+ font-size: 0.8em;
+ font-weight: 600;
cursor: pointer;
list-style: none;
user-select: none;
}
.config-section summary::-webkit-details-marker { display: none; }
-.config-section summary svg { width: 14px; height: 14px; flex-shrink: 0; }
-.config-section summary span { flex: 1; }
-.config-section summary:hover { color: var(--coretrace-text); background: var(--coretrace-surface-raised); }
+.config-section summary svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--coretrace-muted); }
+.config-section summary:hover { background: var(--coretrace-surface-raised); }
+.config-section summary:focus-visible { outline: 1px solid var(--vscode-focusBorder, var(--coretrace-red-bright)); outline-offset: -1px; }
+.config-title { flex: 1; min-width: 0; }
+
+.config-count {
+ min-width: 16px;
+ padding: 0 4px;
+ border-radius: 8px;
+ background: var(--coretrace-red);
+ color: #fff;
+ font-size: 0.82em;
+ line-height: 16px;
+ text-align: center;
+}
+
+.config-count[hidden] { display: none; }
.section-chevron { transition: transform 0.2s ease; }
-.config-section[open] .section-chevron { transform: rotate(180deg); color: var(--coretrace-red-bright); }
+.config-section[open] .section-chevron { transform: rotate(180deg); }
-.config-content {
- display: grid;
- grid-template-columns: 1fr;
- gap: 5px;
- padding: 0 10px 10px;
+.config-content,
+.setting-group {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
}
-.scan-workspace-only.is-hidden {
- display: none;
+.config-content { padding: 4px 10px 12px; }
+
+.config-section.is-disabled { opacity: 0.48; }
+.config-section.is-disabled summary { cursor: not-allowed; }
+
+.is-hidden,
+.search-miss { display: none !important; }
+
+.setting {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
}
-.config-section.is-disabled {
- opacity: 0.48;
+/* Same cue as the VS Code settings editor: a bar beside what was changed. */
+.setting.is-modified::before {
+ content: '';
+ position: absolute;
+ inset: 0 auto 0 -10px;
+ width: 2px;
+ background: var(--coretrace-red-bright);
}
-.config-section.is-disabled summary {
- cursor: not-allowed;
+.setting-toggle {
+ flex-direction: row;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ cursor: pointer;
}
-.config-toggle.is-disabled {
- opacity: 0.45;
- cursor: not-allowed;
+.setting-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.setting-label {
+ color: var(--coretrace-text);
+ font-size: 0.76em;
}
-.settings-hint {
- flex-basis: 100%;
- margin-left: 21px;
+.setting-hint {
color: var(--coretrace-muted);
- font-size: 0.9em;
+ font-size: 0.68em;
+ line-height: 1.35;
}
-.config-content label:not(.config-toggle),
-.profile-select > span {
- margin-top: 3px;
+.setting-scope {
+ display: none;
color: var(--coretrace-muted);
- font-size: 0.72em;
+ font-size: 0.68em;
+ font-weight: 600;
+}
+
+/* An option this scan mode cannot use shrinks to one line saying why. */
+.setting.is-disabled,
+.setting.is-disabled .setting-text {
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 2px 8px;
+ cursor: not-allowed;
+}
+
+.setting.is-disabled .setting-text { flex: 1; }
+.setting.is-disabled .setting-label { color: var(--coretrace-muted); }
+.setting.is-disabled .setting-hint,
+.setting.is-disabled input { display: none; }
+.setting.is-disabled .setting-scope { display: block; }
+
+.switch {
+ appearance: none;
+ position: relative;
+ flex: 0 0 28px;
+ width: 28px;
+ height: 16px;
+ margin: 1px 0 0;
+ border-radius: 8px;
+ background: rgba(128, 128, 128, 0.42);
+ cursor: inherit;
+ transition: background 0.15s ease;
+}
+
+.switch::before {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.15s ease;
}
+.switch:checked { background: var(--coretrace-red); }
+.switch:checked::before { transform: translateX(12px); }
+.switch:focus-visible { outline: 1px solid var(--vscode-focusBorder, var(--coretrace-red-bright)); outline-offset: 2px; }
+
.config-content input[type='text'],
.config-content input[type='number'],
.config-content select {
width: 100%;
min-width: 0;
- padding: 6px 8px;
+ padding: 5px 8px;
background: var(--coretrace-input);
color: var(--coretrace-input-text);
border: 1px solid var(--vscode-input-border, var(--coretrace-border));
border-radius: 4px;
- font-size: 0.78em;
+ font-size: 0.76em;
font-family: var(--vscode-editor-font-family);
}
+.config-content input[type='text']:focus,
+.config-content input[type='number']:focus,
+.config-content select:focus {
+ outline: 1px solid var(--vscode-focusBorder, var(--coretrace-red-bright));
+ outline-offset: -1px;
+}
+
+.settings-no-match {
+ padding: 14px 8px;
+ color: var(--coretrace-muted);
+ font-size: 0.76em;
+ text-align: center;
+}
+
+.settings-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px solid var(--coretrace-border);
+}
+
+.settings-action {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ width: 100%;
+ padding: 5px 6px;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--coretrace-text);
+ font: inherit;
+ font-size: 0.76em;
+ text-align: left;
+ cursor: pointer;
+}
+
+.settings-action svg { width: 14px; height: 14px; flex-shrink: 0; color: var(--coretrace-muted); }
+.settings-action:hover:not(:disabled) { background: var(--coretrace-surface-raised); }
+.settings-action:disabled { color: var(--coretrace-muted); opacity: 0.55; cursor: default; }
+.settings-reset.is-armed,
+.settings-reset.is-armed svg { color: var(--coretrace-red-bright); }
+
+.settings-badge {
+ position: absolute;
+ top: -3px;
+ right: -4px;
+ min-width: 13px;
+ padding: 0 3px;
+ border-radius: 7px;
+ background: var(--coretrace-red);
+ color: #fff;
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 13px;
+ text-align: center;
+}
+
+.settings-badge[hidden] { display: none; }
+
.file-input-row {
display: flex;
align-items: stretch;
@@ -795,22 +952,14 @@ body {
.config-content input:disabled,
.config-content select:disabled {
- opacity: 0.45;
cursor: not-allowed;
}
-.config-toggle {
- display: flex;
- align-items: center;
- gap: 7px;
- padding-top: 3px;
+.profile-select > span {
color: var(--coretrace-muted);
- font-size: 0.75em;
- cursor: pointer;
+ font-size: 0.72em;
}
-.config-toggle input { accent-color: var(--coretrace-red); }
-
.profile-select {
display: flex;
align-items: center;
@@ -843,26 +992,6 @@ body {
background: var(--coretrace-red-soft);
}
-.btn-help svg {
- width: 14px;
- height: 14px;
-}
-
-.btn-help {
- display: grid;
- place-items: center;
- padding: 3px;
- background: transparent;
- color: var(--coretrace-muted);
- border: 0;
- cursor: pointer;
-}
-
-.btn-help:hover {
- color: var(--coretrace-red-bright);
- filter: drop-shadow(0 0 6px var(--coretrace-red-glow));
-}
-
/* ββ Divider βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
.divider {
height: 1px;
@@ -1020,6 +1149,76 @@ body {
word-break: break-word;
}
+.vuln-footer {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.vuln-cwe {
+ font: inherit;
+ font-size: 0.68em;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 999px;
+ border: 1px solid var(--coretrace-border);
+ background: var(--coretrace-surface-raised);
+ color: var(--coretrace-text);
+ cursor: pointer;
+}
+
+.vuln-cwe:hover {
+ border-color: var(--coretrace-red-bright);
+ text-decoration: underline;
+}
+
+.vuln-tool {
+ font-size: 0.68em;
+ color: var(--coretrace-muted);
+}
+
+.vuln-ignore {
+ font: inherit;
+ font-size: 0.68em;
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 1px 6px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--coretrace-muted);
+ cursor: pointer;
+}
+
+.vuln-ignore svg { width: 10px; height: 10px; }
+
+.vuln-ignore:hover {
+ color: var(--coretrace-text);
+ border-color: var(--coretrace-border);
+}
+
+.ignored-notice {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+}
+
+.link-btn {
+ font: inherit;
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--vscode-textLink-foreground);
+ cursor: pointer;
+}
+
+.link-btn:hover { text-decoration: underline; }
+
button:focus-visible,
input:focus-visible,
select:focus-visible {
@@ -1437,440 +1636,326 @@ select:focus-visible {
text-align: center;
}
-/* ββ Call graph βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
-.stack-graph-heading,
-.stack-graph-toolbar,
-.stack-graph-legend,
-.stack-graph-inspector {
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-.stack-graph-heading .stack-section-title { margin-bottom: 6px; }
-.stack-graph-count {
- color: var(--coretrace-muted);
- font-size: 0.68em;
- font-family: var(--vscode-editor-font-family, monospace);
-}
-.stack-graph-toolbar {
+/* ββ Call tree βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+#panel-stack .search-field { width: 100%; margin-bottom: 6px; }
+
+.stack-tree-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
- gap: 8px;
+ gap: 6px;
margin-bottom: 8px;
}
-.stack-graph-modes {
+
+.stack-view-modes {
display: inline-flex;
- align-items: center;
+ padding: 2px;
+ gap: 2px;
background: var(--coretrace-surface);
border: 1px solid var(--coretrace-border);
border-radius: 6px;
- padding: 2px;
- gap: 2px;
}
+
.stack-mode-btn {
+ padding: 2px 8px;
border: none;
+ border-radius: 4px;
background: transparent;
color: var(--coretrace-muted);
- font-size: 0.68em;
+ font: inherit;
+ font-size: 0.72em;
font-weight: 600;
- padding: 2px 7px;
- border-radius: 4px;
cursor: pointer;
- transition: all 0.15s ease;
-}
-.stack-mode-btn:hover {
- color: var(--coretrace-text);
}
+
+.stack-mode-btn:hover { color: var(--coretrace-text); }
+
.stack-mode-btn.active {
- background: var(--coretrace-red-soft);
- color: var(--coretrace-red-bright);
- font-weight: 700;
-}
-.stack-graph-caption {
- color: var(--coretrace-muted);
- font-size: 0.7em;
- line-height: 1.25;
+ background: var(--coretrace-surface-raised);
+ color: var(--coretrace-text);
+ box-shadow: inset 0 0 0 1px var(--coretrace-border);
}
-.stack-graph-zoom {
- display: flex;
+
+.stack-tree-actions { display: inline-flex; gap: 1px; }
+.stack-tree-actions[hidden] { display: none; }
+
+.stack-tree-actions button {
+ display: inline-flex;
align-items: center;
- gap: 1px;
- height: 25px;
- padding: 2px;
- flex-shrink: 0;
- border: 1px solid var(--coretrace-border);
- border-radius: 6px;
- background: var(--coretrace-surface);
-}
-.stack-graph-zoom button {
- display: grid;
- place-items: center;
- width: 20px;
- height: 19px;
- border: 0;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border: none;
border-radius: 4px;
- color: var(--coretrace-muted);
background: transparent;
+ color: var(--coretrace-muted);
cursor: pointer;
}
-.stack-graph-zoom button:hover:not(:disabled),
-.stack-graph-zoom button:focus-visible {
+
+.stack-tree-actions button:hover,
+.stack-tree-actions button:focus-visible {
+ background: var(--coretrace-surface-raised);
color: var(--coretrace-text);
- background: var(--coretrace-red-soft);
- outline: none;
}
-.stack-graph-zoom button:disabled { opacity: 0.35; cursor: default; }
-.stack-graph-zoom svg { width: 12px; height: 12px; }
-.stack-graph-zoom span {
- min-width: 34px;
- text-align: center;
- color: var(--coretrace-text);
- font: 600 0.67em var(--vscode-editor-font-family, monospace);
- cursor: pointer;
- user-select: none;
- padding: 0 2px;
- transition: color 0.15s ease;
+
+.stack-tree-actions svg { width: 14px; height: 14px; }
+
+.stack-tree-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 0 6px 4px;
+ color: var(--coretrace-muted);
+ font-size: 0.66em;
}
-.stack-graph-zoom span:hover {
- color: var(--coretrace-red-bright);
+
+.stack-tree {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
}
-.stack-graph-container {
- height: 320px;
- min-height: 160px;
- max-height: 85vh;
- overflow: auto;
- resize: vertical;
- border: 1px solid var(--coretrace-border);
- border-radius: 10px 10px 0 0;
- background-color: var(--coretrace-black);
- background-image:
- radial-gradient(ellipse at 35% 10%, rgba(227, 38, 54, 0.11), transparent 65%),
- linear-gradient(rgba(125, 130, 145, 0.07) 1px, transparent 1px),
- linear-gradient(90deg, rgba(125, 130, 145, 0.07) 1px, transparent 1px);
- background-size: auto, 22px 22px, 22px 22px;
- box-shadow: inset 0 1px 10px rgba(0, 0, 0, 0.15);
- scrollbar-color: var(--coretrace-border) transparent;
- cursor: grab;
- outline: none !important;
-}
-.stack-graph-resizer {
+
+.stack-row {
+ position: relative;
display: flex;
align-items: center;
- justify-content: center;
- height: 11px;
- margin-bottom: 6px;
- background: var(--coretrace-surface);
- border: 1px solid var(--coretrace-border);
- border-top: none;
- border-radius: 0 0 8px 8px;
- cursor: ns-resize;
+ gap: 2px;
+ min-height: 22px;
+ padding: 0 6px 0 2px;
+ border-radius: 3px;
+ font-size: 0.78em;
+ cursor: pointer;
user-select: none;
- transition: background 0.15s ease, border-color 0.15s ease;
-}
-.stack-graph-resizer:hover,
-.stack-graph-resizer.is-resizing {
- background: var(--coretrace-surface-raised);
- border-color: var(--coretrace-red-bright);
+ isolation: isolate;
}
-.stack-graph-resizer .resizer-handle {
- width: 32px;
- height: 3px;
- background: var(--coretrace-muted);
- border-radius: 999px;
- opacity: 0.6;
- transition: background 0.15s ease, width 0.15s ease, opacity 0.15s ease;
+
+/* A data bar anchored behind the value: the share of the highest peak, kept
+ clear of the indentation and of the start of the name. */
+.stack-row::before {
+ content: '';
+ position: absolute;
+ inset: 1px 0 1px auto;
+ width: calc(var(--pct, 0%) * 0.55);
+ border-radius: 3px;
+ background: rgba(128, 128, 128, 0.16);
+ z-index: -1;
}
-.stack-graph-resizer:hover .resizer-handle,
-.stack-graph-resizer.is-resizing .resizer-handle {
- background: var(--coretrace-red-bright);
- width: 48px;
- opacity: 1;
+
+.stack-row.is-hot::before { background: rgba(227, 38, 54, 0.28); }
+.stack-row.is-group::before,
+.stack-row.is-cycle-ref::before { display: none; }
+
+.stack-row:hover { background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.1)); }
+
+.stack-row:focus { outline: none; }
+
+.stack-row.is-selected {
+ background: var(--vscode-list-inactiveSelectionBackground, rgba(128, 128, 128, 0.2));
+ box-shadow: inset 2px 0 0 var(--coretrace-red-bright);
}
-.stack-graph-container.is-panning { cursor: grabbing; }
-.stack-graph {
- display: block;
- font-family: var(--vscode-editor-font-family, monospace);
- outline: none !important;
- user-select: none;
+
+.stack-row:focus-visible {
+ box-shadow: inset 0 0 0 1px var(--vscode-focusBorder, var(--coretrace-red-bright));
}
-.stack-wire-start { stop-color: var(--coretrace-muted); stop-opacity: 0.28; }
-.stack-wire-end { stop-color: var(--coretrace-red-bright); stop-opacity: 0.85; }
-.stack-node-top { stop-color: var(--coretrace-surface-raised); }
-.stack-node-bottom { stop-color: var(--coretrace-surface); }
-.stack-graph-wire { transition: opacity 0.18s ease; }
-.stack-graph-wire .wire-glow {
- display: none;
+
+.stack-row-guides {
+ flex: 0 0 auto;
+ align-self: stretch;
+ width: min(calc(var(--depth, 0) * 11px), 40%);
+ background-image: repeating-linear-gradient(to right, var(--coretrace-border) 0 1px, transparent 1px 11px);
+ background-position: 7px 0;
+ background-repeat: no-repeat;
}
-.stack-graph-wire .wire-line {
- fill: none;
- stroke: url(#stack-wire-gradient);
- stroke-width: 2;
- stroke-linecap: round;
-}
-.stack-graph-wire .wire-terminal {
- fill: var(--coretrace-red-bright);
- stroke: var(--coretrace-black);
- stroke-width: 2;
-}
-.stack-graph-wire.recursive .wire-line { stroke: var(--coretrace-red-bright); stroke-dasharray: 5 4; }
-.stack-graph-wire.is-selected .wire-line { stroke-width: 2.8; }
-.stack-graph-wire.is-selected .wire-glow { display: none; }
-.stack-graph-wire.is-dimmed { opacity: 0.13; }
-.stack-graph-node {
- cursor: pointer;
- transition: opacity 0.18s ease;
- outline: none !important;
- -webkit-tap-highlight-color: transparent;
-}
-.stack-graph-node:focus,
-.stack-graph-node:focus-visible,
-.stack-graph-node:active {
- outline: none !important;
-}
-.stack-graph-node.is-dimmed { opacity: 0.34; }
-.stack-graph-node .node-shell {
- fill: url(#stack-node-gradient);
- stroke: var(--coretrace-border);
- stroke-width: 1.4;
- filter: none;
-}
-.stack-graph-node:hover .node-shell,
-.stack-graph-node:focus-visible .node-shell,
-.stack-graph-node.is-selected .node-shell {
- stroke: var(--coretrace-red-bright);
- stroke-width: 2;
- filter: none;
-}
-.stack-graph-node .node-accent { fill: var(--coretrace-muted); opacity: 0.45; }
-.stack-graph-node:hover .node-accent,
-.stack-graph-node.is-selected .node-accent,
-.stack-graph-node.recursive .node-accent { fill: var(--coretrace-red-bright); opacity: 1; }
-.stack-graph-node .node-icon-box { fill: var(--coretrace-red-soft); }
-.stack-graph-node .node-icon { fill: var(--coretrace-red-bright); font-size: 17px; font-weight: 700; }
-.stack-graph-node .node-title { fill: var(--coretrace-text); font-size: 12px; font-weight: 700; }
-.stack-graph-node .node-meta-label { fill: var(--coretrace-muted); font-size: 9px; letter-spacing: 0.4px; }
-.stack-graph-node .node-meta-value { fill: var(--coretrace-text); font-size: 10px; font-weight: 600; }
-.stack-graph-node .node-bar-track { fill: var(--coretrace-border); }
-.stack-graph-node .node-bar-fill { fill: var(--coretrace-red-bright); }
-.stack-graph-node .node-port {
- fill: var(--coretrace-black);
- stroke: var(--coretrace-red-bright);
- stroke-width: 1.5;
-}
-.stack-graph-node.recursive .node-shell,
-.stack-graph-node.over-limit .node-shell { stroke: var(--coretrace-red-bright); }
-.stack-graph-empty-links {
- padding: 0 12px 12px;
+
+.stack-row-twisty {
+ flex: 0 0 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 22px;
color: var(--coretrace-muted);
- font-size: 0.7em;
}
-.stack-graph-inspector {
- gap: 8px;
- min-height: 47px;
- margin-top: 7px;
- padding: 7px 9px;
- border: 1px solid var(--coretrace-border);
- border-radius: 7px;
- background: var(--coretrace-surface);
+
+.stack-row-twisty svg {
+ width: 14px;
+ height: 14px;
+ transition: transform 0.12s ease;
}
-.stack-graph-inspector[hidden] { display: none; }
-.stack-inspector-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
-.stack-inspector-main strong {
+
+.stack-row[aria-expanded="true"] .stack-row-twisty svg { transform: rotate(90deg); }
+
+.stack-row-name {
+ flex: 1 1 auto;
+ min-width: 24px;
overflow: hidden;
+ color: var(--coretrace-text);
+ font-family: var(--vscode-editor-font-family, monospace);
text-overflow: ellipsis;
white-space: nowrap;
- color: var(--coretrace-text);
- font: 700 0.76em var(--vscode-editor-font-family, monospace);
}
-.stack-inspector-main span { color: var(--coretrace-muted); font-size: 0.68em; }
-.stack-graph-inspector button {
- flex-shrink: 0;
- padding: 4px 8px;
- border: 1px solid var(--coretrace-border);
- border-radius: 5px;
- color: var(--coretrace-text);
- background: var(--coretrace-surface-raised);
- font-size: 0.7em;
- cursor: pointer;
-}
-.stack-graph-inspector button:hover,
-.stack-graph-inspector button:focus-visible {
- border-color: var(--coretrace-red-bright);
- outline: none;
+
+.stack-row-name mark {
+ background: var(--vscode-editor-findMatchHighlightBackground, rgba(245, 158, 11, 0.35));
+ color: inherit;
+ border-radius: 2px;
}
-.stack-graph-legend {
- gap: 8px;
- justify-content: flex-start;
- flex-wrap: wrap;
- margin: 8px 1px 2px;
+
+.stack-row.is-hot .stack-row-name { font-weight: 600; }
+.stack-row.is-cycle-ref .stack-row-name { color: var(--coretrace-muted); font-style: italic; }
+
+.stack-row.is-group .stack-row-name {
color: var(--coretrace-muted);
- font-size: 0.65em;
-}
-.stack-graph-legend span { display: inline-flex; align-items: center; gap: 4px; }
-.stack-graph-legend i { display: inline-block; width: 11px; height: 0; border-top: 2px solid var(--coretrace-red-bright); }
-.stack-graph-legend .legend-cycle { border-top-style: dashed; }
-/* ββ Stack Visualizer: Functions Footprint List ββββββββββββββββββββββββββββββ */
-.stack-fn-list {
- list-style: none;
- display: flex;
- flex-direction: column;
- gap: 6px;
+ font-family: inherit;
+ font-weight: 600;
}
-#panel-stack,
-#panel-stack *:focus:not(input),
-#panel-stack *:focus-visible:not(input) {
- outline: none !important;
- -webkit-tap-highlight-color: transparent;
+.stack-row-badges {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ margin-left: 4px;
}
-.stack-fn-card {
- padding: 8px 10px;
- background: var(--coretrace-surface);
- border: 1px solid var(--coretrace-border);
- border-radius: 6px;
- cursor: pointer;
- transition: background 0.15s, border-color 0.15s, transform 0.15s;
- outline: none !important;
- -webkit-tap-highlight-color: transparent;
- user-select: none;
+.stack-badge {
+ padding: 0 4px;
+ border-radius: 3px;
+ font-size: 0.82em;
+ font-weight: 600;
+ line-height: 1.45;
}
-.stack-fn-card:hover {
- background: var(--coretrace-surface-raised);
- border-color: rgba(227, 38, 54, 0.5);
- transform: translateX(2px);
-}
+.stack-badge.badge-danger { background: rgba(227, 38, 54, 0.2); color: var(--coretrace-red-bright); }
+.stack-badge.badge-warning { background: rgba(245, 158, 11, 0.18); color: #d97706; }
-.stack-fn-card:focus,
-.stack-fn-card:focus-visible,
-.stack-fn-card:active {
- outline: none !important;
+.stack-row-value {
+ flex: 0 0 auto;
+ margin-left: 6px;
+ color: var(--coretrace-muted);
+ font-family: var(--vscode-editor-font-family, monospace);
+ font-size: 0.92em;
+ font-variant-numeric: tabular-nums;
}
-.stack-fn-card.is-recursive {
- border-left: 3px solid #ff4656;
-}
+.stack-row.is-hot .stack-row-value { color: var(--coretrace-text); }
-.stack-fn-card.is-safe {
- border-left: 3px solid #10b981;
+.stack-tree-notice {
+ padding: 6px;
+ color: var(--coretrace-muted);
+ font-size: 0.7em;
+ text-align: center;
}
-.stack-fn-card.is-focused {
- border-color: var(--coretrace-red-bright);
- background: var(--coretrace-surface-raised);
- box-shadow: none;
+/* Stays in view while scrolling a long tree. */
+.stack-details {
+ position: sticky;
+ bottom: 0;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-top: 8px;
+ padding: 8px;
+ border: 1px solid var(--coretrace-border);
+ border-radius: 6px;
+ background: var(--vscode-sideBar-background, var(--coretrace-surface));
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.18);
}
-.stack-fn-header {
+.stack-details[hidden] { display: none; }
+
+.stack-details-header {
display: flex;
- align-items: center;
+ align-items: flex-start;
justify-content: space-between;
gap: 6px;
- margin-bottom: 5px;
}
-.stack-fn-name {
- font-size: 0.78em;
- font-weight: 700;
- font-family: var(--vscode-editor-font-family, monospace);
+.stack-details-header strong {
+ min-width: 0;
color: var(--coretrace-text);
- display: flex;
- align-items: center;
- gap: 5px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ font: 700 0.78em var(--vscode-editor-font-family, monospace);
+ overflow-wrap: anywhere;
}
-.stack-fn-name svg {
- width: 12px;
- height: 12px;
- color: var(--coretrace-red-bright);
- flex-shrink: 0;
+.stack-details-close {
+ flex: 0 0 auto;
+ width: 18px;
+ height: 18px;
+ border: none;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--coretrace-muted);
+ line-height: 1;
+ cursor: pointer;
}
-.stack-fn-badges {
- display: flex;
- align-items: center;
+.stack-details-close:hover { background: var(--coretrace-surface-raised); color: var(--coretrace-text); }
+
+.stack-details-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
gap: 4px;
- flex-shrink: 0;
}
-.stack-pill {
- font-size: 0.66em;
- padding: 1px 5px;
- border-radius: 4px;
- font-weight: 600;
- background: var(--coretrace-surface-raised);
- color: var(--coretrace-muted);
- border: 1px solid var(--coretrace-border);
+.stack-details-stats div { display: flex; flex-direction: column; min-width: 0; }
+
+.stack-details-stats strong {
+ color: var(--coretrace-text);
+ font: 600 0.78em var(--vscode-editor-font-family, monospace);
+ white-space: nowrap;
}
-.stack-pill.pill-danger {
- background: rgba(227, 38, 54, 0.2);
- color: #ff6b78;
- border-color: rgba(227, 38, 54, 0.5);
+.stack-details-stats span,
+.stack-details-label {
+ color: var(--coretrace-muted);
+ font-size: 0.68em;
}
-.stack-pill.pill-safe {
- background: rgba(16, 185, 129, 0.15);
- color: #34d399;
- border-color: rgba(16, 185, 129, 0.4);
+.stack-details-links {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 3px 4px;
}
-.stack-pill.pill-file {
- background: rgba(255, 255, 255, 0.05);
- color: var(--coretrace-muted);
- border-color: rgba(255, 255, 255, 0.1);
- font-family: var(--vscode-editor-font-family, monospace);
- font-size: 0.63em;
- max-width: 120px;
+.stack-details-links .stack-details-label:first-child { flex: 0 0 100%; }
+
+.stack-link {
+ max-width: 100%;
overflow: hidden;
+ padding: 0 5px;
+ border: 1px solid var(--coretrace-border);
+ border-radius: 3px;
+ background: var(--coretrace-surface-raised);
+ color: var(--coretrace-text);
+ font: 0.7em var(--vscode-editor-font-family, monospace);
+ line-height: 1.6;
text-overflow: ellipsis;
white-space: nowrap;
+ cursor: pointer;
}
-.stack-pill.pill-warning {
- background: rgba(245, 158, 11, 0.15);
- color: #fbbf24;
- border-color: rgba(245, 158, 11, 0.4);
+.stack-link:hover,
+.stack-link:focus-visible {
+ border-color: var(--coretrace-red-bright);
}
-.stack-fn-title {
+.stack-details-open {
+ align-self: flex-start;
+ max-width: 100%;
overflow: hidden;
+ padding: 0;
+ border: none;
+ background: none;
+ color: var(--vscode-textLink-foreground, var(--coretrace-red-bright));
+ font: 0.72em var(--vscode-editor-font-family, monospace);
text-overflow: ellipsis;
white-space: nowrap;
+ cursor: pointer;
}
-.stack-bar-wrap {
- width: 100%;
- height: 4px;
- background: var(--coretrace-surface-raised);
- border-radius: 999px;
- overflow: hidden;
- margin: 5px 0;
-}
-
-.stack-bar-fill {
- height: 100%;
- background: linear-gradient(90deg, #10b981 0%, #f59e0b 60%, #e32636 100%);
- border-radius: 999px;
- transition: width 0.3s ease;
-}
-
-.stack-bar-fill.fill-danger {
- background: #ff4656;
-}
-
-.stack-fn-meta {
- display: flex;
- justify-content: space-between;
- font-size: 0.68em;
- color: var(--coretrace-muted);
-}
-
+.stack-details-open:hover { text-decoration: underline; }
@media (max-width: 260px) {
.sidebar { padding: 10px 8px; gap: 8px; }
diff --git a/package.json b/package.json
index cb5f849..aad0829 100755
--- a/package.json
+++ b/package.json
@@ -74,12 +74,99 @@
{
"command": "ctrace.focusStackFunction",
"title": "CoreTrace: View Function in Stack Visualizer"
+ },
+ {
+ "command": "ctrace.ignoreFinding",
+ "title": "CoreTrace: Ignore Finding at Cursor"
+ },
+ {
+ "command": "ctrace.manageIgnoredFindings",
+ "title": "CoreTrace: Manage Ignored Findings"
+ }
+ ],
+ "configuration": {
+ "title": "CoreTrace",
+ "properties": {
+ "ctrace.compileCommandsPath": {
+ "type": "string",
+ "default": "",
+ "description": "Path to compile_commands.json. When empty, it is auto-detected in the workspace root, the CMake Tools build directory, or build/."
+ },
+ "ctrace.analysis.timeoutSeconds": {
+ "type": "number",
+ "default": 60,
+ "minimum": 1,
+ "description": "Maximum time allowed to analyse a single file before ctrace is stopped."
+ },
+ "ctrace.workspace.incremental": {
+ "type": "boolean",
+ "default": true,
+ "description": "In workspace scans, only analyse files that changed since their last analysis and reuse the cached results of the others. Editing a header, compile_commands.json, the ctrace binary or the analysis options invalidates the cache. Run \"Clear Analysis Cache\" to force a full scan."
+ },
+ "ctrace.analysis.autoEntryPoints": {
+ "type": "boolean",
+ "default": false,
+ "description": "In single-file mode, pass every function of the file as an entry point."
+ },
+ "ctrace.analysis.stackLimit": {
+ "type": [
+ "number",
+ "string"
+ ],
+ "default": "",
+ "description": "Stack size limit passed to ctrace as --stack-limit (e.g. 8192 or 8KiB)."
+ },
+ "ctrace.analysis.resourceCrossTu": {
+ "type": "string",
+ "enum": [
+ "default",
+ "on",
+ "off"
+ ],
+ "default": "default",
+ "description": "Inter-procedural resource analysis across translation units. \"default\" lets ctrace decide."
+ },
+ "ctrace.models.escapeModel": {
+ "type": "string",
+ "default": "",
+ "description": "Path to a custom escape model passed as --escape-model."
+ },
+ "ctrace.models.bufferModel": {
+ "type": "string",
+ "default": "",
+ "description": "Path to a custom buffer model passed as --buffer-model."
+ },
+ "ctrace.models.resourceModel": {
+ "type": "string",
+ "default": "",
+ "description": "Path to a custom resource model passed as --resource-model."
+ },
+ "ctrace.performance.compdbFast": {
+ "type": "boolean",
+ "default": false,
+ "description": "Use the fast compilation-database mode of the stack analyzer."
+ },
+ "ctrace.performance.compileIrCacheDir": {
+ "type": "string",
+ "default": "",
+ "description": "Directory where the stack analyzer caches compiled IR."
+ },
+ "ctrace.cache.resourceSummaryDir": {
+ "type": "string",
+ "default": "",
+ "description": "Directory used to cache resource summaries between runs."
+ },
+ "ctrace.cache.memoryOnly": {
+ "type": "boolean",
+ "default": false,
+ "description": "Keep resource summaries in memory only (no disk cache)."
+ }
}
- ]
+ }
},
"scripts": {
"vscode:prepublish": "npm run compile",
- "compile": "npm run copy-lucide && node --check media/main.js && tsc -p ./",
+ "compile": "npm run copy-lucide && node --check media/main.js && node --check media/stackTree.js && tsc -p ./",
"watch": "npm run copy-lucide && tsc -watch -p ./",
"copy-lucide": "node scripts/copy-lucide.cjs",
"test": "vscode-test",
diff --git a/src/SidebarProvider.ts b/src/SidebarProvider.ts
index c7eb145..f00e2a4 100755
--- a/src/SidebarProvider.ts
+++ b/src/SidebarProvider.ts
@@ -103,7 +103,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
break;
}
case "execute-command": {
- const allowedCommands = ['ctrace.runAnalysis', 'ctrace.runWorkspaceAnalysis', 'ctrace.clearAnalysisCache', 'ctrace.showHelp', 'ctrace.installDependencies', 'ctrace.focusStackFunction'];
+ const allowedCommands = ['ctrace.runAnalysis', 'ctrace.runWorkspaceAnalysis', 'ctrace.clearAnalysisCache', 'ctrace.showHelp', 'ctrace.installDependencies', 'ctrace.focusStackFunction', 'ctrace.manageIgnoredFindings'];
if (!allowedCommands.includes(data.command)) {
console.warn(`[CoreTrace] Blocked unauthorized command from webview: ${data.command}`);
return;
@@ -111,6 +111,30 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
vscode.commands.executeCommand(data.command, data.params);
break;
}
+ case "ignore-finding": {
+ const fingerprint = typeof data.fingerprint === "string" && /^[0-9a-f]{8,64}$/.test(data.fingerprint) ? data.fingerprint : undefined;
+ if (!fingerprint) { break; }
+ const fileUri = await resolveReportedFileUri(data.path);
+ vscode.commands.executeCommand("ctrace.ignoreFinding", {
+ fingerprint,
+ ruleId: typeof data.ruleId === "string" ? data.ruleId : undefined,
+ file: fileUri?.fsPath ?? (typeof data.path === "string" ? data.path : ""),
+ line: Number.isFinite(data.line) ? Math.max(1, Math.floor(data.line)) : 1,
+ message: typeof data.message === "string" ? data.message : undefined,
+ });
+ break;
+ }
+ case "open-cwe": {
+ const match = typeof data.cwe === "string" ? data.cwe.match(/^CWE-(\d{1,5})$/) : null;
+ if (match) {
+ vscode.env.openExternal(vscode.Uri.parse(`https://cwe.mitre.org/data/definitions/${match[1]}.html`));
+ }
+ break;
+ }
+ case "open-vscode-settings": {
+ vscode.commands.executeCommand("workbench.action.openSettings", "@ext:CoreTrace.coretrace");
+ break;
+ }
case "pick-compile-commands": {
const selected = await vscode.window.showOpenDialog({
canSelectMany: false,
@@ -164,6 +188,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
const lucideUri = webview.asWebviewUri(
vscode.Uri.joinPath(this._extensionUri, "media", "lucide.min.js")
);
+ const stackTreeUri = webview.asWebviewUri(
+ vscode.Uri.joinPath(this._extensionUri, "media", "stackTree.js")
+ );
const logoUri = webview.asWebviewUri(
vscode.Uri.joinPath(this._extensionUri, "media", "icon.png")
);
@@ -192,6 +219,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
+
@@ -268,132 +296,217 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
+
+
+
+
+
+
-
-
- Filters & Scope
+
+
+ Scope
-
Function filter
-
-
-
Directory filter
+
+ Function filter
+
+ Only report functions with these names.
+
+
+ Only directories
+ Only report functions under these directories.
+ Scan Workspace only
-
-
-
- Advanced Engine (SMT)
+
+ Compilation
-
-
- Compilation & Build
+
+ Output
-
-
- Output & Diagnostics
+
+ SMT solver
-
-
- Analyzers & Dependencies
-
- Install or configure external tools: cppcheck, flawfinder, ikos, tscancode.
-
- Install / Repair Analyzers
-
-
-
+
+
No settings match your search.
+
+
+
+ Install or repair analyzers
+
+
+ More in VS Code settings
+
+
+ ctrace command help
+
+
+ Reset to defaults
+
+
@@ -460,43 +573,30 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
-
-
-