@@ -52,6 +52,8 @@ import { normalizePathForOS } from "./utilities/normalizePath";
5252
5353export const SERIALIZED_FILE_EXTENSION = ".weaudit" ;
5454const DAY_LOG_FILENAME = ".weauditdaylog" ;
55+ // Special path label used to group entries that have no locations.
56+ const NO_LOCATION_PATH_LABEL = "(no location)" ;
5557
5658/**
5759 * Class representing a WeAudit workspace root. Each root maintains its own set of
@@ -1611,6 +1613,8 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
16111613 private pathToEntryMap : Map < string , FullLocationEntry [ ] > ;
16121614 private pathToEntryMapDirty = true ;
16131615 private locationEntryCache = new WeakMap < FullLocation , FullLocationEntry > ( ) ;
1616+ // Preserve which workspace root produced locationless entries so they can be filtered and saved correctly.
1617+ private entryOriginRoots = new WeakMap < FullEntry , RootPathAndLabel > ( ) ;
16141618
16151619 private treeViewMode : TreeViewMode ;
16161620
@@ -2158,6 +2162,11 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
21582162
21592163 async getCodeToCopyFromLocation ( entry : FullEntry | FullLocationEntry ) : Promise < FromLocationResponse | void > {
21602164 const location = isLocationEntry ( entry ) ? entry . location : entry . locations [ 0 ] ;
2165+ if ( location === undefined ) {
2166+ // Locationless entries have no source code region.
2167+ vscode . window . showErrorMessage ( "weAudit: Cannot copy code for an entry without locations." ) ;
2168+ return ;
2169+ }
21612170 const permalink = await this . getClientPermalink ( location ) ;
21622171 if ( permalink === undefined ) {
21632172 return ;
@@ -2684,6 +2693,11 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
26842693 */
26852694 async copyEntryPermalink ( entry : FullEntry | FullLocationEntry ) : Promise < void > {
26862695 const location = isLocationEntry ( entry ) ? entry . location : entry . locations [ 0 ] ;
2696+ if ( location === undefined ) {
2697+ // Locationless entries have no permalink target.
2698+ vscode . window . showErrorMessage ( "weAudit: Cannot copy a permalink for an entry without locations." ) ;
2699+ return ;
2700+ }
26872701 const remoteAndPermalink = await this . getEntryRemoteAndPermalink ( location ) ;
26882702 if ( remoteAndPermalink === undefined ) {
26892703 return ;
@@ -2696,6 +2710,11 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
26962710 * @param entry The entry to copy the permalinks of
26972711 */
26982712 async copyEntryPermalinks ( entry : FullEntry ) : Promise < void > {
2713+ if ( entry . locations . length === 0 ) {
2714+ // Locationless entries have no permalinks to gather.
2715+ vscode . window . showErrorMessage ( "weAudit: Cannot copy permalinks for an entry without locations." ) ;
2716+ return ;
2717+ }
26992718 const permalinkList = [ ] ;
27002719 for ( const location of entry . locations ) {
27012720 const remoteAndPermalink = await this . getEntryRemoteAndPermalink ( location ) ;
@@ -2750,6 +2769,11 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
27502769 * @param entry The entry to open an issue for
27512770 */
27522771 async openGithubIssue ( entry : FullEntry ) : Promise < void > {
2772+ if ( entry . locations . length === 0 ) {
2773+ // Locationless entries cannot resolve a repository or file target.
2774+ vscode . window . showErrorMessage ( "weAudit: Cannot open a GitHub issue for an entry without locations." ) ;
2775+ return ;
2776+ }
27532777 // open github issue with the issue body with the finding text and permalink
27542778 const title = encodeURIComponent ( entry . label ) ;
27552779
@@ -3338,26 +3362,9 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
33383362 * @returns
33393363 */
33403364 getFilteredEntriesForSaving ( username : string , root : WARoot ) : [ FullEntry [ ] , FullEntry [ ] ] {
3341- const filteredEntries = this . treeEntries . filter ( ( entry ) => {
3342- let inWs = false ;
3343- for ( const location of entry . locations ) {
3344- if ( location . rootPath === root . rootPath ) {
3345- inWs = true ;
3346- break ;
3347- }
3348- }
3349- return entry . author === username && inWs ;
3350- } ) ;
3351- const filteredResolvedEntries = this . resolvedEntries . filter ( ( entry ) => {
3352- let inWs = false ;
3353- for ( const location of entry . locations ) {
3354- if ( location . rootPath === root . rootPath ) {
3355- inWs = true ;
3356- break ;
3357- }
3358- }
3359- return entry . author === username && inWs ;
3360- } ) ;
3365+ // Include locationless entries that originated from this root when saving.
3366+ const filteredEntries = this . treeEntries . filter ( ( entry ) => entry . author === username && this . entryMatchesRoot ( entry , root ) ) ;
3367+ const filteredResolvedEntries = this . resolvedEntries . filter ( ( entry ) => entry . author === username && this . entryMatchesRoot ( entry , root ) ) ;
33613368 return [ filteredEntries , filteredResolvedEntries ] ;
33623369 }
33633370
@@ -3379,10 +3386,11 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
33793386 // create a quick pick to select the entry to add the region to
33803387 const items = this . treeEntries
33813388 . filter ( ( entry ) => {
3382- if ( entry . locations . length === 0 || entry . locations [ 0 ] . rootPath !== locations [ 0 ] . rootPath ) {
3383- return false ;
3389+ if ( entry . locations . length === 0 ) {
3390+ // Allow attaching the first location to entries imported without locations.
3391+ return true ;
33843392 }
3385- return true ;
3393+ return entry . locations [ 0 ] . rootPath === locations [ 0 ] . rootPath ;
33863394 } )
33873395 . map ( ( entry ) => {
33883396 return {
@@ -3528,6 +3536,13 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
35283536 ) ,
35293537 } as FullSerializedData ;
35303538
3539+ // Keep track of which workspace root produced entries with no locations.
3540+ for ( const entry of fullParsedEntries . treeEntries . concat ( fullParsedEntries . resolvedEntries ) ) {
3541+ if ( entry . locations . length === 0 ) {
3542+ this . entryOriginRoots . set ( entry , { rootPath : rootPath , rootLabel : wsRoot . getRootLabel ( ) } ) ;
3543+ }
3544+ }
3545+
35313546 // Normalize all the paths from loaded files. These can come from different OSes with different path
35323547 // conventions. We do a best effort to match them to the current OS format.
35333548 fullParsedEntries . treeEntries . forEach ( ( entry ) => {
@@ -3568,18 +3583,10 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
35683583 . map ( ( selectedConfig ) => selectedConfig . username )
35693584 . includes ( config . username )
35703585 ) {
3571- this . treeEntries = this . treeEntries . filter (
3572- ( entry ) =>
3573- entry . author !== config . username ||
3574- entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) !== - 1 ,
3575- ) ;
3586+ this . treeEntries = this . treeEntries . filter ( ( entry ) => entry . author !== config . username || ! this . entryMatchesConfig ( entry , config ) ) ;
35763587 wsRoot . filterAudited ( config . username ) ;
35773588 wsRoot . filterPartiallyAudited ( config . username ) ;
3578- this . resolvedEntries = this . resolvedEntries . filter (
3579- ( entry ) =>
3580- entry . author !== config . username ||
3581- entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) !== - 1 ,
3582- ) ;
3589+ this . resolvedEntries = this . resolvedEntries . filter ( ( entry ) => entry . author !== config . username || ! this . entryMatchesConfig ( entry , config ) ) ;
35833590 }
35843591
35853592 const newTreeEntries = fullParsedEntries . treeEntries ;
@@ -3596,18 +3603,10 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
35963603 this . resolvedEntries = this . resolvedEntries . concat ( fullParsedEntries . resolvedEntries ) ;
35973604 }
35983605 } else {
3599- this . treeEntries = this . treeEntries . filter (
3600- ( entry ) =>
3601- entry . author !== config . username ||
3602- entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) !== - 1 ,
3603- ) ;
3606+ this . treeEntries = this . treeEntries . filter ( ( entry ) => entry . author !== config . username || ! this . entryMatchesConfig ( entry , config ) ) ;
36043607 wsRoot . filterAudited ( config . username ) ;
36053608 wsRoot . filterPartiallyAudited ( config . username ) ;
3606- this . resolvedEntries = this . resolvedEntries . filter (
3607- ( entry ) =>
3608- entry . author !== config . username ||
3609- entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) !== - 1 ,
3610- ) ;
3609+ this . resolvedEntries = this . resolvedEntries . filter ( ( entry ) => entry . author !== config . username || ! this . entryMatchesConfig ( entry , config ) ) ;
36113610 }
36123611 }
36133612
@@ -3721,18 +3720,10 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
37213720 if ( wsRoot === undefined ) {
37223721 return ;
37233722 }
3724- this . treeEntries = this . treeEntries . filter (
3725- ( entry ) =>
3726- entry . author !== config . username ||
3727- entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) !== - 1 ,
3728- ) ;
3723+ this . treeEntries = this . treeEntries . filter ( ( entry ) => entry . author !== config . username || ! this . entryMatchesConfig ( entry , config ) ) ;
37293724 wsRoot . filterAudited ( config . username ) ;
37303725 wsRoot . filterPartiallyAudited ( config . username ) ;
3731- this . resolvedEntries = this . resolvedEntries . filter (
3732- ( entry ) =>
3733- entry . author !== config . username ||
3734- entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) !== - 1 ,
3735- ) ;
3726+ this . resolvedEntries = this . resolvedEntries . filter ( ( entry ) => entry . author !== config . username || ! this . entryMatchesConfig ( entry , config ) ) ;
37363727 this . markPathMapDirty ( ) ;
37373728 }
37383729
@@ -3928,10 +3919,27 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
39283919
39293920 if ( element === undefined ) {
39303921 const pathLabels = Array . from ( this . pathToEntryMap . keys ( ) ) . sort ( ) ;
3922+ const unlocatedEntries = this . treeEntries . filter ( ( entry ) => entry . locations . length === 0 && this . isEntryVisible ( entry ) ) ;
3923+ if ( unlocatedEntries . length > 0 ) {
3924+ // Keep locationless entries visible in group-by-file mode under a dedicated bucket.
3925+ pathLabels . push ( NO_LOCATION_PATH_LABEL ) ;
3926+ }
39313927 return pathLabels . map ( ( label ) => createPathOrganizer ( label ) ) ;
39323928 } else {
39333929 // get entries with same path as element
39343930 if ( isPathOrganizerEntry ( element ) ) {
3931+ if ( element . pathLabel === NO_LOCATION_PATH_LABEL ) {
3932+ const unlocatedEntries = this . treeEntries . filter ( ( entry ) => entry . locations . length === 0 && this . isEntryVisible ( entry ) ) ;
3933+ if ( this . sortEntriesAlphabetically ) {
3934+ return [ ...unlocatedEntries ] . sort ( ( a , b ) => {
3935+ if ( a . entryType !== b . entryType ) {
3936+ return a . entryType === EntryType . Finding ? - 1 : 1 ;
3937+ }
3938+ return a . label . localeCompare ( b . label ) ;
3939+ } ) ;
3940+ }
3941+ return unlocatedEntries ;
3942+ }
39353943 const entries = this . pathToEntryMap . get ( element . pathLabel ) ?? [ ] ;
39363944 if ( this . sortEntriesAlphabetically ) {
39373945 return [ ...entries ] . sort ( ( a , b ) => {
@@ -3993,7 +4001,7 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
39934001 notes . sort ( ( a , b ) => a . label . localeCompare ( b . label ) ) ;
39944002 }
39954003
3996- return entries . concat ( notes ) . filter ( ( entry ) => this . hasVisibleLocation ( entry ) ) ;
4004+ return entries . concat ( notes ) . filter ( ( entry ) => this . isEntryVisible ( entry ) ) ;
39974005 }
39984006
39994007 /**
@@ -4107,6 +4115,13 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
41074115 }
41084116
41094117 const mainLocation = entry . locations [ 0 ] ;
4118+ if ( mainLocation === undefined ) {
4119+ // Entries imported without locations still appear, but cannot be navigated.
4120+ treeItem . description = `No location (${ entry . author } )` ;
4121+ treeItem . tooltip = `${ entry . author } 's ${ entry . entryType === EntryType . Note ? "note" : "finding" } (no location)` ;
4122+ treeItem . contextValue = entry . entryType === EntryType . Note ? "note" : "finding" ;
4123+ return treeItem ;
4124+ }
41104125
41114126 const basePath = path . basename ( mainLocation . path ) ;
41124127 treeItem . description = basePath + ":" + ( mainLocation . startLine + 1 ) . toString ( ) ;
@@ -4244,6 +4259,51 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
42444259 return false ;
42454260 }
42464261
4262+ /**
4263+ * Returns whether an entry should be shown in the tree view.
4264+ * Locationless entries are scoped to the config root they were loaded from.
4265+ */
4266+ private isEntryVisible ( entry : FullEntry ) : boolean {
4267+ if ( entry . locations . length > 0 ) {
4268+ return this . hasVisibleLocation ( entry ) ;
4269+ }
4270+ const origin = this . entryOriginRoots . get ( entry ) ;
4271+ if ( origin === undefined ) {
4272+ return ! this . workspaces . moreThanOneRoot ( ) && this . workspaces . getSelectedConfigurations ( ) . some ( ( config ) => config . username === entry . author ) ;
4273+ }
4274+ return this . workspaces . getSelectedConfigurations ( ) . some ( ( config ) => config . username === entry . author && config . root . label === origin . rootLabel ) ;
4275+ }
4276+
4277+ /**
4278+ * Returns true when an entry belongs to the config's workspace root.
4279+ * Locationless entries use their recorded origin root.
4280+ */
4281+ private entryMatchesConfig ( entry : FullEntry , config : ConfigurationEntry ) : boolean {
4282+ if ( entry . locations . length > 0 ) {
4283+ return entry . locations . findIndex ( ( loc ) => this . workspaces . getUniqueLabel ( loc . rootPath ) !== config . root . label ) === - 1 ;
4284+ }
4285+ const origin = this . entryOriginRoots . get ( entry ) ;
4286+ if ( origin !== undefined ) {
4287+ return origin . rootLabel === config . root . label ;
4288+ }
4289+ return ! this . workspaces . moreThanOneRoot ( ) ;
4290+ }
4291+
4292+ /**
4293+ * Returns true when an entry should be saved under the provided workspace root.
4294+ * Locationless entries use their recorded origin root.
4295+ */
4296+ private entryMatchesRoot ( entry : FullEntry , root : WARoot ) : boolean {
4297+ if ( entry . locations . length > 0 ) {
4298+ return entry . locations . some ( ( location ) => location . rootPath === root . rootPath ) ;
4299+ }
4300+ const origin = this . entryOriginRoots . get ( entry ) ;
4301+ if ( origin !== undefined ) {
4302+ return origin . rootPath === root . rootPath ;
4303+ }
4304+ return ! this . workspaces . moreThanOneRoot ( ) ;
4305+ }
4306+
42474307 private markPathMapDirty ( ) : void {
42484308 this . pathToEntryMapDirty = true ;
42494309 }
@@ -4288,6 +4348,11 @@ export class CodeMarker implements vscode.TreeDataProvider<TreeEntry> {
42884348 * @param entry the entry to refresh and decorate
42894349 */
42904350 refreshAndDecorateEntry ( entry : FullEntry ) : void {
4351+ if ( entry . locations . length === 0 ) {
4352+ // Ensure locationless entries still trigger a tree refresh.
4353+ this . refreshTree ( ) ;
4354+ return ;
4355+ }
42914356 for ( const loc of entry . locations ) {
42924357 const uri = vscode . Uri . file ( path . join ( loc . rootPath , loc . path ) ) ;
42934358 this . decorateWithUri ( uri ) ;
@@ -4385,6 +4450,12 @@ class DragAndDropController implements vscode.TreeDragAndDropController<TreeEntr
43854450 return ;
43864451 }
43874452
4453+ // Locationless entries cannot accept dropped locations.
4454+ if ( target . locations . length === 0 ) {
4455+ vscode . window . showErrorMessage ( "weAudit: Error moving a location to a finding without locations." ) ;
4456+ return ;
4457+ }
4458+
43884459 // Prevent mixing findings that belong to different workspace roots, because it is a headache to synchronize this.
43894460 if ( target . locations [ 0 ] . rootPath !== locationEntry . location . rootPath ) {
43904461 vscode . window . showErrorMessage (
@@ -4499,6 +4570,12 @@ class DragAndDropController implements vscode.TreeDragAndDropController<TreeEntr
44994570 target = target . parentEntry ;
45004571 }
45014572
4573+ // Entries without locations cannot be merged because we cannot verify roots.
4574+ if ( entry . locations . length === 0 || target . locations . length === 0 ) {
4575+ vscode . window . showErrorMessage ( "weAudit: Error merging findings that do not have locations." ) ;
4576+ return ;
4577+ }
4578+
45024579 // Prevent mixing findings that belong to different workspace roots, because it is a headache to synchronize this.
45034580 if ( target . locations [ 0 ] . rootPath !== entry . locations [ 0 ] . rootPath ) {
45044581 vscode . window . showErrorMessage ( "weAudit: Error merging findings, as this finding is in a different workspace root." ) ;
0 commit comments