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 = ` -
    - - - ${escapeHtml(fn.name)}() - -
    - ${filePill} - ${isRec ? '⚠️ Cycle' : 'Safe'} - ${fn.hasDynamicAlloca ? 'alloca' : ''} -
    -
    -
    -
    -
    -
    - 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 CoreTrace @@ -268,132 +296,217 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo @@ -460,43 +573,30 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo - -
    -
    Call graph
    - 0 links -
    -
    -
    - - + + +
    +
    + +
    -
    - - - 100% - +
    + + +
    -
    -
    Run the stack analyzer to view functions and calls.
    +
    + 0 functions + Peak stack
    -
    -
    -
    - -
    CallRecursionClick a node to inspect
    - - -
    - Function Stack Footprints -
    -
    - +
    +
    Run the stack analyzer to view functions and calls.
    -
      +
      @@ -516,6 +616,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider, vscode.Dispo
      + `; diff --git a/src/codelens/IgnoreFindingCodeActionProvider.ts b/src/codelens/IgnoreFindingCodeActionProvider.ts new file mode 100644 index 0000000..4ddd0ed --- /dev/null +++ b/src/codelens/IgnoreFindingCodeActionProvider.ts @@ -0,0 +1,30 @@ +import * as vscode from 'vscode'; +import { getFindingForDiagnostic } from '../ctrace/DiagnosticsManager'; + +/** Offers "Ignore this finding" on CoreTrace diagnostics. */ +export class IgnoreFindingCodeActionProvider implements vscode.CodeActionProvider { + public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]; + + provideCodeActions( + document: vscode.TextDocument, + _range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext + ): vscode.CodeAction[] { + const actions: vscode.CodeAction[] = []; + for (const diagnostic of context.diagnostics) { + if (!diagnostic.source?.startsWith('CoreTrace')) { continue; } + const finding = getFindingForDiagnostic(document.uri, diagnostic); + if (!finding) { continue; } + const label = finding.ruleId ? ` (${finding.ruleId})` : ''; + const action = new vscode.CodeAction(`CoreTrace: Ignore this finding${label}`, vscode.CodeActionKind.QuickFix); + action.diagnostics = [diagnostic]; + action.command = { + command: 'ctrace.ignoreFinding', + title: 'Ignore this finding', + arguments: [finding], + }; + actions.push(action); + } + return actions; + } +} diff --git a/src/ctrace/AnalysisRunner.ts b/src/ctrace/AnalysisRunner.ts index f70c073..14ffec4 100755 --- a/src/ctrace/AnalysisRunner.ts +++ b/src/ctrace/AnalysisRunner.ts @@ -9,12 +9,16 @@ export interface RunResult { killed?: boolean; } -const DEFAULT_TIMEOUT_MS = 60_000; // 60 seconds per file +export const DEFAULT_TIMEOUT_MS = 60_000; // 60 seconds per file +const MAX_BUFFER = 10 * 1024 * 1024; /** * Executes a shell command and returns stdout/stderr. * cwd should be the extension folder so ctrace can find its bundled tools. * Never rejects β€” errors are captured in the RunResult. + * + * The shell runs in its own process group so that a timeout or cancellation + * kills ctrace and the analyzers it spawned, not just the wrapping shell. */ export function runCommand( command: string, @@ -23,8 +27,15 @@ export function runCommand( timeoutMs: number = DEFAULT_TIMEOUT_MS ): Promise { return new Promise(resolve => { - let child: cp.ChildProcess | undefined; + if (token?.isCancellationRequested) { + resolve({ stdout: '', stderr: 'Analysis cancelled by user.', exitCode: null, killed: true }); + return; + } + + let child: cp.ChildProcess; let settled = false; + let stdout = ''; + let stderr = ''; const finish = (result: RunResult) => { if (settled) { return; } @@ -34,23 +45,20 @@ export function runCommand( resolve(result); }; - // Set when a kill is requested (timeout or cancellation). If the child - // process is spawned AFTER this flag goes true (e.g. during an async - // chmod call) it must be killed immediately upon creation. - let killRequested = false; - const kill = (reason: string) => { - killRequested = true; - if (child && !child.killed) { - try { - child.kill(); - } catch { - // Ignore kill errors if already terminated - } - } - finish({ stdout: '', stderr: reason, exitCode: null, killed: true }); + killProcessTree(child); + finish({ stdout, stderr: stderr ? `${stderr}\n${reason}` : reason, exitCode: null, killed: true }); }; + try { + child = process.platform === 'win32' + ? cp.spawn(command, { cwd, shell: true, windowsHide: true }) + : cp.spawn('/bin/sh', ['-c', command], { cwd, detached: true }); + } catch (e) { + resolve({ stdout: '', stderr: 'Failed to execute command: ' + String(e), exitCode: 1 }); + return; + } + // Timeout guard to prevent infinite hanging const timer = setTimeout(() => { kill('Ctrace timed out after ' + (timeoutMs / 1000) + 's.'); @@ -61,27 +69,31 @@ export function runCommand( kill('Analysis cancelled by user.'); }); - if (token?.isCancellationRequested) { - finish({ stdout: '', stderr: 'Analysis cancelled by user.', exitCode: null, killed: true }); - return; - } + child.stdout?.on('data', (chunk: Buffer) => { + if (stdout.length < MAX_BUFFER) { stdout += chunk.toString(); } + }); + child.stderr?.on('data', (chunk: Buffer) => { + if (stderr.length < MAX_BUFFER) { stderr += chunk.toString(); } + }); + child.on('error', (err) => { + finish({ stdout, stderr: stderr + 'Failed to execute command: ' + err.message, exitCode: 1 }); + }); + child.on('close', (code) => { + finish({ stdout, stderr, exitCode: code }); + }); + }); +} - try { - child = cp.exec( - command, - { cwd, maxBuffer: 10 * 1024 * 1024 }, - (err, stdout, stderr) => { - finish({ - stdout: stdout ?? '', - stderr: stderr ?? '', - exitCode: err?.code ?? null, - }); - } - ); - } catch (e) { - clearTimeout(timer); - cancelListener?.dispose(); - resolve({ stdout: '', stderr: 'Failed to execute command: ' + String(e), exitCode: 1 }); +function killProcessTree(child: cp.ChildProcess): void { + if (child.pid === undefined || child.exitCode !== null) { return; } + try { + if (process.platform === 'win32') { + cp.spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); + } else { + // Negative pid targets the whole process group created by `detached`. + process.kill(-child.pid, 'SIGKILL'); } - }); + } catch { + // Ignore kill errors if already terminated + } } diff --git a/src/ctrace/CommandBuilder.ts b/src/ctrace/CommandBuilder.ts index dd3e687..3d6f2fe 100755 --- a/src/ctrace/CommandBuilder.ts +++ b/src/ctrace/CommandBuilder.ts @@ -91,11 +91,16 @@ function buildNativeCommand( params: string, compileCommands = false ): BuiltCommand { - const extraArgs = compileCommands ? ` --compile-commands "${inputFilePath}"` : ''; + // Paths are single-quoted: a workspace file named e.g. `a$(cmd).c` must + // not be expanded by the shell. + const input = shellEscapeArg(inputFilePath); + const bin = shellEscapeArg(ctracePath); + const extraArgs = compileCommands ? ` --compile-commands ${input}` : ''; const toolsDir = path.posix.join(os.homedir().replace(/\\/g, '/'), '.coretrace', 'tools'); - const envExports = `export CORETRACE_CPPCHECK_BIN="/opt/homebrew/bin/cppcheck" CORETRACE_IKOS_BIN="${toolsDir}/ikos/src/ikos-build/bin/ikos" CORETRACE_TSCANCODE_BIN="${toolsDir}/tscancode/src/tscancode/trunk/tscancode" CORETRACE_FLAWFINDER_SCRIPT="${toolsDir}/flawfinder/src/flawfinder-build/flawfinder.py" && `; - const cdTools = `cd "${toolsDir}" 2>/dev/null || true; `; - const command = `${cdTools}${envExports}chmod +x "${ctracePath}" && "${ctracePath}" --input "${inputFilePath}"${extraArgs} ${params}`; + const q = (p: string) => shellEscapeArg(`${toolsDir}/${p}`); + const envExports = `export CORETRACE_CPPCHECK_BIN='/opt/homebrew/bin/cppcheck' CORETRACE_IKOS_BIN=${q('ikos/src/ikos-build/bin/ikos')} CORETRACE_TSCANCODE_BIN=${q('tscancode/src/tscancode/trunk/tscancode')} CORETRACE_FLAWFINDER_SCRIPT=${q('flawfinder/src/flawfinder-build/flawfinder.py')} && `; + const cdTools = `cd ${shellEscapeArg(toolsDir)} 2>/dev/null || true; `; + const command = `${cdTools}${envExports}chmod +x ${bin} && ${bin} --input ${input}${extraArgs} ${params}`; return { command, tempFiles: [] }; } diff --git a/src/ctrace/DiagnosticsManager.ts b/src/ctrace/DiagnosticsManager.ts index 6d3c759..b02def2 100755 --- a/src/ctrace/DiagnosticsManager.ts +++ b/src/ctrace/DiagnosticsManager.ts @@ -1,7 +1,39 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; -import type { SarifLog } from '../types/sarif'; +import type { SarifLog, SarifResultProperties } from '../types/sarif'; +import { computeFingerprint, relativeFindingPath } from './FindingIgnoreList'; + +/** What the "Ignore finding" quick fix needs to know about a published diagnostic. */ +export interface FindingRef { + fingerprint: string; + ruleId?: string; + /** Absolute path of the file the diagnostic is shown in. */ + file: string; + /** 1-based line. */ + line: number; + message?: string; +} + +export interface UpdateDiagnosticsOptions { + /** Results whose fingerprint is ignored are not published. */ + isIgnored?: (fingerprint: string) => boolean; +} + +// Diagnostics shown in the editor β†’ finding they come from. Looked up by +// value (uri, position, message) because VS Code hands code-action providers +// copies of the diagnostics, not the published instances. +const findingsByDiagnostic = new Map(); + +/** Clears the collection and the finding lookup used by code actions. */ +export function clearDiagnostics(collection: vscode.DiagnosticCollection): void { + collection.clear(); + findingsByDiagnostic.clear(); +} + +export function getFindingForDiagnostic(uri: vscode.Uri, diagnostic: vscode.Diagnostic): FindingRef | undefined { + return findingsByDiagnostic.get(lookupKey(uri, diagnostic)); +} /** * Translates a SARIF result set into VS Code Diagnostics and updates the @@ -11,6 +43,10 @@ import type { SarifLog } from '../types/sarif'; * artifactLocation.uri field (resolved relative to workspace root or analysedFilePath). * Falls back to analysedFilePath when no URI is present. * + * Every result is annotated in place with `properties.ctraceTool`, + * `properties.ctraceFingerprint` and `properties.ctraceIgnored`, which the + * sidebar uses to show and hide findings. + * * @param clearFirst Set to false when calling inside a workspace scan loop * to avoid wiping earlier results. Caller must clear once * before the loop instead. @@ -19,64 +55,175 @@ export function updateDiagnostics( sarifData: SarifLog | any, collection: vscode.DiagnosticCollection, analysedFilePath: string, - clearFirst = true + clearFirst = true, + options: UpdateDiagnosticsOptions = {} ): void { - if (clearFirst) { collection.clear(); } + if (clearFirst) { clearDiagnostics(collection); } if (!sarifData?.runs?.length) { return; } - const allResults: any[] = sarifData.runs.flatMap((r: any) => r.results ?? []); - // Group diagnostics by resolved absolute file path const byFile = new Map(); - const fileDir = path.dirname(analysedFilePath); + const findingsByFile = new Map>(); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const lineReader = createLineReader(); - for (const result of allResults) { - const physLoc = result.locations?.[0]?.physicalLocation; - const region = physLoc?.region; - if (!region) { continue; } - - // ── Resolve the target file ─────────────────────────────────────────── - let targetFile = analysedFilePath; - const uriStr: string | undefined = physLoc?.artifactLocation?.uri; - if (uriStr) { - const normalised = normaliseMountPath(uriStr); - if (normalised.startsWith('file://')) { - targetFile = vscode.Uri.parse(normalised).fsPath; - } else if (fs.existsSync(normalised)) { - targetFile = normalised; - } else if (workspaceRoot && fs.existsSync(path.resolve(workspaceRoot, normalised))) { - targetFile = path.resolve(workspaceRoot, normalised); - } else if (fs.existsSync(path.resolve(fileDir, normalised))) { - targetFile = path.resolve(fileDir, normalised); - } else { - targetFile = analysedFilePath; - } - } + for (const run of sarifData.runs) { + const toolName: string | undefined = run?.tool?.driver?.name; + for (const result of run?.results ?? []) { + const physLoc = result.locations?.[0]?.physicalLocation; + const region = physLoc?.region; + if (!region) { continue; } + + const targetFile = resolveResultFile(physLoc?.artifactLocation?.uri, analysedFilePath, workspaceRoot); + + // ── Build range (SARIF lines are 1-based) ──────────────────────────── + const startLine = Math.max(0, (region.startLine ?? 1) - 1); + const startCol = Math.max(0, (region.startColumn ?? 1) - 1); + const endLine = Math.max(startLine, ((region.endLine || region.startLine || 1) - 1)); + const endCol = Math.max(startCol + 1, ((region.endColumn || region.startColumn || 1) - 1)); + + const rawMessage: string = result.message?.text ?? 'Unknown issue'; + const fingerprint = computeFingerprint( + result.ruleId, + relativeFindingPath(targetFile, workspaceRoot), + rawMessage, + lineReader(targetFile, startLine) + ); + const ignored = options.isIgnored?.(fingerprint) === true; + result.properties = { + ...(result.properties ?? {}), + ...(toolName ? { ctraceTool: toolName } : {}), + ctraceFingerprint: fingerprint, + ctraceIgnored: ignored, + }; + if (ignored) { continue; } + + const range = new vscode.Range(startLine, startCol, endLine, endCol); + const severity = sarifLevelToVsCode(result.level); + const diagnostic = new vscode.Diagnostic(range, buildMessage(rawMessage, result.properties), severity); + diagnostic.source = toolName ? `CoreTrace (${toolName})` : 'CoreTrace'; + diagnostic.code = buildCode(result.ruleId, cweIds(result.properties)); - // ── Build range (SARIF lines are 1-based) ──────────────────────────── - const startLine = Math.max(0, (region.startLine ?? 1) - 1); - const startCol = Math.max(0, (region.startColumn ?? 1) - 1); - const endLine = Math.max(startLine, ((region.endLine || region.startLine || 1) - 1)); - const endCol = Math.max(startCol + 1, ((region.endColumn || region.startColumn || 1) - 1)); - - const range = new vscode.Range(startLine, startCol, endLine, endCol); - const message = result.message?.text ?? 'Unknown issue'; - const severity = sarifLevelToVsCode(result.level); - const diagnostic = new vscode.Diagnostic(range, message, severity); - diagnostic.source = 'Ctrace'; - diagnostic.code = result.ruleId; - - if (!byFile.has(targetFile)) { byFile.set(targetFile, []); } - byFile.get(targetFile)!.push(diagnostic); + if (!byFile.has(targetFile)) { byFile.set(targetFile, []); } + byFile.get(targetFile)!.push(diagnostic); + if (!findingsByFile.has(targetFile)) { findingsByFile.set(targetFile, new Map()); } + findingsByFile.get(targetFile)!.set(diagnostic, { + fingerprint, + ruleId: result.ruleId, + file: targetFile, + line: startLine + 1, + message: rawMessage, + }); + } } byFile.forEach((diagnostics, fp) => { - collection.set(vscode.Uri.file(fp), diagnostics); + const uri = vscode.Uri.file(fp); + // In a workspace scan several translation units can report into the + // same header: append to what earlier files published instead of + // replacing it. + const existing = clearFirst ? [] : (collection.get(uri) ?? []); + const seen = new Set(existing.map(diagnosticKey)); + const merged = [...existing]; + for (const d of diagnostics) { + const key = diagnosticKey(d); + if (!seen.has(key)) { + seen.add(key); + merged.push(d); + const finding = findingsByFile.get(fp)?.get(d); + if (finding) { findingsByDiagnostic.set(lookupKey(uri, d), finding); } + } + } + collection.set(uri, merged); }); } +/** Resolves a SARIF artifact URI to an absolute host path, defaulting to the analysed file. */ +export function resolveResultFile( + uriStr: string | undefined, + analysedFilePath: string, + workspaceRoot: string | undefined +): string { + if (!uriStr) { return analysedFilePath; } + const normalised = normaliseMountPath(uriStr); + if (normalised.startsWith('file://')) { + return vscode.Uri.parse(normalised).fsPath; + } + if (fs.existsSync(normalised)) { + // normaliseMountPath yields forward slashes on Windows: restore the + // native form so one file never ends up under two different keys. + return path.normalize(normalised); + } + if (workspaceRoot && fs.existsSync(path.resolve(workspaceRoot, normalised))) { + return path.resolve(workspaceRoot, normalised); + } + const fileDir = path.dirname(analysedFilePath); + if (fs.existsSync(path.resolve(fileDir, normalised))) { + return path.resolve(fileDir, normalised); + } + return analysedFilePath; +} + +/** Normalised CWE ids ("CWE-415") from ctrace's `cwe` property or GitHub-style tags. */ +export function cweIds(properties: SarifResultProperties | undefined): string[] { + if (!properties) { return []; } + const raw: unknown[] = [ + ...(Array.isArray(properties.cwe) ? properties.cwe : [properties.cwe]), + ...(Array.isArray(properties.tags) ? properties.tags : []), + ]; + const ids: string[] = []; + for (const value of raw) { + const match = typeof value === 'number' + ? [String(value), String(value)] + : typeof value === 'string' ? value.match(/^(?:external\/cwe\/)?cwe[-/]?0*(\d{1,5})$/i) ?? value.match(/^0*(\d{1,5})$/) : null; + if (match) { + const id = `CWE-${Number(match[1])}`; + if (!ids.includes(id)) { ids.push(id); } + } + } + return ids; +} + +export function cweUrl(cweId: string): vscode.Uri { + return vscode.Uri.parse(`https://cwe.mitre.org/data/definitions/${cweId.replace(/^CWE-/i, '')}.html`); +} + +function buildCode(ruleId: string | undefined, cwes: string[]): vscode.Diagnostic['code'] { + if (cwes.length === 0) { return ruleId; } + return { + value: ruleId ? `${ruleId} Β· ${cwes.join(', ')}` : cwes.join(', '), + target: cweUrl(cwes[0]), + }; +} + +function buildMessage(message: string, properties: SarifResultProperties | undefined): string { + const others = (properties?.alsoReportedBy ?? []) + .filter(r => r && (r.tool || r.ruleId)) + .map(r => r.tool && r.ruleId ? `${r.tool} (${r.ruleId})` : (r.tool || r.ruleId)); + return others.length > 0 ? `${message}\nAlso reported by: ${others.join(', ')}` : message; +} + +function createLineReader(): (file: string, zeroBasedLine: number) => string { + const cache = new Map(); + return (file, line) => { + let lines = cache.get(file); + if (!lines) { + try { + lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + } catch { + lines = []; + } + cache.set(file, lines); + } + return lines[line] ?? ''; + }; +} + +function lookupKey(uri: vscode.Uri, d: vscode.Diagnostic): string { + return `${uri.toString()}|${d.range.start.line}:${d.range.start.character}|${d.message}`; +} + /** * Converts a WSL mount path (produced by ctrace running inside WSL on Windows) * to a Windows-style absolute path so `vscode.Uri.file()` resolves it correctly. @@ -98,11 +245,16 @@ export function normaliseMountPath(p: string): string { ); } +function diagnosticKey(d: vscode.Diagnostic): string { + return `${String(d.code ?? '')}|${d.range.start.line}|${d.range.start.character}|${d.message}`; +} + function sarifLevelToVsCode(level: 'error' | 'warning' | 'note' | 'none' | string | undefined): vscode.DiagnosticSeverity { switch ((level ?? '').toLowerCase()) { case 'error': return vscode.DiagnosticSeverity.Error; case 'warning': return vscode.DiagnosticSeverity.Warning; case 'note': return vscode.DiagnosticSeverity.Information; + case 'none': return vscode.DiagnosticSeverity.Hint; default: return vscode.DiagnosticSeverity.Warning; } } diff --git a/src/ctrace/FindingIgnoreList.ts b/src/ctrace/FindingIgnoreList.ts new file mode 100644 index 0000000..a2673c6 --- /dev/null +++ b/src/ctrace/FindingIgnoreList.ts @@ -0,0 +1,140 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** Lives at the workspace root so a team can commit its triaged false positives. */ +export const IGNORE_FILE_NAME = '.coretrace-ignore.json'; + +export interface IgnoredFinding { + fingerprint: string; + ruleId?: string; + /** Workspace-relative POSIX path. */ + file?: string; + line?: number; + message?: string; + reason?: string; + addedAt: string; +} + +interface IgnoreFile { + version: 1; + ignored: IgnoredFinding[]; +} + +/** + * Identifies a finding independently of its line number, so an ignored finding + * stays ignored when code above it is edited. The trimmed source line is part + * of the key to tell apart identical messages reported on different lines. + */ +export function computeFingerprint( + ruleId: string | undefined, + relativeFile: string, + message: string | undefined, + lineText: string +): string { + const normalizedLine = lineText.replace(/\s+/g, ' ').trim(); + return crypto.createHash('sha1') + .update(`${ruleId ?? ''}\0${relativeFile}\0${(message ?? '').trim()}\0${normalizedLine}`) + .digest('hex') + .slice(0, 20); +} + +/** Path used in fingerprints and in the ignore file: relative to the workspace when inside it. */ +export function relativeFindingPath(filePath: string, workspaceRoot: string | undefined): string { + if (workspaceRoot) { + const rel = path.relative(workspaceRoot, filePath); + if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) { + return rel.split(path.sep).join('/'); + } + } + return filePath.replace(/\\/g, '/'); +} + +export class FindingIgnoreList implements vscode.Disposable { + private _entries = new Map(); + private _serialized = ''; + private readonly _onDidChange = new vscode.EventEmitter(); + private readonly _watcher: vscode.FileSystemWatcher | undefined; + + /** Fires when the set of ignored fingerprints changes (from the UI or an edit of the file). */ + public readonly onDidChange = this._onDidChange.event; + + constructor(private readonly _workspaceRoot: string | undefined) { + this.reload(); + if (_workspaceRoot) { + this._watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(_workspaceRoot, IGNORE_FILE_NAME) + ); + const onDisk = () => { if (this.reload()) { this._onDidChange.fire(); } }; + this._watcher.onDidCreate(onDisk); + this._watcher.onDidChange(onDisk); + this._watcher.onDidDelete(onDisk); + } + } + + public get filePath(): string | undefined { + return this._workspaceRoot ? path.join(this._workspaceRoot, IGNORE_FILE_NAME) : undefined; + } + + public has(fingerprint: string | undefined): boolean { + return fingerprint !== undefined && this._entries.has(fingerprint); + } + + public list(): IgnoredFinding[] { + return [...this._entries.values()]; + } + + public async add(entry: Omit): Promise { + this.reload(); + if (this._entries.has(entry.fingerprint)) { return; } + this._entries.set(entry.fingerprint, { ...entry, addedAt: new Date().toISOString() }); + await this._write(); + } + + public async remove(fingerprints: string[]): Promise { + this.reload(); + let changed = false; + for (const fingerprint of fingerprints) { + changed = this._entries.delete(fingerprint) || changed; + } + if (changed) { await this._write(); } + } + + /** Re-reads the file. Returns true when the ignored set changed. */ + public reload(): boolean { + const file = this.filePath; + let entries: IgnoredFinding[] = []; + if (file) { + try { + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial; + entries = Array.isArray(parsed.ignored) + ? parsed.ignored.filter(e => typeof e?.fingerprint === 'string' && e.fingerprint.length > 0) + : []; + } catch { + // Missing or invalid file: nothing is ignored. + } + } + const serialized = JSON.stringify(entries.map(e => e.fingerprint).sort()); + if (serialized === this._serialized) { return false; } + this._serialized = serialized; + this._entries = new Map(entries.map(e => [e.fingerprint, e])); + return true; + } + + public dispose(): void { + this._watcher?.dispose(); + this._onDidChange.dispose(); + } + + private async _write(): Promise { + const file = this.filePath; + if (!file) { + throw new Error('Open a workspace folder to ignore findings.'); + } + const content: IgnoreFile = { version: 1, ignored: this.list() }; + await fs.promises.writeFile(file, JSON.stringify(content, null, 2) + '\n', 'utf8'); + this._serialized = JSON.stringify(this.list().map(e => e.fingerprint).sort()); + this._onDidChange.fire(); + } +} diff --git a/src/ctrace/FindingsStore.ts b/src/ctrace/FindingsStore.ts index d9df9ae..b67159c 100644 --- a/src/ctrace/FindingsStore.ts +++ b/src/ctrace/FindingsStore.ts @@ -18,13 +18,22 @@ export class FindingsStore { ? value : null; } - public async save(report: SarifLog): Promise { + public async save(report: SarifLog, savedAt = Date.now()): Promise { const results: SarifResult[] = (report.runs ?? []).flatMap(run => run.results ?? []).map(result => { const location = result.locations?.[0]?.physicalLocation; + const props = result.properties; + const properties = props ? { + cwe: props.cwe, + tags: props.tags, + ctraceTool: props.ctraceTool, + ctraceFingerprint: props.ctraceFingerprint, + ctraceIgnored: props.ctraceIgnored, + } : undefined; return { ruleId: result.ruleId, level: result.level, message: { text: result.message?.text ?? '' }, + ...(properties ? { properties } : {}), locations: location ? [{ physicalLocation: { artifactLocation: { uri: location.artifactLocation?.uri }, region: { startLine: location.region?.startLine }, @@ -32,7 +41,7 @@ export class FindingsStore { }; }); await this.state.update(STORAGE_KEY, { - savedAt: Date.now(), + savedAt, report: { version: '2.1.0', runs: [{ tool: { driver: { name: 'CoreTrace' } }, results }], diff --git a/src/ctrace/StackParser.ts b/src/ctrace/StackParser.ts index 621517d..e9b3056 100644 --- a/src/ctrace/StackParser.ts +++ b/src/ctrace/StackParser.ts @@ -104,13 +104,17 @@ function parseIrReportText(text: string): StackFunction[] { const name = cleanFunctionName(nameToClean) || rawName; const localStack = parseInt(match[2], 10) || 0; const maxStack = parseInt(match[3], 10) || localStack; - const isRecursive = text.includes(`recursive or mutually recursive function detected`) && (text.includes(rawName) || text.includes(name)); + + const nextFunction = text.indexOf('Function:', functionRegex.lastIndex); + const block = text.slice(match.index, nextFunction < 0 ? undefined : nextFunction); + const isRecursive = block.includes('recursive or mutually recursive function detected'); list.push({ name, localStack, maxStack, isRecursive, + exceedsLimit: /exceeds limit of \d+ bytes/.test(block), }); } diff --git a/src/ctrace/WorkspaceScanner.ts b/src/ctrace/WorkspaceScanner.ts index 547c96c..93f4d5c 100755 --- a/src/ctrace/WorkspaceScanner.ts +++ b/src/ctrace/WorkspaceScanner.ts @@ -2,7 +2,8 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; -import type { SarifRun } from '../types/sarif'; +import type { SarifLog } from '../types/sarif'; +import type { StackReport } from '../types/stack'; // ─── Public types ───────────────────────────────────────────────────────────── @@ -13,15 +14,17 @@ export interface WorkspaceFile { hash: string; } -export interface ScanResult { - /** compile_commands.json path if found (in root, via CMake Tools config, or in /build/), else null */ - compileCommandsPath: string | null; - /** All C/C++ source files found in the workspace */ - files: WorkspaceFile[]; - /** Subset of files whose content changed since the last scan */ - changedFiles: WorkspaceFile[]; - /** Cached SARIF runs for unchanged files, keyed by fsPath */ - cachedSarifByFile: Map; +/** Everything a workspace scan publishes for one translation unit. */ +export interface CachedFileAnalysis { + sarif: SarifLog; + stack: StackReport | null; +} + +export interface PartitionResult { + /** Files that are new, modified, or were analysed with other settings. */ + changed: WorkspaceFile[]; + /** Reusable results for the other files, keyed by fsPath. */ + cached: Map; } // ─── File-hash cache ────────────────────────────────────────────────────────── @@ -36,73 +39,144 @@ interface CachedFileMetadata { mtime: number; } -/** - * In-memory cache: fsPath β†’ { hash, size, mtime }. - * Lives for the extension session; cleared on `clearCache()`. - * Stat-based cache greatly reduces I/O and CPU cost on large workspaces by - * skipping expensive hash computation for files whose size and mtime haven't changed. - */ +/** In-memory cache: fsPath β†’ { hash, size, mtime }. */ const _fileCache = new Map(); -// ─── SARIF result cache ──────────────────────────────────────────────────────── +// ─── Analysis result cache ──────────────────────────────────────────────────── -/** - * Cached SARIF runs for individual files, keyed by `fsPath\0hash`. - * Allows us to preserve analysis results for unchanged files when using file-by-file mode. - * When a file is skipped (unchanged), its cached SARIF is merged into the combined result. - */ -const _sarifCache = new Map(); +interface AnalysisCacheEntry { + hash: string; + contextKey: string; + analysis: CachedFileAnalysis; +} + +/** fsPath β†’ last successful analysis. Persisted to `CACHE_FILE_NAME` in the workspace storage. */ +const _analysisCache = new Map(); +const CACHE_FILE_NAME = 'workspace-scan-cache.v1.json'; +let _cacheFile: string | undefined; /** - * Store SARIF runs for a file (keyed by its current hash). - * Called after successfully analyzing a file to cache its results for future runs. + * Loads the analysis cache persisted by a previous session. Call once on + * activation; without a storage directory the cache stays in memory only. */ -export function cacheSarifForFile(fsPath: string, fileHash: string, runs: SarifRun[]): void { - const key = `${fsPath}\0${fileHash}`; - _sarifCache.set(key, runs); +export async function initAnalysisCache(storageDir: string | undefined): Promise { + _cacheFile = storageDir ? path.join(storageDir, CACHE_FILE_NAME) : undefined; + if (!_cacheFile) { return; } + try { + const entries = JSON.parse(await fs.promises.readFile(_cacheFile, 'utf8')); + if (!Array.isArray(entries)) { return; } + for (const [fsPath, entry] of entries) { + if (typeof fsPath === 'string' && typeof entry?.hash === 'string' && + typeof entry?.contextKey === 'string' && Array.isArray(entry?.analysis?.sarif?.runs)) { + _analysisCache.set(fsPath, entry); + } + } + } catch { + // No cache yet, or an unreadable one: start from scratch. + } } -/** - * Retrieve cached SARIF runs for a file by its fsPath and hash. - * Returns undefined if no cached results exist for this exact file version. - */ -export function getCachedSarif(fsPath: string, fileHash: string): SarifRun[] | undefined { - const key = `${fsPath}\0${fileHash}`; - return _sarifCache.get(key); +/** Writes the analysis cache to disk, dropping entries for files that no longer exist. */ +export async function persistAnalysisCache(): Promise { + if (!_cacheFile) { return; } + for (const fsPath of [..._analysisCache.keys()]) { + if (!await fileExists(fsPath)) { _analysisCache.delete(fsPath); } + } + try { + await fs.promises.mkdir(path.dirname(_cacheFile), { recursive: true }); + await fs.promises.writeFile(_cacheFile, JSON.stringify([..._analysisCache]), 'utf8'); + } catch (e) { + console.warn('[ctrace] Could not persist the workspace scan cache:', e); + } +} + +export function cacheFileAnalysis(file: WorkspaceFile, contextKey: string, analysis: CachedFileAnalysis): void { + _analysisCache.set(file.fsPath, { hash: file.hash, contextKey, analysis }); } -export function clearCache(): void { +export async function clearCache(): Promise { _fileCache.clear(); - _sarifCache.clear(); + _analysisCache.clear(); + if (_cacheFile) { + await fs.promises.unlink(_cacheFile).catch(() => { /* already gone */ }); + } } -// ─── Scanner ────────────────────────────────────────────────────────────────── +/** + * Splits `files` into the ones that must be analysed again and the ones whose + * cached result is still valid: same content hash and same `contextKey`. + * Unreadable files are reported as changed so the analysis surfaces the error. + */ +export async function partitionFiles(files: string[], contextKey: string): Promise { + const changed: WorkspaceFile[] = []; + const cached = new Map(); + for (const fsPath of files) { + const hash = await fileContentHash(fsPath); + const entry = _analysisCache.get(fsPath); + if (hash && entry && entry.hash === hash && entry.contextKey === contextKey) { + cached.set(fsPath, entry.analysis); + } else { + changed.push({ fsPath, hash: hash ?? '' }); + } + } + return { changed, cached }; +} -const C_CPP_GLOB = '**/*.{c,cpp,cc,cxx,c++,C}'; +/** + * Summarises everything besides a file's own content that can change its + * analysis result: the given values (ctrace arguments, generated config…) and + * the stat of the given files (binary, compile_commands.json) and of every + * header in the workspace. Header edits therefore invalidate the whole cache, + * since an unchanged .c file may include a changed header. + */ +export async function analysisContextKey(values: unknown[], dependencyFiles: (string | undefined)[]): Promise { + const hash = crypto.createHash('sha1'); + hash.update(JSON.stringify(values)); + const headers = await vscode.workspace.findFiles(HEADER_GLOB, EXCLUDE_GLOB); + const statFiles = [...dependencyFiles.filter((f): f is string => Boolean(f)), ...headers.map(u => u.fsPath).sort()]; + for (const file of statFiles) { + try { + const stat = await fs.promises.stat(file); + hash.update(`\0${file}\0${stat.size}\0${stat.mtimeMs}`); + } catch { + hash.update(`\0${file}\0missing`); + } + } + return hash.digest('hex'); +} + +const HEADER_GLOB = '**/*.{h,hh,hpp,hxx,h++,inl,inc,ipp,tpp}'; /** Directories that should never be scanned (build artifacts, vendored code…) */ const EXCLUDE_GLOB = '{**/node_modules/**,**/build/**,**/out/**,**/dist/**,**/.git/**,**/CMakeFiles/**,**/cmake-build-*/**}'; -/** - * Scans all workspace folders for C/C++ source files and returns: - * - the path of `compile_commands.json` if present at any workspace root - * - the full list of discovered files with their current content hashes - * - only the files that are new or whose content changed since the last call - * - * Skips files it cannot read rather than throwing, so a single unreadable - * file does not abort the entire scan. - */ -export async function scanWorkspace(): Promise { - const folders = vscode.workspace.workspaceFolders; - if (!folders?.length) { - return { compileCommandsPath: null, files: [], changedFiles: [], cachedSarifByFile: new Map() }; +/** sha1 of the file content, reusing the previous hash when size and mtime are unchanged. */ +async function fileContentHash(fsPath: string): Promise { + try { + const stat = await fs.promises.stat(fsPath); + const cached = _fileCache.get(fsPath); + if (cached && cached.size === stat.size && cached.mtime === stat.mtimeMs) { + return cached.hash; + } + const buf = await fs.promises.readFile(fsPath); + const hash = crypto.createHash('sha1').update(buf).digest('hex'); + _fileCache.set(fsPath, { hash, size: stat.size, mtime: stat.mtimeMs }); + return hash; + } catch { + return null; } +} + +// ─── compile_commands.json ──────────────────────────────────────────────────── - // 1 β€” Look for compile_commands.json at every workspace root or build directory - let compileCommandsPath: string | null = null; - for (const folder of folders) { +/** + * Looks for compile_commands.json at every workspace root, in the CMake Tools + * build directory, or in `build/`. Cheap: does not touch the source files. + */ +export async function findCompileCommandsPath(): Promise { + for (const folder of vscode.workspace.workspaceFolders ?? []) { const rootCandidate = path.join(folder.uri.fsPath, 'compile_commands.json'); const buildCandidate = path.join(folder.uri.fsPath, 'build', 'compile_commands.json'); - + // Also check CMake Tools build directory configuration if present let cmakeBuildCandidate: string | null = null; const config = vscode.workspace.getConfiguration('cmake', folder.uri); @@ -114,97 +188,14 @@ export async function scanWorkspace(): Promise { } if (await fileExists(rootCandidate)) { - compileCommandsPath = rootCandidate; - break; + return rootCandidate; } else if (cmakeBuildCandidate && await fileExists(cmakeBuildCandidate)) { - compileCommandsPath = cmakeBuildCandidate; - break; + return cmakeBuildCandidate; } else if (await fileExists(buildCandidate)) { - compileCommandsPath = buildCandidate; - break; - } - } - - // 2 β€” Find all C/C++ source files - const uris = await vscode.workspace.findFiles(C_CPP_GLOB, EXCLUDE_GLOB); - const files: WorkspaceFile[] = []; - const changedFiles: WorkspaceFile[] = []; - const cachedSarifByFile = new Map(); - - for (const uri of uris) { - const fsPath = uri.fsPath; - let hash: string; - let metadata: CachedFileMetadata; - - try { - // Cheap change detection: check file size and mtime first. - // Skip expensive hash computation if stats haven't changed. - const stat = await fs.promises.stat(fsPath); - const currentSize = stat.size; - const currentMtime = stat.mtimeMs; - const cached = _fileCache.get(fsPath); - - if (cached && cached.size === currentSize && cached.mtime === currentMtime) { - // File stat hasn't changed β€” reuse cached hash without re-reading. - hash = cached.hash; - } else { - // File is new or modified β€” read and hash. - const buf = await fs.promises.readFile(fsPath); - hash = crypto.createHash('sha1').update(buf).digest('hex'); - } - - metadata = { hash, size: currentSize, mtime: currentMtime }; - } catch { - // Unreadable file (permission error, race with deletion, …) β€” skip. - continue; + return buildCandidate; } - - const entry: WorkspaceFile = { fsPath, hash }; - files.push(entry); - - const cached = _fileCache.get(fsPath); - if (!cached || cached.hash !== hash) { - changedFiles.push(entry); - } else { - // File is unchanged β€” try to retrieve its cached SARIF results - const cachedSarif = getCachedSarif(fsPath, hash); - if (cachedSarif) { - cachedSarifByFile.set(fsPath, cachedSarif); - } else { - // File hasn't changed but has no cached SARIF (e.g. previous run failed). - // Add it to changedFiles so we retry analysis. - changedFiles.push(entry); - } - } - _fileCache.set(fsPath, metadata); } - - // Remove stale cache entries for files that no longer exist - const currentPaths = new Set(files.map(f => f.fsPath)); - const fileKeysToDelete: string[] = []; - for (const fsPath of _fileCache.keys()) { - if (!currentPaths.has(fsPath)) { - fileKeysToDelete.push(fsPath); - } - } - for (const fsPath of fileKeysToDelete) { - _fileCache.delete(fsPath); - } - - // Also clean up SARIF cache entries for files that no longer exist - // SARIF cache keys are in format "fsPath\0hash" - const sarifKeysToDelete: string[] = []; - for (const cacheKey of _sarifCache.keys()) { - const fsPath = cacheKey.split('\0')[0]; - if (!currentPaths.has(fsPath)) { - sarifKeysToDelete.push(cacheKey); - } - } - for (const cacheKey of sarifKeysToDelete) { - _sarifCache.delete(cacheKey); - } - - return { compileCommandsPath, files, changedFiles, cachedSarifByFile }; + return null; } // ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/src/ctraceRunner.ts b/src/ctraceRunner.ts index 9765fa2..296d038 100755 --- a/src/ctraceRunner.ts +++ b/src/ctraceRunner.ts @@ -96,6 +96,7 @@ export function buildCtraceArgs( args.push('--sarif-format'); args.push(`--report-file=${requiredReportFile(uiState.reportFile)}`); + args.push(...profileArgs(uiState)); const compileCommandsPath = cleanValue(uiState.compileCommandsPath) ?? configString(vscodeConfig, 'compileCommandsPath'); @@ -172,6 +173,15 @@ function configString(config: vscode.WorkspaceConfiguration | undefined, key: st return cleanValue(config?.get(key)); } +/** Reads a boolean setting that may also be "on"/"off"/"default"; "default" means unset. */ +function configTriState(config: vscode.WorkspaceConfiguration | undefined, key: string): boolean | undefined { + const value = config?.get(key); + if (typeof value === 'boolean') { return value; } + if (value === 'on') { return true; } + if (value === 'off') { return false; } + return undefined; +} + function configValue(config: vscode.WorkspaceConfiguration, key: string): string | number | undefined { const value = config.get(key); if (typeof value === 'number') { return Number.isFinite(value) ? value : undefined; } @@ -196,6 +206,12 @@ function compileArgs(value: unknown, prefix?: '-I' | '-D'): string[] { .map(item => `--compile-arg=${prefix ?? ''}${item}`); } +function profileArgs(uiState: CtraceUIState): string[] { + return uiState.analysisProfile === undefined + ? [] + : [`--analysis-profile=${validProfile(uiState.analysisProfile)}`]; +} + function validProfile(value: unknown): AnalysisProfile { return value === 'fast' || value === 'full' ? value : DEFAULT_PROFILE; } @@ -235,7 +251,8 @@ function toInternalPath(p: string): string { if (distroMatch) { return '/' + distroMatch[2] + '/' + distroMatch[3]; } - return normalized; + // ctrace reads the config file inside WSL, where C:\x is /mnt/c/x. + return normalized.replace(/^([a-zA-Z]):(?=\/|$)/, (_match, drive: string) => `/mnt/${drive.toLowerCase()}`); } export function generateConfigFileIfNeeded( @@ -278,8 +295,15 @@ export function generateConfigFileIfNeeded( .map(s => s.replace(/^--only-functions?=/, '').replace(/^--only-func=/, '').trim()) .filter(Boolean); - const onlyDirs = parseList(uiState.onlyDir); - const excludeDirs = parseList(uiState.excludeDir); + // ctrace does not run from the workspace, so a relative directory would + // match nothing: only_dirs would hide every function and exclude_dirs no file. + const resolveDir = (dir: string): string => { + const isAbsolute = path.isAbsolute(dir) || dir.startsWith('/') || /^[a-zA-Z]:/.test(dir); + const resolved = internalRoot && !isAbsolute ? path.posix.join(internalRoot, dir.replace(/\\/g, '/')) : dir; + return toInternalPath(resolved).replace(/(?<=.)\/+$/, ''); + }; + const onlyDirs = parseList(uiState.onlyDir).map(resolveDir); + const excludeDirs = parseList(uiState.excludeDir).map(resolveDir); const includeStl = uiState.includeStl === true; const compdbFast = uiState.compdbFast === true || (vscodeConfig?.get('performance.compdbFast', false) ?? false); const dumpFilter = uiState.dumpFilter === true; @@ -287,7 +311,7 @@ export function generateConfigFileIfNeeded( const compileIrCacheDir = configString(vscodeConfig, 'performance.compileIrCacheDir'); const resourceCrossTu = uiState.resourceCrossTu !== undefined ? uiState.resourceCrossTu - : vscodeConfig?.get('analysis.resourceCrossTu'); + : configTriState(vscodeConfig, 'analysis.resourceCrossTu'); const resourceSummaryDir = configString(vscodeConfig, 'cache.resourceSummaryDir'); const resourceSummaryMemoryOnly = vscodeConfig?.get('cache.memoryOnly', false); @@ -385,7 +409,7 @@ export function buildStackAnalyzerArgs( uiState: CtraceUIState, vscodeConfig?: vscode.WorkspaceConfiguration ): string[] { - const args: string[] = ['--invoke=ctrace_stack_analyzer']; + const args: string[] = ['--invoke=ctrace_stack_analyzer', ...profileArgs(uiState)]; const rawEntryPoints = (uiState.entryPoints ?? []) .flatMap(name => typeof name === 'string' ? name.split(',') : []); @@ -421,5 +445,11 @@ export function buildStackAnalyzerArgs( if (rules) { args.push(`--smt-rules=${rules}`); } } + // The Stack Tree flags the functions over this limit. + if (vscodeConfig) { + const stackLimit = configValue(vscodeConfig, 'analysis.stackLimit'); + if (stackLimit !== undefined) { args.push(`--stack-limit=${stackLimit}`); } + } + return args; } diff --git a/src/extension.ts b/src/extension.ts index d4a5876..b43eaff 100755 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,14 +5,25 @@ import * as path from 'path'; import { SidebarProvider } from './SidebarProvider'; import { locateBinary } from './ctrace/BinaryLocator'; import { buildCommand, isWslAvailable } from './ctrace/CommandBuilder'; -import { runCommand } from './ctrace/AnalysisRunner'; -import { parseSarifOutput, countResults } from './ctrace/SarifParser'; -import { updateDiagnostics } from './ctrace/DiagnosticsManager'; +import { runCommand, DEFAULT_TIMEOUT_MS } from './ctrace/AnalysisRunner'; +import { parseSarifOutput } from './ctrace/SarifParser'; +import { updateDiagnostics, clearDiagnostics, getFindingForDiagnostic, FindingRef } from './ctrace/DiagnosticsManager'; import { buildCtraceArgs, buildStackAnalyzerArgs, createReportPath, createConfigPath, generateConfigFileIfNeeded, shellQuoteArgs, CtraceUIState } from './ctraceRunner'; import { getFunctionSymbols, cleanFunctionName } from './utils/symbolExtractor'; import { CtraceCodeLensProvider } from './codelens/CtraceCodeLensProvider'; import { ensureBinary, isUpdatingBinary, setBinaryUpdateListener } from './ctrace/BinaryUpdater'; -import { clearCache, scanWorkspace as runWorkspaceScan } from './ctrace/WorkspaceScanner'; +import { + clearCache, + findCompileCommandsPath, + initAnalysisCache, + persistAnalysisCache, + partitionFiles, + analysisContextKey, + cacheFileAnalysis, + WorkspaceFile, +} from './ctrace/WorkspaceScanner'; +import { FindingIgnoreList, relativeFindingPath, IGNORE_FILE_NAME } from './ctrace/FindingIgnoreList'; +import { IgnoreFindingCodeActionProvider } from './codelens/IgnoreFindingCodeActionProvider'; import { checkDependencies, installDependencies } from './ctrace/DependencyInstaller'; import { StackManager } from './ctrace/StackManager'; import { parseStackReport, mergeStackReports } from './ctrace/StackParser'; @@ -54,6 +65,53 @@ export function activate(context: vscode.ExtensionContext) { const diagnosticCollection = vscode.languages.createDiagnosticCollection('ctrace'); context.subscriptions.push(diagnosticCollection); + // ── Ignored findings ───────────────────────────────────────────────────── + const ignoreList = new FindingIgnoreList(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath); + context.subscriptions.push(ignoreList); + const isIgnored = (fingerprint: string) => ignoreList.has(fingerprint); + for (const language of ['c', 'cpp', 'objective-c', 'objective-cpp']) { + context.subscriptions.push(vscode.languages.registerCodeActionsProvider( + { language }, + new IgnoreFindingCodeActionProvider(), + { providedCodeActionKinds: IgnoreFindingCodeActionProvider.providedCodeActionKinds } + )); + } + + // SARIF of the latest analysis, per analysed file, so that ignoring a + // finding can refresh the Problems panel and the sidebar without a rerun. + let lastSarifByFile = new Map(); + let republishPending = false; + + async function republishFindings(): Promise { + if (isRunning) { + republishPending = true; + return; + } + if (lastSarifByFile.size > 0) { + clearDiagnostics(diagnosticCollection); + for (const [fp, sarif] of lastSarifByFile) { + updateDiagnostics(sarif, diagnosticCollection, fp, false, { isIgnored }); + } + const merged = mergeSarifDocs([...lastSarifByFile.values()]); + await findingsStore.save(merged).catch(() => { /* best effort */ }); + sidebarProvider.postMessage({ type: 'analysis-result', data: merged, refresh: true }); + return; + } + // Results restored from a previous session: only the sidebar shows them. + const snapshot = findingsStore.get(); + if (!snapshot) { return; } + for (const result of snapshot.report.runs.flatMap(run => run.results ?? [])) { + const fingerprint = result.properties?.ctraceFingerprint; + if (fingerprint) { result.properties = { ...result.properties, ctraceIgnored: ignoreList.has(fingerprint) }; } + } + await findingsStore.save(snapshot.report, snapshot.savedAt).catch(() => { /* best effort */ }); + sidebarProvider.postMessage({ type: 'analysis-result', data: snapshot.report, restored: true, savedAt: snapshot.savedAt, refresh: true }); + } + context.subscriptions.push(ignoreList.onDidChange(() => { void republishFindings(); })); + + // Results of unchanged files from previous workspace scans. + const analysisCacheReady = initAnalysisCache(context.storageUri?.fsPath); + // ── Command: ctrace.runAnalysis ────────────────────────────────────────── // Guard against concurrent invocations (e.g. a second postMessage arriving // while the first analysis is still running, or a keyboard shortcut being @@ -233,19 +291,24 @@ export function activate(context: vscode.ExtensionContext) { if (!effectiveUiState.compileCommandsPath) { try { - const scanRes = await runWorkspaceScan(); - if (scanRes.compileCommandsPath) { + const compileCommandsPath = await findCompileCommandsPath(); + if (compileCommandsPath) { effectiveUiState = { ...effectiveUiState, - compileCommandsPath: scanRes.compileCommandsPath, + compileCommandsPath, }; - output.appendLine(`[ctrace] Auto-detected compilation database: ${scanRes.compileCommandsPath}`); + output.appendLine(`[ctrace] Auto-detected compilation database: ${compileCommandsPath}`); } } catch (e) { console.warn('[ctrace] Could not auto-detect compilation database:', e); } } + const timeoutSeconds = ctraceConfig.get('analysis.timeoutSeconds', DEFAULT_TIMEOUT_MS / 1000); + const timeoutMs = Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 + ? timeoutSeconds * 1000 + : DEFAULT_TIMEOUT_MS; + const autoEntryPoints = uiState.autoEntryPoints === true || ctraceConfig.get('analysis.autoEntryPoints', false); @@ -268,23 +331,68 @@ export function activate(context: vscode.ExtensionContext) { cancellable: true, }, async (progress, token) => { - const step = filesToAnalyze.length > 0 ? 100 / filesToAnalyze.length : 100; const allSarif: any[] = []; const allStackReports: any[] = []; + lastSarifByFile = new Map(); // Clear once before the loop so workspace-mode doesn't wipe // previously collected diagnostics on every file. if (scanWorkspace) { - diagnosticCollection.clear(); + clearDiagnostics(diagnosticCollection); StackManager.instance.clear(); } const configPath = createConfigPath(extensionPath); const hasConfig = generateConfigFileIfNeeded(configPath, effectiveUiState, workspaceRoot, ctraceConfig); + const publishStack = (stackReport: any) => { + allStackReports.push(stackReport); + const unifiedStack = mergeStackReports(allStackReports); + if (unifiedStack) { + StackManager.instance.updateStackData(unifiedStack); + sidebarProvider.postMessage({ + type: 'stack-data', + data: unifiedStack, + }); + } + }; + + // ── Incremental workspace scan ─────────────────────────── + // Reuse the results of files whose content, headers and + // ctrace invocation are unchanged since they were analysed. + const incremental = scanWorkspace && ctraceConfig.get('workspace.incremental', true); + let contextKey = ''; + let queue: WorkspaceFile[] = filesToAnalyze.map(fsPath => ({ fsPath, hash: '' })); + let cachedCount = 0; + if (incremental) { + await analysisCacheReady; + const placeholderState = { + ...effectiveUiState, + reportFile: '', + configFile: hasConfig ? '' : effectiveUiState.configFile, + }; + let configContent = ''; + try { configContent = hasConfig ? fs.readFileSync(configPath, 'utf8') : ''; } catch { /* ignore */ } + contextKey = await analysisContextKey( + [buildCtraceArgs(placeholderState, ctraceConfig), buildStackAnalyzerArgs(placeholderState, ctraceConfig), configContent, timeoutMs], + [ctracePath, effectiveUiState.compileCommandsPath, effectiveUiState.configFile] + ); + const partition = await partitionFiles(filesToAnalyze, contextKey); + queue = partition.changed; + cachedCount = partition.cached.size; + for (const [fp, cached] of partition.cached) { + allSarif.push(cached.sarif); + lastSarifByFile.set(fp, cached.sarif); + updateDiagnostics(cached.sarif, diagnosticCollection, fp, false, { isIgnored }); + if (cached.stack && cached.stack.functions.length > 0) { publishStack(cached.stack); } + } + output.appendLine(`[ctrace] Incremental scan: ${queue.length} file(s) to analyse, ${cachedCount} unchanged file(s) reused from cache.`); + } + const step = queue.length > 0 ? 100 / queue.length : 100; + let hadToolExecutionWarning = false; try { - for (let i = 0; i < filesToAnalyze.length; i++) { + for (let i = 0; i < queue.length; i++) { if (token.isCancellationRequested) { output.appendLine('[ctrace] Analysis cancelled by user.'); vscode.window.showInformationMessage('Ctrace analysis cancelled.'); @@ -297,20 +405,21 @@ export function activate(context: vscode.ExtensionContext) { break; } - const fp = filesToAnalyze[i]; + const fp = queue[i].fsPath; const fileName = path.basename(fp); + const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} unchanged)` : ''; - if (filesToAnalyze.length > 1) { + if (queue.length > 1 || cachedCount > 0) { progress.report({ - message: `(${i + 1}/${filesToAnalyze.length}) ${fileName}`, + message: `(${i + 1}/${queue.length}) ${fileName}${cachedSuffix}`, increment: step, }); } sidebarProvider._view?.webview.postMessage({ type: 'analysis-progress', message: `Analysing ${fileName}`, - detail: `File ${i + 1} of ${filesToAnalyze.length}`, - percent: Math.round((i / filesToAnalyze.length) * 100), + detail: `File ${i + 1} of ${queue.length}${cachedSuffix}`, + percent: Math.round((i / queue.length) * 100), }); // Build & run command for this file. @@ -324,7 +433,7 @@ export function activate(context: vscode.ExtensionContext) { tryDelete(reportPath); output.appendLine(`\n${'─'.repeat(60)}`); - output.appendLine(`[${i + 1}/${filesToAnalyze.length}] ${fileName}`); + output.appendLine(`[${i + 1}/${queue.length}] ${fileName}`); output.appendLine(`$ ${command}`); if (hasConfig) { try { @@ -332,7 +441,7 @@ export function activate(context: vscode.ExtensionContext) { } catch {} } - const result = await runCommand(command, extensionPath, token); + const result = await runCommand(command, extensionPath, token, timeoutMs); const { stdout, stderr, exitCode, killed } = result; if (stdout) { output.appendLine(stdout); } @@ -352,7 +461,7 @@ export function activate(context: vscode.ExtensionContext) { break; } output.appendLine(`[timed out] ${fileName}`); - vscode.window.showWarningMessage(`Analysis of ${fileName} timed out (60s).`); + vscode.window.showWarningMessage(`Analysis of ${fileName} timed out (${timeoutMs / 1000}s). You can raise "ctrace.analysis.timeoutSeconds" in the settings.`); } let reportContent = ''; @@ -366,6 +475,7 @@ export function activate(context: vscode.ExtensionContext) { tryDelete(reportPath); // Extract and publish stack analyzer data + let fileStack: any = null; try { let sourceCode: string | undefined; try { @@ -392,7 +502,7 @@ export function activate(context: vscode.ExtensionContext) { }, ctraceConfig); const { command: stackCmd, tempFiles: stackTemp } = await buildCommand(ctracePath, fp, shellQuoteArgs(stackArgs)); try { - const stackRes = await runCommand(stackCmd, extensionPath, token); + const stackRes = await runCommand(stackCmd, extensionPath, token, timeoutMs); const stackOut = (stackRes.stdout || '') + (stackRes.stderr || ''); stackReport = parseStackReport(stackOut, sourceCode); } finally { @@ -405,15 +515,8 @@ export function activate(context: vscode.ExtensionContext) { } if (stackReport && stackReport.functions.length > 0) { - allStackReports.push(stackReport); - const unifiedStack = mergeStackReports(allStackReports); - if (unifiedStack) { - StackManager.instance.updateStackData(unifiedStack); - sidebarProvider.postMessage({ - type: 'stack-data', - data: unifiedStack, - }); - } + fileStack = stackReport; + publishStack(stackReport); } } catch (e) { console.warn('[StackAnalyzer] Error parsing stack data:', e); @@ -421,7 +524,11 @@ export function activate(context: vscode.ExtensionContext) { if (sarif) { normalizeSarifArtifactUris(sarif, fp); allSarif.push(sarif); - updateDiagnostics(sarif, diagnosticCollection, fp, !scanWorkspace); + lastSarifByFile.set(fp, sarif); + updateDiagnostics(sarif, diagnosticCollection, fp, !scanWorkspace, { isIgnored }); + if (incremental && !killed && queue[i].hash) { + cacheFileAnalysis(queue[i], contextKey, { sarif, stack: fileStack }); + } } else if (!scanWorkspace) { // Single-file mode: report parse failure immediately handleNoResults(stdout, stderr, exitCode); @@ -455,7 +562,7 @@ export function activate(context: vscode.ExtensionContext) { } const merged = mergeSarifDocs(allSarif); - const total = countResults(merged); + const total = countVisibleResults(merged); try { await findingsStore.save(merged); @@ -467,9 +574,9 @@ export function activate(context: vscode.ExtensionContext) { data: merged, }); - const filesSuffix = scanWorkspace && filesToAnalyze.length > 1 + const filesSuffix = (scanWorkspace && filesToAnalyze.length > 1 ? ` across ${filesToAnalyze.length} files` - : ''; + : '') + (cachedCount > 0 ? ` (${cachedCount} unchanged, reused from cache)` : ''); if (total > 0) { vscode.window.showInformationMessage( @@ -502,8 +609,15 @@ export function activate(context: vscode.ExtensionContext) { if (hasConfig) { tryDelete(configPath); } + if (incremental) { + void persistAnalysisCache(); + } isRunning = false; sidebarProvider.postMessage({ type: 'analysis-done' }); + if (republishPending) { + republishPending = false; + void republishFindings(); + } } } ); @@ -521,7 +635,7 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand('ctrace.clearAnalysisCache', async () => { - clearCache(); + await clearCache(); StackManager.instance.clear(); await findingsStore.clear(); sidebarProvider.postMessage({ type: 'clear-results' }); @@ -529,6 +643,80 @@ export function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('ctrace.ignoreFinding', async (arg?: Partial) => { + const finding = isFindingRef(arg) ? arg : await pickFindingAtCursor(); + if (!finding) { return; } + const reason = await vscode.window.showInputBox({ + title: `Ignore ${finding.ruleId ?? 'finding'}`, + prompt: `Why is this finding ignored? (optional) β€” stored in ${IGNORE_FILE_NAME}`, + placeHolder: 'e.g. false positive: the buffer size is checked by the caller', + }); + if (reason === undefined) { return; } + try { + await ignoreList.add({ + fingerprint: finding.fingerprint, + ruleId: finding.ruleId, + file: relativeFindingPath(finding.file, vscode.workspace.workspaceFolders?.[0]?.uri.fsPath), + line: finding.line, + message: finding.message, + ...(reason.trim() ? { reason: reason.trim() } : {}), + }); + } catch (e: any) { + vscode.window.showErrorMessage(`Could not ignore the finding: ${e?.message ?? e}`); + return; + } + const action = await vscode.window.showInformationMessage( + `Finding ignored. It is listed in ${IGNORE_FILE_NAME}.`, + 'Undo' + ); + if (action === 'Undo') { + await ignoreList.remove([finding.fingerprint]); + } + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('ctrace.manageIgnoredFindings', async () => { + const entries = ignoreList.list(); + if (entries.length === 0) { + vscode.window.showInformationMessage('No CoreTrace finding is ignored in this workspace.'); + return; + } + const openFileButton: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon('go-to-file'), + tooltip: `Open ${IGNORE_FILE_NAME}`, + }; + const quickPick = vscode.window.createQuickPick(); + quickPick.title = 'Ignored CoreTrace findings β€” select the ones to restore'; + quickPick.canSelectMany = true; + quickPick.matchOnDetail = true; + quickPick.buttons = [openFileButton]; + quickPick.items = entries.map(entry => ({ + fingerprint: entry.fingerprint, + label: entry.ruleId ?? 'Finding', + description: entry.file ? `${entry.file}${entry.line ? `:${entry.line}` : ''}` : undefined, + detail: [entry.message, entry.reason ? `Reason: ${entry.reason}` : ''].filter(Boolean).join(' β€” '), + })); + quickPick.onDidTriggerButton(async () => { + quickPick.hide(); + if (ignoreList.filePath) { + await vscode.window.showTextDocument(vscode.Uri.file(ignoreList.filePath)); + } + }); + quickPick.onDidAccept(async () => { + const selected = quickPick.selectedItems.map(item => item.fingerprint); + quickPick.hide(); + if (selected.length > 0) { + await ignoreList.remove(selected); + vscode.window.showInformationMessage(`${selected.length} finding${selected.length > 1 ? 's' : ''} restored.`); + } + }); + quickPick.onDidHide(() => quickPick.dispose()); + quickPick.show(); + }) + ); + context.subscriptions.push( vscode.commands.registerCommand('ctrace.focusStackFunction', async (functionName?: string) => { await vscode.commands.executeCommand('workbench.view.extension.ctrace-sidebar-view'); @@ -588,6 +776,42 @@ export function deactivate() { } // ─── Helpers ───────────────────────────────────────────────────────────────── +function isFindingRef(value: unknown): value is FindingRef { + const v = value as Partial | undefined; + return typeof v?.fingerprint === 'string' && v.fingerprint.length > 0 && + typeof v.file === 'string' && Number.isFinite(v.line); +} + +/** Used when "Ignore finding" is run from the Command Palette. */ +async function pickFindingAtCursor(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showInformationMessage('Place the cursor on a CoreTrace finding to ignore it.'); + return undefined; + } + const uri = editor.document.uri; + const findings = vscode.languages.getDiagnostics(uri) + .filter(d => d.source?.startsWith('CoreTrace') && d.range.contains(editor.selection.active)) + .map(d => getFindingForDiagnostic(uri, d)) + .filter((f): f is FindingRef => f !== undefined); + if (findings.length === 0) { + vscode.window.showInformationMessage('There is no CoreTrace finding at the cursor.'); + return undefined; + } + if (findings.length === 1) { return findings[0]; } + const picked = await vscode.window.showQuickPick( + findings.map(f => ({ label: f.ruleId ?? 'Finding', detail: f.message, finding: f })), + { title: 'Which finding do you want to ignore?' } + ); + return picked?.finding; +} + +function countVisibleResults(sarif: any): number { + if (!Array.isArray(sarif?.runs)) { return 0; } + return sarif.runs.reduce((sum: number, run: any) => + sum + (run.results ?? []).filter((r: any) => r.properties?.ctraceIgnored !== true).length, 0); +} + function mergeSarifDocs(sarifList: any[]): any { if (!sarifList.length) { return null; } diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index d958e39..138466e 100755 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -39,5 +39,7 @@ suite('Extension Activation & Integration Test Suite', function () { assert.ok(commands.includes('ctrace.clearAnalysisCache'), 'Command ctrace.clearAnalysisCache is missing'); assert.ok(commands.includes('ctrace.installDependencies'), 'Command ctrace.installDependencies is missing'); assert.ok(commands.includes('ctrace.focusStackFunction'), 'Command ctrace.focusStackFunction is missing'); + assert.ok(commands.includes('ctrace.ignoreFinding'), 'Command ctrace.ignoreFinding is missing'); + assert.ok(commands.includes('ctrace.manageIgnoredFindings'), 'Command ctrace.manageIgnoredFindings is missing'); }); }); diff --git a/src/test/findings.test.ts b/src/test/findings.test.ts new file mode 100644 index 0000000..3fda5c6 --- /dev/null +++ b/src/test/findings.test.ts @@ -0,0 +1,259 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { updateDiagnostics, getFindingForDiagnostic, cweIds } from '../ctrace/DiagnosticsManager'; +import { FindingIgnoreList, computeFingerprint, IGNORE_FILE_NAME } from '../ctrace/FindingIgnoreList'; +import { + analysisContextKey, + cacheFileAnalysis, + clearCache, + initAnalysisCache, + partitionFiles, + persistAnalysisCache, +} from '../ctrace/WorkspaceScanner'; + +function tempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function sarifWith(result: Record, tool = 'cppcheck'): any { + return { version: '2.1.0', runs: [{ tool: { driver: { name: tool } }, results: [result] }] }; +} + +function resultAt(file: string, line: number, extra: Record = {}) { + return { + ruleId: 'doubleFree', + level: 'error', + message: { text: "Memory pointed to by 'ptr' is freed twice." }, + locations: [{ physicalLocation: { artifactLocation: { uri: file }, region: { startLine: line, startColumn: 5 } } }], + ...extra, + }; +} + +suite('CWE enrichment Test Suite', () => { + test('cweIds normalises ctrace properties and GitHub-style tags', () => { + assert.deepStrictEqual(cweIds({ cwe: 'CWE-415' }), ['CWE-415']); + assert.deepStrictEqual(cweIds({ cwe: 476 }), ['CWE-476']); + assert.deepStrictEqual(cweIds({ cwe: ['cwe-120', 'CWE-120'], tags: ['external/cwe/cwe-787', 'security'] }), ['CWE-120', 'CWE-787']); + assert.deepStrictEqual(cweIds({ cwe: 'not a cwe' }), []); + assert.deepStrictEqual(cweIds(undefined), []); + }); + + test('diagnostics link to the CWE and name the analyzers', () => { + const dir = tempDir('ctrace-cwe-'); + const file = path.join(dir, 'double_free.c'); + fs.writeFileSync(file, 'int main() {\n free(p);\n free(p);\n}\n'); + const collection = vscode.languages.createDiagnosticCollection('ctrace-test'); + try { + const sarif = sarifWith(resultAt(file, 3, { + properties: { + cwe: 'CWE-415', + alsoReportedBy: [{ tool: 'ctrace_stack_analyzer', ruleId: 'ResourceLifetime.DoubleRelease' }], + }, + })); + updateDiagnostics(sarif, collection, file, true); + const [diagnostic] = collection.get(vscode.Uri.file(file)) ?? []; + assert.ok(diagnostic); + assert.strictEqual(diagnostic.source, 'CoreTrace (cppcheck)'); + const code = diagnostic.code as { value: string; target: vscode.Uri }; + assert.strictEqual(code.value, 'doubleFree Β· CWE-415'); + assert.strictEqual(code.target.toString(), 'https://cwe.mitre.org/data/definitions/415.html'); + assert.match(diagnostic.message, /Also reported by: ctrace_stack_analyzer \(ResourceLifetime\.DoubleRelease\)/); + } finally { + collection.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('a result without CWE keeps the rule id as plain code', () => { + const dir = tempDir('ctrace-cwe-'); + const file = path.join(dir, 'a.c'); + fs.writeFileSync(file, 'x\n'); + const collection = vscode.languages.createDiagnosticCollection('ctrace-test'); + try { + updateDiagnostics(sarifWith(resultAt(file, 1)), collection, file, true); + assert.strictEqual(collection.get(vscode.Uri.file(file))?.[0].code, 'doubleFree'); + } finally { + collection.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +suite('Ignored findings Test Suite', () => { + test('fingerprint ignores the line number but not the source line', () => { + const a = computeFingerprint('rule', 'src/a.c', 'msg', ' strcpy(buf, src);'); + assert.strictEqual(a, computeFingerprint('rule', 'src/a.c', 'msg', 'strcpy(buf, src);')); + assert.notStrictEqual(a, computeFingerprint('rule', 'src/a.c', 'msg', 'strcpy(other, src);')); + assert.notStrictEqual(a, computeFingerprint('rule', 'src/b.c', 'msg', 'strcpy(buf, src);')); + }); + + test('an ignored finding stays ignored when code is inserted above it', () => { + const dir = tempDir('ctrace-ignore-'); + const file = path.join(dir, 'a.c'); + const collection = vscode.languages.createDiagnosticCollection('ctrace-test'); + try { + fs.writeFileSync(file, 'void f() {\n free(p);\n}\n'); + const first = sarifWith(resultAt(file, 2)); + updateDiagnostics(first, collection, file, true); + const fingerprint = first.runs[0].results[0].properties.ctraceFingerprint; + assert.ok(fingerprint); + + fs.writeFileSync(file, '// new comment\n// another\nvoid f() {\n free(p);\n}\n'); + const second = sarifWith(resultAt(file, 4)); + updateDiagnostics(second, collection, file, true, { isIgnored: fp => fp === fingerprint }); + assert.strictEqual(second.runs[0].results[0].properties.ctraceIgnored, true); + assert.strictEqual(collection.get(vscode.Uri.file(file))?.length ?? 0, 0); + } finally { + collection.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('published diagnostics can be mapped back to their finding for the quick fix', () => { + const dir = tempDir('ctrace-ignore-'); + const file = path.join(dir, 'a.c'); + fs.writeFileSync(file, 'x\ny\n'); + const collection = vscode.languages.createDiagnosticCollection('ctrace-test'); + try { + updateDiagnostics(sarifWith(resultAt(file, 2)), collection, file, true); + const published = collection.get(vscode.Uri.file(file))![0]; + // Code actions receive copies, so look up with an equal diagnostic. + const copy = new vscode.Diagnostic(published.range, published.message, published.severity); + const finding = getFindingForDiagnostic(vscode.Uri.file(file), copy); + assert.ok(finding); + assert.strictEqual(finding.ruleId, 'doubleFree'); + assert.strictEqual(finding.line, 2); + assert.strictEqual(finding.file, file); + } finally { + collection.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('ignore list persists entries and tolerates a broken file', async () => { + const dir = tempDir('ctrace-ignore-'); + const list = new FindingIgnoreList(dir); + try { + let changes = 0; + list.onDidChange(() => changes++); + await list.add({ fingerprint: 'abc123def456', ruleId: 'doubleFree', file: 'a.c', line: 3, reason: 'checked by caller' }); + await list.add({ fingerprint: 'abc123def456', ruleId: 'doubleFree' }); + assert.strictEqual(changes, 1); + assert.ok(list.has('abc123def456')); + + const onDisk = JSON.parse(fs.readFileSync(path.join(dir, IGNORE_FILE_NAME), 'utf8')); + assert.strictEqual(onDisk.version, 1); + assert.strictEqual(onDisk.ignored.length, 1); + assert.strictEqual(onDisk.ignored[0].reason, 'checked by caller'); + + const reopened = new FindingIgnoreList(dir); + assert.ok(reopened.has('abc123def456')); + reopened.dispose(); + + await list.remove(['abc123def456']); + assert.ok(!list.has('abc123def456')); + + fs.writeFileSync(path.join(dir, IGNORE_FILE_NAME), '{ not json'); + assert.doesNotThrow(() => list.reload()); + assert.deepStrictEqual(list.list(), []); + } finally { + list.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('ignoring requires a workspace folder', async () => { + const list = new FindingIgnoreList(undefined); + try { + await assert.rejects(list.add({ fingerprint: 'abc123def456' }), /workspace folder/); + } finally { + list.dispose(); + } + }); +}); + +suite('Incremental workspace scan Test Suite', () => { + const emptySarif = (name: string) => ({ version: '2.1.0' as const, runs: [{ tool: { driver: { name } }, results: [] }] }); + + teardown(async () => { + await clearCache(); + await initAnalysisCache(undefined); + }); + + test('only changed files or files analysed with other options are re-analysed', async () => { + const dir = tempDir('ctrace-incr-'); + const a = path.join(dir, 'a.c'); + const b = path.join(dir, 'b.c'); + fs.writeFileSync(a, 'int a;'); + fs.writeFileSync(b, 'int b;'); + try { + let partition = await partitionFiles([a, b], 'key1'); + assert.deepStrictEqual(partition.changed.map(f => f.fsPath), [a, b]); + for (const file of partition.changed) { + cacheFileAnalysis(file, 'key1', { sarif: emptySarif(path.basename(file.fsPath)), stack: null }); + } + + partition = await partitionFiles([a, b], 'key1'); + assert.deepStrictEqual(partition.changed, []); + assert.strictEqual(partition.cached.get(a)?.sarif.runs[0].tool.driver.name, 'a.c'); + + fs.writeFileSync(b, 'int b = 2;'); + partition = await partitionFiles([a, b], 'key1'); + assert.deepStrictEqual(partition.changed.map(f => f.fsPath), [b]); + + partition = await partitionFiles([a, b], 'key2'); + assert.deepStrictEqual(partition.changed.map(f => f.fsPath), [a, b]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('the cache survives a restart and drops deleted files', async () => { + const dir = tempDir('ctrace-incr-'); + const storage = path.join(dir, 'storage'); + const a = path.join(dir, 'a.c'); + const gone = path.join(dir, 'gone.c'); + fs.writeFileSync(a, 'int a;'); + fs.writeFileSync(gone, 'int g;'); + try { + await initAnalysisCache(storage); + for (const file of (await partitionFiles([a, gone], 'key')).changed) { + cacheFileAnalysis(file, 'key', { sarif: emptySarif('x'), stack: null }); + } + fs.unlinkSync(gone); + await persistAnalysisCache(); + + // Simulate a new session: in-memory state is gone, the file stays. + const persisted = fs.readFileSync(path.join(storage, 'workspace-scan-cache.v1.json'), 'utf8'); + await clearCache(); + fs.mkdirSync(storage, { recursive: true }); + fs.writeFileSync(path.join(storage, 'workspace-scan-cache.v1.json'), persisted); + await initAnalysisCache(storage); + + const partition = await partitionFiles([a], 'key'); + assert.deepStrictEqual(partition.changed, []); + assert.ok(partition.cached.has(a)); + assert.ok(!JSON.parse(persisted).some(([p]: [string]) => p === gone)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('context key changes with the options and the dependency files', async () => { + const dir = tempDir('ctrace-incr-'); + const compdb = path.join(dir, 'compile_commands.json'); + fs.writeFileSync(compdb, '[]'); + try { + const base = await analysisContextKey(['--static'], [compdb]); + assert.strictEqual(base, await analysisContextKey(['--static'], [compdb])); + assert.notStrictEqual(base, await analysisContextKey(['--static', '--smt=on'], [compdb])); + fs.writeFileSync(compdb, '[{"file":"a.c"}]'); + assert.notStrictEqual(base, await analysisContextKey(['--static'], [compdb])); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/test/params.integration.test.ts b/src/test/params.integration.test.ts new file mode 100644 index 0000000..ab052e2 --- /dev/null +++ b/src/test/params.integration.test.ts @@ -0,0 +1,204 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runCommand } from '../ctrace/AnalysisRunner'; +import { buildCommand } from '../ctrace/CommandBuilder'; +import { parseSarifOutput } from '../ctrace/SarifParser'; +import { parseStackReport } from '../ctrace/StackParser'; +import { buildCtraceArgs, buildStackAnalyzerArgs, generateConfigFileIfNeeded, shellQuoteArgs, CtraceUIState } from '../ctraceRunner'; + +/** + * Runs each parameter through the extension's code and a real ctrace, to prove + * that ctrace understands it and that it changes the result. + * Needs a binary: set CTRACE_BIN, or put `ctrace` at the repository root. + * Skipped otherwise, which includes CI. + */ +const REPO_BINARY = path.resolve(__dirname, '..', '..', 'ctrace'); +const BIN = process.env.CTRACE_BIN || (fs.existsSync(REPO_BINARY) ? REPO_BINARY : ''); + +const SOURCE = `#include +#include +#include "cfg.h" +int helper(int n) { char b[64]; b[0] = (char)n; return b[0]; } +void leak_one(void) { char *p = malloc(10); p[0] = 1; } +#ifdef ENABLE_BUG +void macro_only_fn(void) { char *q = malloc(4); free(q); free(q); } +#endif +int recurse(int n) { char big[128]; big[0] = (char)n; return n > 0 ? recurse(n - 1) + big[0] : 0; } +int main(void) { char s[BUF_SIZE]; strcpy(s, "x"); leak_one(); return helper(1) + recurse(3) + s[0]; } +`; + +interface Run { + functions: string[]; + limitFunctions: string[]; + rules: string[]; + hasSarif: boolean; + output: string; +} + +suite('Analysis parameters: real ctrace', function () { + this.timeout(90000); + let root = ''; + let file = ''; + + suiteSetup(function () { + if (!BIN || process.platform === 'win32') { this.skip(); } + root = fs.mkdtempSync(path.join(os.tmpdir(), 'ctrace-params-')); + fs.mkdirSync(path.join(root, 'src')); + fs.mkdirSync(path.join(root, 'include')); + fs.writeFileSync(path.join(root, 'include', 'cfg.h'), '#define BUF_SIZE 8\n'); + file = path.join(root, 'src', 'app.c'); + fs.writeFileSync(file, SOURCE); + }); + + suiteTeardown(() => { + if (root) { fs.rmSync(root, { recursive: true, force: true }); } + }); + + /** Same sequence as extension.ts: config file, SARIF pass, stack pass. */ + async function analyse(state: CtraceUIState, config: Record = {}): Promise { + const settings: any = { get: (key: string, fallback?: unknown) => key in config ? config[key] : fallback }; + const full: CtraceUIState = { staticEnabled: true, staticTools: ['ctrace_stack_analyzer'], dynamicEnabled: false, dynamicTools: [], extraIncludes: 'include', ...state }; + const stamp = `${Date.now()}-${Math.random()}`; + const report = path.join(root, `report-${stamp}.json`); + const generated = path.join(root, `config-${stamp}.json`); + const hasConfig = generateConfigFileIfNeeded(generated, full, root, settings); + const configFile = hasConfig ? generated : undefined; + // Not the workspace: like the extension, ctrace must not depend on the cwd. + const cwd = os.tmpdir(); + const sarifRun = await runCommand( + (await buildCommand(BIN, file, shellQuoteArgs(buildCtraceArgs({ ...full, reportFile: report, configFile }, settings)))).command, cwd); + const sarif = await parseSarifOutput(sarifRun.stdout, report); + const stackRun = await runCommand( + (await buildCommand(BIN, file, shellQuoteArgs(buildStackAnalyzerArgs({ ...full, configFile }, settings)))).command, cwd); + const stackOutput = stackRun.stdout + stackRun.stderr; + const stack = parseStackReport(stackOutput, SOURCE); + return { + functions: (stack?.functions ?? []).map(fn => fn.name).sort(), + limitFunctions: (stack?.functions ?? []).filter(fn => fn.exceedsLimit).map(fn => fn.name).sort(), + rules: (sarif?.runs ?? []).flatMap(run => run.results ?? []).map(result => result.ruleId ?? ''), + hasSarif: sarif !== null, + output: sarifRun.stdout + sarifRun.stderr + stackOutput, + }; + } + + const ALL = ['helper', 'leak_one', 'main', 'recurse']; + + test('baseline: the fixture is analysed', async () => { + const run = await analyse({}); + assert.deepStrictEqual(run.functions, ALL); + assert.ok(run.rules.length > 0); + }); + + test('ctrace does reject an unknown config key, so "accepted" below means something', async () => { + const bad = path.join(root, 'bad.json'); + fs.writeFileSync(bad, JSON.stringify({ stack_analyzer: { bogus_key: 1 } })); + const { command } = await buildCommand(BIN, file, shellQuoteArgs([`--config=${bad}`, '--invoke=ctrace_stack_analyzer'])); + const result = await runCommand(command, os.tmpdir()); + assert.notStrictEqual(result.exitCode, 0); + assert.match(result.stdout + result.stderr, /Unknown key/); + }); + + test('include directories make the file compile', async () => { + const run = await analyse({ extraIncludes: '' }); + assert.deepStrictEqual(run.functions, []); + assert.match(run.output, /Compilation failed|file not found/i); + }); + + test('function filter narrows the report', async () => { + assert.deepStrictEqual((await analyse({ onlyFunction: 'helper' })).functions, ['helper']); + }); + + test('entry points narrow the report', async () => { + assert.deepStrictEqual((await analyse({ entryPoints: ['helper'] })).functions, ['helper']); + }); + + test('relative "only directories" keeps the files inside', async () => { + assert.deepStrictEqual((await analyse({ onlyDir: 'src' })).functions, ALL); + }); + + test('relative "only directories" drops the files outside', async () => { + assert.deepStrictEqual((await analyse({ onlyDir: 'vendor' })).functions, []); + }); + + test('relative "exclude directories" skips the files inside', async () => { + assert.deepStrictEqual((await analyse({ excludeDir: 'src' })).functions, []); + }); + + test('macros reach the compiler', async () => { + assert.ok((await analyse({ macros: 'ENABLE_BUG' })).functions.includes('macro_only_fn')); + }); + + test('extra compiler arguments reach the compiler', async () => { + assert.ok((await analyse({ compilerExtraArgs: '-DENABLE_BUG' })).functions.includes('macro_only_fn')); + }); + + test('compile_commands.json provides the flags', async () => { + const database = path.join(root, 'compile_commands.json'); + fs.writeFileSync(database, JSON.stringify([{ directory: root, file, arguments: ['clang', '-c', `-I${root}/include`, '-DENABLE_BUG', file] }])); + try { + assert.ok((await analyse({ extraIncludes: '', compileCommandsPath: database })).functions.includes('macro_only_fn')); + } finally { + fs.rmSync(database, { force: true }); + } + }); + + test('warnings only hides info-level findings', async () => { + const all = await analyse({}); + const warnings = await analyse({ warningsOnly: true }); + assert.ok(warnings.rules.length < all.rules.length, `${warnings.rules.length} vs ${all.rules.length}`); + }); + + test('stack limit adds findings and flags functions in the Stack Tree', async () => { + const run = await analyse({}, { 'analysis.stackLimit': 64 }); + assert.ok(run.rules.includes('StackFrameTooLarge')); + assert.ok(run.limitFunctions.includes('helper'), run.limitFunctions.join(',')); + assert.ok(!run.limitFunctions.includes('leak_one')); + assert.deepStrictEqual((await analyse({})).limitFunctions, []); + }); + + test('timings are printed', async () => { + const plain = await analyse({}); + const timed = await analyse({ timing: true }); + assert.ok(timed.output.length > plain.output.length); + }); + + test('filter decisions are printed', async () => { + const plain = await analyse({}); + const dumped = await analyse({ dumpFilter: true }); + assert.ok(dumped.output.length > plain.output.length); + }); + + test('compile IR cache directory is used', async () => { + const cache = path.join(root, 'ir-cache'); + await analyse({}, { 'performance.compileIrCacheDir': cache }); + assert.ok(fs.readdirSync(cache).length > 0); + }); + + // No visible effect on this small fixture: this proves ctrace accepts them + // and that the analysis still works, not that they change the result. + const accepted: { name: string; state?: CtraceUIState; config?: Record }[] = [ + { name: 'profile fast', state: { analysisProfile: 'fast' } }, + { name: 'profile full', state: { analysisProfile: 'full' } }, + { name: 'SMT with z3', state: { smtEnabled: true, smtBackend: 'z3', smtSecondaryBackend: 'interval', smtMode: 'single', smtTimeoutMs: 500, smtRules: 'recursion' } }, + { name: 'SMT portfolio', state: { smtEnabled: true, smtBackend: 'interval', smtSecondaryBackend: 'interval', smtMode: 'portfolio' } }, + { name: 'system libraries', state: { includeStl: true } }, + { name: 'cross-file on', state: { resourceCrossTu: true } }, + { name: 'cross-file off', state: { resourceCrossTu: false } }, + { name: 'fast compdb', state: { compdbFast: true } }, + { name: 'compdb deps', state: { includeCompdbDeps: true } }, + { name: 'quiet', state: { quiet: true } }, + { name: 'demangle', state: { demangle: true } }, + { name: 'resource summary directory', config: { 'cache.resourceSummaryDir': '/tmp/ctrace-summary-test' } }, + { name: 'memory-only summaries', config: { 'cache.memoryOnly': true } }, + ]; + for (const { name, state, config } of accepted) { + test(`${name} is accepted`, async () => { + const run = await analyse(state ?? {}, config ?? {}); + assert.doesNotMatch(run.output, /Unknown key|failed to load config|unrecognized option/i); + assert.ok(run.hasSarif); + assert.deepStrictEqual(run.functions, ALL); + }); + } +}); diff --git a/src/test/params.test.ts b/src/test/params.test.ts new file mode 100644 index 0000000..2d54b97 --- /dev/null +++ b/src/test/params.test.ts @@ -0,0 +1,266 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildCtraceArgs, buildStackAnalyzerArgs, generateConfigFileIfNeeded, CtraceUIState } from '../ctraceRunner'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const REPORT = '/tmp/report.json'; + +function settings(values: Record = {}): any { + return { get: (key: string, fallback?: unknown) => key in values ? values[key] : fallback }; +} + +function args(state: CtraceUIState, config: Record = {}): string[] { + return buildCtraceArgs({ reportFile: REPORT, ...state }, settings(config)); +} + +/** The `stack_analyzer` section of the generated config, or null when none is needed. */ +function stackConfig(state: CtraceUIState, root: string | undefined = '/work', config: Record = {}): any { + const file = path.join(os.tmpdir(), `ctrace-params-${process.pid}-${Date.now()}-${Math.random()}.json`); + try { + if (!generateConfigFileIfNeeded(file, state, root, settings(config))) { return null; } + return JSON.parse(fs.readFileSync(file, 'utf8')).stack_analyzer; + } finally { + fs.rmSync(file, { force: true }); + } +} + +suite('Analysis parameters: command line', () => { + const cases: { name: string; state: CtraceUIState; expected: string[] }[] = [ + { name: 'profile fast', state: { analysisProfile: 'fast' }, expected: ['--analysis-profile=fast'] }, + { name: 'profile full', state: { analysisProfile: 'full' }, expected: ['--analysis-profile=full'] }, + { name: 'entry points', state: { entryPoints: ['main', 'int helper(int)'] }, expected: ['--entry-points=main,helper'] }, + { name: 'compile commands', state: { compileCommandsPath: '/w/compile_commands.json' }, expected: ['--compile-commands=/w/compile_commands.json'] }, + { name: 'compdb deps', state: { includeCompdbDeps: true }, expected: ['--include-compdb-deps'] }, + { name: 'config file', state: { configFile: '/w/c.json' }, expected: ['--config=/w/c.json'] }, + { name: 'quiet', state: { quiet: true }, expected: ['--quiet'] }, + { name: 'timing', state: { timing: true }, expected: ['--timing'] }, + { name: 'demangle', state: { demangle: true }, expected: ['--demangle'] }, + ]; + for (const { name, state, expected } of cases) { + test(`${name} reaches ctrace`, () => { + const built = args(state); + for (const arg of expected) { assert.ok(built.includes(arg), `${arg} missing from ${built.join(' ')}`); } + }); + } + + test('options left at their default add no flag', () => { + const built = args({ quiet: false, timing: false, demangle: false, includeCompdbDeps: false, smtEnabled: false }); + for (const flag of ['--quiet', '--timing', '--demangle', '--include-compdb-deps', '--smt', '--analysis-profile', '--compile-commands', '--config', '--entry-points']) { + assert.ok(!built.some(arg => arg.startsWith(flag)), `unexpected ${flag}`); + } + }); + + test('an invalid profile falls back to full', () => { + assert.ok(args({ analysisProfile: 'turbo' as any }).includes('--analysis-profile=full')); + }); + + test('SMT options are all sent when SMT is on', () => { + const built = args({ smtEnabled: true, smtBackend: 'z3', smtSecondaryBackend: 'cvc5', smtMode: 'portfolio', smtTimeoutMs: 500, smtRules: 'recursion' }); + for (const arg of ['--smt=on', '--smt-backend=z3', '--smt-secondary-backend=cvc5', '--smt-mode=portfolio', '--smt-timeout-ms=500', '--smt-rules=recursion']) { + assert.ok(built.includes(arg), `${arg} missing`); + } + }); + + test('SMT options are ignored when SMT is off', () => { + const built = args({ smtEnabled: false, smtBackend: 'z3', smtMode: 'portfolio', smtTimeoutMs: 500, smtRules: 'recursion' }); + assert.ok(!built.some(arg => arg.startsWith('--smt'))); + }); + + test('invalid SMT values fall back to safe defaults', () => { + const built = args({ smtEnabled: true, smtBackend: 'evil' as any, smtMode: 'nope' as any, smtTimeoutMs: -1 }); + assert.ok(built.includes('--smt-backend=interval')); + assert.ok(built.includes('--smt-mode=single')); + assert.ok(!built.some(arg => arg.startsWith('--smt-timeout-ms'))); + }); + + test('tools outside the allowed list never reach --invoke', () => { + const built = args({ staticTools: ['cppcheck', 'rm -rf'], invokedTools: ['evil', 'flawfinder'], dynamicEnabled: false }); + assert.ok(built.includes('--invoke=cppcheck,flawfinder')); + }); + + test('turning a family of tools off removes it', () => { + const built = args({ staticEnabled: false, dynamicEnabled: false }); + assert.ok(!built.includes('--static')); + assert.ok(!built.includes('--dyn')); + }); + + test('VS Code settings reach ctrace', () => { + const built = args({}, { + compileCommandsPath: '/s/compile_commands.json', + 'models.escapeModel': '/m/escape', + 'models.bufferModel': '/m/buffer', + 'models.resourceModel': '/m/resource', + 'analysis.stackLimit': 8192, + }); + for (const arg of ['--compile-commands=/s/compile_commands.json', '--escape-model=/m/escape', '--buffer-model=/m/buffer', '--resource-model=/m/resource', '--stack-limit=8192']) { + assert.ok(built.includes(arg), `${arg} missing`); + } + }); + + test('the sidebar value wins over the VS Code setting', () => { + const built = args({ compileCommandsPath: '/ui/db.json' }, { compileCommandsPath: '/s/db.json' }); + assert.ok(built.includes('--compile-commands=/ui/db.json')); + assert.ok(!built.includes('--compile-commands=/s/db.json')); + }); + + test('empty settings add no flag', () => { + const built = args({}, { 'models.escapeModel': ' ', 'analysis.stackLimit': '', compileCommandsPath: '' }); + assert.ok(!built.some(arg => /--(escape-model|stack-limit|compile-commands)/.test(arg))); + }); + + test('the separate stack pass uses the same analysis options', () => { + const state: CtraceUIState = { analysisProfile: 'fast', entryPoints: ['main'], compileCommandsPath: '/w/db.json', configFile: '/w/c.json', smtEnabled: true, smtBackend: 'z3' }; + const stack = buildStackAnalyzerArgs(state, settings({ 'analysis.stackLimit': 4096 })); + for (const arg of ['--analysis-profile=fast', '--entry-points=main', '--compile-commands=/w/db.json', '--config=/w/c.json', '--smt=on', '--smt-backend=z3', '--stack-limit=4096']) { + assert.ok(stack.includes(arg), `${arg} missing from ${stack.join(' ')}`); + } + }); +}); + +suite('Analysis parameters: generated config', () => { + const cases: { name: string; state: CtraceUIState; key: string; expected: unknown }[] = [ + { name: 'function filter', state: { onlyFunction: 'main, helper' }, key: 'only_functions', expected: ['main', 'helper'] }, + { name: 'macros', state: { macros: 'DEBUG,-DVERSION=2' }, key: 'defines', expected: ['DEBUG', 'VERSION=2'] }, + { name: 'compiler arguments', state: { compilerExtraArgs: '-Wall;-O1' }, key: 'compile_args', expected: ['-Wall', '-O1'] }, + { name: 'system libraries', state: { includeStl: true }, key: 'include_stl', expected: true }, + { name: 'fast compdb', state: { compdbFast: true }, key: 'compdb_fast', expected: true }, + { name: 'filter decisions', state: { dumpFilter: true }, key: 'dump_filter', expected: true }, + { name: 'warnings only', state: { warningsOnly: true }, key: 'warnings_only', expected: true }, + { name: 'cross-file on', state: { resourceCrossTu: true }, key: 'resource_cross_tu', expected: true }, + { name: 'cross-file off', state: { resourceCrossTu: false }, key: 'resource_cross_tu', expected: false }, + ]; + for (const { name, state, key, expected } of cases) { + test(`${name} is written as ${key}`, () => { + assert.deepStrictEqual(stackConfig(state)?.[key], expected); + }); + } + + test('no config file is written when nothing needs one', () => { + assert.strictEqual(stackConfig({}), null); + assert.strictEqual(stackConfig({ includeStl: false, compdbFast: false, warningsOnly: false, onlyFunction: ' ' }), null); + }); + + test('relative directory filters are resolved from the workspace', () => { + const config = stackConfig({ onlyDir: 'src, lib/core/', excludeDir: 'build' }); + assert.deepStrictEqual(config.only_dirs, ['/work/src', '/work/lib/core']); + assert.deepStrictEqual(config.exclude_dirs, ['/work/build']); + }); + + test('absolute directory filters are kept', () => { + assert.deepStrictEqual(stackConfig({ onlyDir: '/abs/src/' }).only_dirs, ['/abs/src']); + }); + + test('Windows directories are converted for WSL', () => { + const config = stackConfig({ onlyDir: 'src', excludeDir: 'D:\\vendor\\lib' }, 'C:\\Users\\me\\proj'); + assert.deepStrictEqual(config.only_dirs, ['/mnt/c/Users/me/proj/src']); + assert.deepStrictEqual(config.exclude_dirs, ['/mnt/d/vendor/lib']); + }); + + test('relative include directories are resolved from the workspace', () => { + const config = stackConfig({ extraIncludes: 'include,-I/abs/inc' }); + assert.ok(config.include_dirs.includes('/work/include')); + assert.ok(config.include_dirs.includes('/abs/inc')); + assert.ok(config.extra_args.includes('-I/work/include')); + }); + + test('VS Code settings are written to the config', () => { + const config = stackConfig({}, '/work', { + 'performance.compdbFast': true, + 'performance.compileIrCacheDir': '/cache/ir', + 'analysis.resourceCrossTu': 'off', + 'cache.resourceSummaryDir': '/cache/summary', + 'cache.memoryOnly': true, + }); + assert.strictEqual(config.compdb_fast, true); + assert.strictEqual(config.compile_ir_cache_dir, '/cache/ir'); + assert.strictEqual(config.resource_cross_tu, false); + assert.strictEqual(config.resource_summary_cache_dir, '/cache/summary'); + assert.strictEqual(config.resource_summary_cache_memory_only, true); + }); + + test('resourceCrossTu "default" leaves the choice to ctrace', () => { + assert.strictEqual(stackConfig({}, '/work', { 'analysis.resourceCrossTu': 'default' }), null); + }); + + test('the sidebar cross-file switch wins over the VS Code setting', () => { + assert.strictEqual(stackConfig({ resourceCrossTu: true }, '/work', { 'analysis.resourceCrossTu': 'off' }).resource_cross_tu, true); + }); + + test('values from a user config file are kept', () => { + const userConfig = path.join(os.tmpdir(), `ctrace-user-${process.pid}-${Date.now()}.json`); + fs.writeFileSync(userConfig, JSON.stringify({ stack_analyzer: { jobs: 4 }, output: { verbose: true } })); + const generated = `${userConfig}.out`; + try { + assert.strictEqual(generateConfigFileIfNeeded(generated, { configFile: userConfig, includeStl: true }, '/work', settings()), true); + const content = JSON.parse(fs.readFileSync(generated, 'utf8')); + assert.strictEqual(content.stack_analyzer.jobs, 4); + assert.strictEqual(content.stack_analyzer.include_stl, true); + assert.strictEqual(content.output.verbose, true); + } finally { + fs.rmSync(userConfig, { force: true }); + fs.rmSync(generated, { force: true }); + } + }); +}); + +suite('Analysis parameters: wiring between the sidebar, the runner and package.json', () => { + const read = (file: string) => fs.readFileSync(path.join(REPO_ROOT, file), 'utf8'); + const mainJs = read('media/main.js'); + const sidebarHtml = read('src/SidebarProvider.ts'); + const runner = read('src/ctraceRunner.ts'); + const readState = mainJs.slice(mainJs.indexOf('function readCtraceUiState()'), mainJs.indexOf('function selectedInvokeTools()')); + const restoreState = mainJs.slice(mainJs.indexOf('function restoreUiState()'), mainJs.indexOf('function saveUiState()')); + const stateKeys = [...readState.matchAll(/^\s+([a-zA-Z]+):/gm)].map(match => match[1]); + + test('the sidebar state is parsed correctly by this test', () => { + assert.ok(stateKeys.length > 20, `only found ${stateKeys.join(',')}`); + }); + + test('every control read by the sidebar exists in its HTML', () => { + const ids = [...readState.matchAll(/(?:valueOf|checked|numberValueOf)\('([^']+)'\)/g)].map(match => match[1]); + assert.ok(ids.length > 15); + for (const id of ids) { + assert.ok(sidebarHtml.includes(`id="${id}"`), `no control with id "${id}"`); + } + }); + + test('every control of the settings panel is read by the sidebar', () => { + const panel = sidebarHtml.slice(sidebarHtml.indexOf('id="settings-view"'), sidebarHtml.indexOf('id="dashboard-results"')); + const ids = [...panel.matchAll(/<(?:input|select)[^>]*\bid="([^"]+)"/g)].map(match => match[1]).filter(id => id !== 'settings-search'); + assert.ok(ids.length > 15); + for (const id of ids) { + assert.ok(readState.includes(`'${id}'`), `"${id}" is shown but never sent to the analysis`); + } + }); + + test('every value sent by the sidebar is restored after a reload', () => { + const derived = new Set(['scanMode', 'staticEnabled', 'dynamicEnabled', 'invokedTools']); + for (const key of stateKeys.filter(k => !derived.has(k))) { + assert.ok(restoreState.includes(`saved.${key}`), `${key} is lost after a reload`); + } + }); + + test('every value sent by the sidebar is used by the runner', () => { + const consumers = runner + read('src/extension.ts'); + // scanMode travels next to uiState, as a command argument. + for (const key of stateKeys.filter(k => k !== 'scanMode')) { + assert.ok(consumers.includes(`State.${key}`) || consumers.includes(`uiState?.${key}`), `${key} is sent by the sidebar but ignored by the runner`); + } + }); + + test('every declared setting is read by the code, and the reverse', () => { + const declared = Object.keys(JSON.parse(read('package.json')).contributes.configuration.properties) + .map(key => key.replace(/^ctrace\./, '')); + const sources = runner + read('src/extension.ts'); + for (const key of declared) { + assert.ok(sources.includes(`'${key}'`), `ctrace.${key} is declared but never read`); + } + const readKeys = [...runner.matchAll(/config(?:String|Value|TriState)\((?:vscodeConfig|config), '([^']+)'\)/g)].map(match => match[1]); + assert.ok(readKeys.length > 5); + for (const key of readKeys) { + assert.ok(declared.includes(key), `ctrace.${key} is read but not declared in package.json`); + } + }); +}); diff --git a/src/test/runner.test.ts b/src/test/runner.test.ts new file mode 100644 index 0000000..13b7546 --- /dev/null +++ b/src/test/runner.test.ts @@ -0,0 +1,129 @@ +import * as assert from 'assert'; +import * as cp from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { runCommand } from '../ctrace/AnalysisRunner'; +import { buildCommand } from '../ctrace/CommandBuilder'; +import { updateDiagnostics } from '../ctrace/DiagnosticsManager'; + +const posixOnly = process.platform === 'win32' ? test.skip : test; + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +suite('AnalysisRunner Test Suite', () => { + test('returns exit code 0 and stdout on success', async () => { + const result = await runCommand('echo hello', os.tmpdir()); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stdout.trim(), 'hello'); + assert.ok(!result.killed); + }); + + test('returns the non-zero exit code of the command', async () => { + const result = await runCommand('exit 3', os.tmpdir()); + assert.strictEqual(result.exitCode, 3); + }); + + posixOnly('timeout kills grandchild processes, not only the shell', async () => { + const pidFile = path.join(os.tmpdir(), `ctrace-test-pid-${process.pid}-${Date.now()}`); + // `cd …;` forces sh to fork instead of exec'ing sleep, like the real command. + const command = `cd /tmp || true; sleep 30 & echo $! > '${pidFile}'; wait`; + const result = await runCommand(command, os.tmpdir(), undefined, 500); + assert.strictEqual(result.killed, true); + assert.match(result.stderr, /timed out after 0.5s/); + + const pid = Number(fs.readFileSync(pidFile, 'utf8').trim()); + fs.unlinkSync(pidFile); + await new Promise(r => setTimeout(r, 200)); + const alive = isAlive(pid); + if (alive) { cp.execSync(`kill -9 ${pid}`); } + assert.strictEqual(alive, false, 'sleep should have been killed with its process group'); + }); + + test('cancellation stops the command and reports killed', async () => { + const source = new vscode.CancellationTokenSource(); + setTimeout(() => source.cancel(), 200); + const started = Date.now(); + const result = await runCommand('sleep 30', os.tmpdir(), source.token); + assert.strictEqual(result.killed, true); + assert.ok(Date.now() - started < 5000); + source.dispose(); + }); +}); + +suite('Native command quoting Test Suite', () => { + posixOnly('shell metacharacters in file paths are not expanded', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctrace-quote-')); + const marker = path.join(dir, 'pwned'); + const bin = path.join(dir, 'fake ctrace'); + // The fake binary prints the --input argument it received. + fs.writeFileSync(bin, '#!/bin/sh\nwhile [ "$1" != "--input" ]; do shift; done\nprintf "%s" "$2"\n'); + const src = path.join(dir, `a$(touch ${marker})"b".c`); + try { + const { command } = await buildCommand(bin, src, ''); + const result = await runCommand(command, dir); + assert.strictEqual(fs.existsSync(marker), false, 'command substitution must not run'); + assert.strictEqual(result.stdout, src); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +suite('DiagnosticsManager Test Suite', () => { + function sarifFor(uri: string, line: number, message: string) { + return { + version: '2.1.0', + runs: [{ + tool: { driver: { name: 'cppcheck' } }, + results: [{ + ruleId: 'rule', + level: 'warning', + message: { text: message }, + locations: [{ physicalLocation: { artifactLocation: { uri }, region: { startLine: line } } }], + }], + }], + }; + } + + test('workspace mode keeps diagnostics reported into a shared header by several files', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctrace-diag-')); + const header = path.join(dir, 'shared.h'); + fs.writeFileSync(header, ''); + const collection = vscode.languages.createDiagnosticCollection('ctrace-test'); + try { + updateDiagnostics(sarifFor(header, 1, 'from a.c'), collection, path.join(dir, 'a.c'), false); + updateDiagnostics(sarifFor(header, 2, 'from b.c'), collection, path.join(dir, 'b.c'), false); + updateDiagnostics(sarifFor(header, 2, 'from b.c'), collection, path.join(dir, 'b.c'), false); + const messages = (collection.get(vscode.Uri.file(header)) ?? []).map(d => d.message).sort(); + assert.deepStrictEqual(messages, ['from a.c', 'from b.c']); + } finally { + collection.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('single-file mode replaces previous diagnostics', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ctrace-diag-')); + const file = path.join(dir, 'a.c'); + fs.writeFileSync(file, ''); + const collection = vscode.languages.createDiagnosticCollection('ctrace-test'); + try { + updateDiagnostics(sarifFor(file, 1, 'old'), collection, file, true); + updateDiagnostics(sarifFor(file, 3, 'new'), collection, file, true); + const messages = (collection.get(vscode.Uri.file(file)) ?? []).map(d => d.message); + assert.deepStrictEqual(messages, ['new']); + } finally { + collection.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/test/stackParser.test.ts b/src/test/stackParser.test.ts index dd26d6e..bb2c4c5 100644 --- a/src/test/stackParser.test.ts +++ b/src/test/stackParser.test.ts @@ -2,6 +2,27 @@ import * as assert from 'assert'; import { parseStackReport, mergeStackReports } from '../ctrace/StackParser'; suite('StackParser Test Suite', () => { + test('flags only the recursive functions in a plaintext report', () => { + const report = [ + 'Function: loop ', '\tlocal stack: 16 bytes', '\tmax stack (including callees): 32 bytes', + '\t[ !Info! ] recursive or mutually recursive function detected', '', + 'Function: plain ', '\tlocal stack: 16 bytes', '\tmax stack (including callees): 16 bytes', '', + ].join('\n'); + const parsed = parseStackReport(report); + assert.deepStrictEqual(parsed?.functions.map(fn => [fn.name, fn.isRecursive]), [['loop', true], ['plain', false]]); + }); + + test('flags only the functions over the stack limit in a plaintext report', () => { + const report = [ + 'Mode: IR', '', + 'Function: big ', '\tlocal stack: 80 bytes', '\tmax stack (including callees): 80 bytes', + '\tat line 5, column 46', '\t[!!!Error] potential stack overflow: exceeds limit of 64 bytes', '', '', + 'Function: small ', '\tlocal stack: 16 bytes', '\tmax stack (including callees): 16 bytes', '', + ].join('\n'); + const parsed = parseStackReport(report); + assert.deepStrictEqual(parsed?.functions.map(fn => [fn.name, fn.exceedsLimit]), [['big', true], ['small', false]]); + }); + test('parses JSON stack analyzer report with functions and stack sizes', () => { const jsonReport = JSON.stringify({ meta: { diff --git a/src/test/stackTree.test.ts b/src/test/stackTree.test.ts new file mode 100644 index 0000000..7ce4f6a --- /dev/null +++ b/src/test/stackTree.test.ts @@ -0,0 +1,145 @@ +import * as assert from 'assert'; + +// Plain script shared with the webview (no typings). +// eslint-disable-next-line @typescript-eslint/no-var-requires +const lib = require('../../media/stackTree.js'); + +function report(fns: Record) { + const names = Object.keys(fns); + return { + peakStack: Math.max(...names.map(n => fns[n].peak)), + recursiveCount: 0, + functions: names.map(name => ({ name, localStack: 8, maxStack: fns[name].peak, isRecursive: false, file: fns[name].file })), + callGraph: { + nodes: names.map(name => ({ id: name, name, localStack: 8, maxStack: fns[name].peak, isRecursive: false })), + edges: names.flatMap(from => (fns[from].calls ?? []).map(to => ({ from, to }))), + chains: [], + }, + }; +} + +const visibleIds = (result: any) => result.rows.filter((r: any) => r.kind === 'function').map((r: any) => r.id); + +suite('Stack call tree Test Suite', () => { + const app = report({ + helper: { peak: 10, calls: ['leaf'] }, + main: { peak: 100, calls: ['parse', 'helper'] }, + parse: { peak: 90, calls: ['leaf'] }, + leaf: { peak: 40 }, + unused: { peak: 500 }, + }); + + test('main comes first, callees are sorted by peak, unlinked functions are grouped', () => { + const model = lib.createModel(app); + assert.deepStrictEqual(model.roots, ['main']); + assert.deepStrictEqual(model.children.get('main'), ['parse', 'helper']); + assert.deepStrictEqual(model.standalone, ['unused']); + assert.strictEqual(model.callCount, 4); + + const { rows } = lib.flattenTree(model, { expanded: new Set(['main']) }); + assert.deepStrictEqual(rows.map((r: any) => r.kind === 'group' ? `group:${r.count}` : r.id), ['main', 'parse', 'helper', 'group:1']); + assert.deepStrictEqual(rows.map((r: any) => r.depth), [0, 1, 1, 0]); + }); + + test('hot path follows the heaviest callee, and only those rows are marked', () => { + const model = lib.createModel(app); + assert.deepStrictEqual(model.hotPath, ['main', 'parse', 'leaf']); + const { rows } = lib.flattenTree(model, { expanded: lib.allExpanded(model) }); + const hot = rows.filter((r: any) => r.hot).map((r: any) => r.key); + assert.deepStrictEqual(hot, ['main', 'main\nparse', 'main\nparse\nleaf']); + // `leaf` is also reached through `helper`, but that row is not on the path. + assert.strictEqual(rows.filter((r: any) => r.id === 'leaf').length, 2); + }); + + test('the default layout opens the path to the hottest function', () => { + const model = lib.createModel(app); + const ids = visibleIds(lib.flattenTree(model, { expanded: lib.defaultExpanded(model) })); + assert.deepStrictEqual(ids, ['main', 'parse', 'leaf', 'helper']); + }); + + test('recursion ends on a marked row instead of looping forever', () => { + const model = lib.createModel(report({ + main: { peak: 30, calls: ['a'] }, + a: { peak: 20, calls: ['b'] }, + b: { peak: 10, calls: ['a', 'b'] }, + })); + const { rows, truncated } = lib.flattenTree(model, { expanded: lib.allExpanded(model) }); + assert.strictEqual(truncated, false); + assert.deepStrictEqual(rows.filter((r: any) => r.isCycleRef).map((r: any) => r.id).sort(), ['a', 'b']); + assert.ok(rows.filter((r: any) => r.isCycleRef).every((r: any) => !r.expandable)); + }); + + test('a cycle nobody enters is still reachable from a root', () => { + const model = lib.createModel(report({ + ping: { peak: 10, calls: ['pong'] }, + pong: { peak: 20, calls: ['ping'] }, + })); + assert.deepStrictEqual(model.roots, ['pong']); + assert.deepStrictEqual(model.standalone, []); + assert.ok(lib.pathTo(model, 'ping')); + }); + + test('a function that only calls itself is not treated as unlinked', () => { + const model = lib.createModel(report({ fact: { peak: 10, calls: ['fact'] } })); + assert.deepStrictEqual(model.roots, ['fact']); + assert.deepStrictEqual(model.standalone, []); + }); + + test('without any call link, functions are listed without a group', () => { + const model = lib.createModel(report({ a: { peak: 1 }, b: { peak: 2 } })); + const { rows } = lib.flattenTree(model, {}); + assert.deepStrictEqual(rows.map((r: any) => `${r.kind}:${r.id}`), ['function:b', 'function:a']); + }); + + test('a filter keeps the matches and the rows leading to them, all open', () => { + const model = lib.createModel(app); + const result = lib.flattenTree(model, { expanded: new Set(), query: 'LEAF' }); + assert.strictEqual(result.matchCount, 1); + assert.deepStrictEqual(visibleIds(result), ['main', 'parse', 'leaf', 'helper', 'leaf']); + assert.deepStrictEqual(result.rows.filter((r: any) => r.match).map((r: any) => r.key), ['main\nparse\nleaf', 'main\nhelper\nleaf']); + }); + + test('a filter also searches file names', () => { + const model = lib.createModel(report({ a: { peak: 1, file: '/src/net/socket.c' }, b: { peak: 2 } })); + assert.deepStrictEqual(visibleIds(lib.flattenTree(model, { query: 'socket' })), ['a']); + }); + + test('a densely connected graph is cut off instead of freezing the sidebar', () => { + const fns: Record = {}; + for (let i = 0; i < 40; i++) { + fns[`f${i}`] = { peak: 40 - i, calls: Array.from({ length: 40 - i - 1 }, (_, j) => `f${i + j + 1}`) }; + } + const model = lib.createModel(report(fns)); + const started = Date.now(); + const { rows, truncated } = lib.flattenTree(model, { query: 'f', maxRows: 200 }); + assert.strictEqual(rows.length, 200); + assert.strictEqual(truncated, true); + assert.ok(Date.now() - started < 2000); + }); + + test('ranking lists every function, heaviest first', () => { + const model = lib.createModel(app); + assert.deepStrictEqual(lib.rankedRows(model).map((r: any) => r.id), ['unused', 'main', 'parse', 'leaf', 'helper']); + assert.deepStrictEqual(lib.rankedRows(model, 'par').map((r: any) => r.id), ['parse']); + }); + + test('pathTo gives the rows to open, including the unlinked group', () => { + const model = lib.createModel(app); + assert.deepStrictEqual(lib.pathTo(model, 'leaf'), { key: 'main\nparse\nleaf', open: ['main', 'main\nparse'] }); + assert.deepStrictEqual(lib.pathTo(model, 'unused').open, [lib.STANDALONE_KEY]); + assert.strictEqual(lib.pathTo(model, 'nope'), null); + }); + + test('resolveName finds qualified functions from a CodeLens name', () => { + const model = lib.createModel(report({ 'Worker::run': { peak: 5 }, main: { peak: 1 } })); + assert.strictEqual(lib.resolveName(model, 'main'), 'main'); + assert.strictEqual(lib.resolveName(model, 'run'), 'Worker::run'); + assert.strictEqual(lib.resolveName(model, 'missing'), null); + }); + + test('an empty report does not throw', () => { + const model = lib.createModel({ functions: [], peakStack: 0 }); + assert.deepStrictEqual(lib.flattenTree(model, {}).rows, []); + assert.deepStrictEqual(model.hotPath, []); + }); +}); diff --git a/src/types/sarif.ts b/src/types/sarif.ts index 42042b5..0f578e6 100755 --- a/src/types/sarif.ts +++ b/src/types/sarif.ts @@ -39,6 +39,24 @@ export interface SarifResult { locations?: SarifLocation[]; relatedLocations?: SarifLocation[]; fixes?: SarifFix[]; + partialFingerprints?: Record; + properties?: SarifResultProperties; +} + +/** + * Property bag of a result. `cwe` and `alsoReportedBy` come from ctrace; the + * `ctrace*` keys are added by the extension when results are published. + */ +export interface SarifResultProperties { + cwe?: string | number | (string | number)[]; + tags?: string[]; + alsoReportedBy?: { ruleId?: string; tool?: string }[]; + /** Name of the analyzer that produced the result (SARIF run driver). */ + ctraceTool?: string; + /** Stable id used by the ignore list (see FindingIgnoreList). */ + ctraceFingerprint?: string; + ctraceIgnored?: boolean; + [key: string]: unknown; } export interface SarifMessage {