@preventive/triage 1.0.0-alpha.33 → 1.0.0-alpha.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +320 -212
  16. package/out/view.css +1 -1
  17. package/out/view.js +410 -202
  18. package/package.json +22 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/bundle-advisories.ts +8 -3
  21. package/server-managed/bundle-advisory-inventory.ts +27 -25
  22. package/server-managed/bundle-build-worker.js +45 -9
  23. package/server-managed/bundle-build.ts +35 -11
  24. package/server-managed/bundle-cache.ts +1 -1
  25. package/server-managed/bundle.ts +17 -1
  26. package/server-managed/config.ts +5 -0
  27. package/server-managed/db-methods.ts +168 -49
  28. package/server-managed/db-neon.ts +59 -4
  29. package/server-managed/db-schema.ts +12 -3
  30. package/server-managed/db.ts +9 -1
  31. package/server-managed/github-issue-links.ts +127 -0
  32. package/server-managed/github-issues.ts +1 -1
  33. package/server-managed/http-response.ts +7 -1
  34. package/server-managed/http.ts +248 -80
  35. package/server-managed/index.ts +4 -0
  36. package/server-managed/link-reports.ts +158 -0
  37. package/server-managed/managed-issues.ts +31 -3
  38. package/server-managed/management.ts +17 -4
  39. package/server-managed/public-workspace.ts +5 -2
  40. package/server-managed/report-sources.ts +6 -5
  41. package/server-managed/repository-aliases.ts +91 -0
  42. package/server-managed/revisions.ts +2 -2
  43. package/server-managed/sql.ts +3 -2
  44. package/server-managed/storage.ts +17 -0
  45. package/server-managed/team-feed.ts +3 -3
  46. package/server-managed/team-reports.ts +24 -15
  47. package/server-managed/upstream-cache.ts +73 -0
  48. package/server-managed/workspace-shares.ts +16 -12
@@ -1,7 +1,8 @@
1
1
  // The "links" file — a small JSON document that says which findings
2
2
  // are the SAME finding, reported twice:
3
3
  //
4
- // [[{"id":"a"},{"id":"b"}], [{"id":"c"},{"id":"d"},{"id":"e"}]]
4
+ // [["a", "b"], ["c", "d", "e"]]
5
+ // Entries may also be objects such as {"id":"a"}, for existing exports.
5
6
  //
6
7
  // One inner array per link. Every finding named in it is a duplicate
7
8
  // of every other one in that array, and the file says nothing else:
@@ -30,11 +31,11 @@ import { isLinkableFindingId } from './finding-link.js'
30
31
  // here, never from a document's own content.
31
32
  export const LINKS_KIND = 'links'
32
33
 
33
- // Is this one entry of an inner array — `{"id": "…"}` — as the format
34
- // spells it? Extra fields are allowed and ignored: an exporter that
34
+ // Read a string ID or an existing object entry — `{"id": "…"}`. Extra fields are allowed and ignored: an exporter that
35
35
  // writes the title or the report alongside the id is writing a
36
36
  // superset of this format, not a different one.
37
37
  function entryId(entry) {
38
+ if (typeof entry === 'string') return entry.length > 0 ? entry : null
38
39
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null
39
40
  const { id } = entry
40
41
  return typeof id === 'string' && id.length > 0 ? id : null
@@ -50,7 +51,7 @@ function entryId(entry) {
50
51
  //
51
52
  // Recognition is on SHAPE, and it is strict, because this parser runs
52
53
  // against every dropped file before the report readers get their turn:
53
- // a top-level array, holding only arrays, holding only objects with a
54
+ // a top-level array, holding only arrays, holding string IDs or objects with a
54
55
  // string `id`. Nothing else this app reads is a bare JSON array, so
55
56
  // nothing else can be mistaken for one — and an empty top-level array
56
57
  // is refused rather than claimed, since `[]` is every empty JSON list
@@ -12,7 +12,8 @@ import { bundleFileSizes, bundleSourcesAsMap, bundleUnsizedFiles } from './bundl
12
12
  // out. Either way its sizes are not file sizes, so it is read for its
13
13
  // hashes alone and rebuilt on open.
14
14
  // Version 3 also retains the bundle's repository and package origin metadata.
15
- export const BUNDLE_METADATA_VERSION = 3
15
+ // Version 4 retains dependency repositories as well.
16
+ export const BUNDLE_METADATA_VERSION = 4
16
17
  const INDEX_VERSION = BUNDLE_METADATA_VERSION
17
18
  const hashJobs = new WeakMap()
18
19
  const SOURCE_TABS = new Set(['terminal', 'code', 'search', 'compare'])
@@ -86,7 +87,7 @@ export async function createBundleMetadata(details) {
86
87
  for (const [dir, info] of b.modules) {
87
88
  const target = dir.split('/').includes('node_modules') ? modules : sources
88
89
  Object.defineProperty(target, dir, { enumerable: true, value: {
89
- name: info.name, version: info.version, ecosystem: info.ecosystem,
90
+ name: info.name, version: info.version, ecosystem: info.ecosystem, repo: info.repo,
90
91
  files: Object.fromEntries(Object.keys(info.files).map((path) => [path, null])),
91
92
  } })
92
93
  }
@@ -115,7 +116,7 @@ export function createBundleSummary(details, metadata) {
115
116
  // answer report lookups, but an open rebuilds it for current sizes, line
116
117
  // counts and origin metadata.
117
118
  export function parseBundleMetadata(data, integrity) {
118
- if (![1, 2, INDEX_VERSION].includes(data?.version) || data.integrity !== integrity || !['stasis', 'sourcemap'].includes(data.kind)
119
+ if (![1, 2, 3, INDEX_VERSION].includes(data?.version) || data.integrity !== integrity || !['stasis', 'sourcemap'].includes(data.kind)
119
120
  || !Number.isSafeInteger(data.size) || data.size < 0 || !Array.isArray(data.files)) throw new Error('Invalid bundle metadata')
120
121
  const stale = data.version !== INDEX_VERSION
121
122
  const legacySizes = data.version === 1
@@ -1,5 +1,6 @@
1
1
  import type { BundleDetails } from './bundle-metadata.js'
2
2
 
3
3
  export function bundleSourceImports(details: BundleDetails, sources?: Map<string, string>): Map<string, Map<string, string | null>>
4
- export function sourceLinkResolver(sources: Map<string, string>, parent: string, imports?: Map<string, Map<string, string | null>> | null): (specifier: string) => string | null
4
+ export function bundleSourceLinkImports(details: BundleDetails, sources?: Map<string, string>): Map<string, Map<string, string | null>>
5
+ export function sourceLinkResolver(sources: Map<string, string>, parent: string, imports?: Map<string, Map<string, string | null>> | null, packageFiles?: Map<string, unknown>, packageDirs?: Map<string, string> | null): (specifier: string) => string | null
5
6
  export function bundleSourceLinkResolver(details: BundleDetails, parent: string): (specifier: string) => string | null
@@ -1,6 +1,7 @@
1
- import { bundleSourcesAsMap } from './bundle-sources.js'
1
+ import { bundleFilesAsMap, bundlePackageDirs, bundleSourcesAsMap } from './bundle-sources.js'
2
2
 
3
3
  const indexes = new WeakMap()
4
+ const manifests = new WeakMap()
4
5
 
5
6
  // Normalize only path segments. Keep sourcemap URL roots (webpack:/// etc.)
6
7
  // and leading ../ segments, and never guess extensions or basename matches.
@@ -30,6 +31,102 @@ function buildIndex(sources) {
30
31
  return paths
31
32
  }
32
33
 
34
+ function packageScope(sources, index, parent, packageDir) {
35
+ const normalized = normalizePath(parent)
36
+ if (normalized === null) return null
37
+ const boundary = typeof packageDir === 'string' ? normalizePath(`${packageDir}/package.json`) : null
38
+ let directory = normalized.slice(0, normalized.lastIndexOf('/') + 1)
39
+ let cache = manifests.get(sources)
40
+ if (!cache) { cache = new Map(); manifests.set(sources, cache) }
41
+ for (;;) {
42
+ // A dependency without a captured manifest cannot inherit the app's imports.
43
+ if (directory.endsWith('node_modules/')) return null
44
+ const path = `${directory}package.json`
45
+ if (index.has(path)) {
46
+ if (!cache.has(path)) {
47
+ const content = sources.get(index.get(path))
48
+ let imports = null
49
+ try { imports = typeof content === 'string' ? JSON.parse(content.replace(/^\uFEFF/u, ''))?.imports ?? null : null } catch {}
50
+ cache.set(path, imports && typeof imports === 'object' && !Array.isArray(imports) ? imports : null)
51
+ }
52
+ // The nearest package defines the scope, including absent/invalid imports.
53
+ return { directory, imports: cache.get(path) }
54
+ }
55
+ // Recorded module roots are package boundaries even without a manifest.
56
+ if (path === boundary) return null
57
+ if (!directory || directory === '/' || directory.endsWith('../') || /^[a-z][a-z\d+.-]*:\/\/[^/]*\/?$/iu.test(directory)) return null
58
+ const trimmed = directory.slice(0, -1)
59
+ directory = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
60
+ }
61
+ }
62
+
63
+ function importMapping(imports, specifier) {
64
+ if (!imports) return null
65
+ if (Object.hasOwn(imports, specifier) && !specifier.includes('*')) return { value: imports[specifier], wildcard: null }
66
+ let best = null
67
+ for (const pattern of Object.keys(imports)) {
68
+ const star = pattern.indexOf('*')
69
+ if (!pattern.startsWith('#') || star === -1 || pattern.indexOf('*', star + 1) !== -1) continue
70
+ const prefix = pattern.slice(0, star), suffix = pattern.slice(star + 1)
71
+ if (specifier.length <= prefix.length + suffix.length || !specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue
72
+ // Node's pattern precedence: longest prefix, then longest complete key.
73
+ if (!best || star > best.star || star === best.star && pattern.length > best.pattern.length) best = { pattern, star, suffix }
74
+ }
75
+ return best ? { value: imports[best.pattern], wildcard: specifier.slice(best.star, specifier.length - best.suffix.length) } : null
76
+ }
77
+
78
+ function importTargets(value, wildcard, depth = 0) {
79
+ if (depth > 16) return [null]
80
+ if (typeof value === 'string') return [wildcard === null ? value : value.replaceAll('*', wildcard)]
81
+ // The viewer combines recorded conditions. Conditional/array mappings link
82
+ // only when every possible target agrees; a blocked branch stays blocked.
83
+ if (value && typeof value === 'object') return Object.values(value).flatMap(target => importTargets(target, wildcard, depth + 1))
84
+ return [null]
85
+ }
86
+
87
+ function mappedSource(target, scope, sources, index, recorded, directory) {
88
+ if (typeof target !== 'string' || /[\\\p{Cc}]/u.test(target) || /%2f|%5c/iu.test(target)) return null
89
+ const relative = target.startsWith('./')
90
+ let file = null
91
+ if (relative) {
92
+ let decoded
93
+ try { decoded = decodeURIComponent(target.slice(2)) } catch { return null }
94
+ if (/[\\\p{Cc}]/u.test(decoded) || decoded.split('/').some(part => ['.', '..', 'node_modules'].includes(part.toLowerCase()))) return null
95
+ file = normalizePath(scope.directory + decoded)
96
+ } else if (target.startsWith('../') || target.startsWith('/') || target.startsWith('#') || target.includes(':')) return null
97
+ const keys = new Set([target])
98
+ // Captures may retain the package-relative rewritten spelling or express
99
+ // that same path relative to a nested importing file.
100
+ if (relative) {
101
+ for (const key of recorded?.keys() ?? []) {
102
+ if ((key.startsWith('./') || key.startsWith('../')) && normalizePath(directory + key) === file) keys.add(key)
103
+ }
104
+ }
105
+ let found = false, resolved = null
106
+ for (const key of keys) {
107
+ if (!recorded?.has(key)) continue
108
+ const current = recorded.get(key)
109
+ if (typeof current !== 'string' || !sources.has(current) || found && current !== resolved) return null
110
+ found = true
111
+ resolved = current
112
+ }
113
+ // Exact captured files are useful too, but never guess an extension/index.
114
+ return found ? resolved : relative ? index.get(file) ?? null : null
115
+ }
116
+
117
+ function resolvePackageImport(scope, specifier, sources, index, recorded, directory) {
118
+ const mapping = importMapping(scope?.imports, specifier)
119
+ if (!mapping) return null
120
+ const targets = importTargets(mapping.value, mapping.wildcard)
121
+ let result = null
122
+ for (const target of targets) {
123
+ const resolved = mappedSource(target, scope, sources, index, recorded, directory)
124
+ if (!resolved || result && resolved !== result) return null
125
+ result = resolved
126
+ }
127
+ return result
128
+ }
129
+
33
130
  // Merge conditions before linking. When exporting a restricted source set,
34
131
  // include only visible parents and replace every unavailable target with null:
35
132
  // its path must not leak, and relative fallback must still remain blocked.
@@ -53,17 +150,44 @@ export function bundleSourceImports(details, sources = bundleSourcesAsMap(detail
53
150
  return imports
54
151
  }
55
152
 
56
- export function sourceLinkResolver(sources, parent, imports = null) {
153
+ // Managed previews receive only report-selected bodies, often without the
154
+ // owning manifest. Resolve their quoted # aliases using the full manifest
155
+ // inventory, but export only visible targets (or null), never manifest data.
156
+ // These are candidate strings; Prism still decides which tokens become links.
157
+ export function bundleSourceLinkImports(details, sources = bundleSourcesAsMap(details)) {
158
+ const imports = bundleSourceImports(details, sources)
159
+ if (details?.kind !== 'stasis') return imports
160
+ const packageDirs = bundlePackageDirs(details), packageFiles = bundleFilesAsMap(details)
161
+ for (const [parent, content] of sources) {
162
+ const resolve = sourceLinkResolver(sources, parent, imports, packageFiles, packageDirs)
163
+ let targets = imports.get(parent)
164
+ // Look ahead so a template containing a quoted alias cannot hide that
165
+ // inner candidate. Escaped/multiline strings are not portable paths.
166
+ for (const [, , specifier] of content.matchAll(/(?=(["'`])(#[^\\\r\n]*?)\1)/gu)) {
167
+ if (targets?.has(specifier)) continue
168
+ const target = resolve(specifier)
169
+ if (!targets) { targets = new Map(); imports.set(parent, targets) }
170
+ targets.set(specifier, target)
171
+ }
172
+ }
173
+ return imports
174
+ }
175
+
176
+ export function sourceLinkResolver(sources, parent, imports = null, packageFiles = sources, packageDirs = null) {
57
177
  let index = indexes.get(sources)
58
178
  if (!index) { index = buildIndex(sources); indexes.set(sources, index) }
179
+ let packageIndex = indexes.get(packageFiles)
180
+ if (!packageIndex) { packageIndex = buildIndex(packageFiles); indexes.set(packageFiles, packageIndex) }
59
181
  const recorded = imports?.get(parent)
60
182
  const directory = parent.slice(0, parent.lastIndexOf('/') + 1)
183
+ const scope = imports ? packageScope(packageFiles, packageIndex, parent, packageDirs?.get(parent)) : null
61
184
  return (specifier) => {
62
185
  if (typeof specifier !== 'string' || /\p{Cc}/u.test(specifier)) return null
63
186
  if (recorded?.has(specifier)) {
64
187
  const target = recorded.get(specifier)
65
188
  return typeof target === 'string' && sources.has(target) ? target : null
66
189
  }
190
+ if (specifier.startsWith('#')) return resolvePackageImport(scope, specifier, sources, index, recorded, directory)
67
191
  if (!specifier.startsWith('./') && !specifier.startsWith('../')) return null
68
192
  const name = specifier.slice(specifier.lastIndexOf('/') + 1)
69
193
  const dot = name.lastIndexOf('.')
@@ -72,11 +196,14 @@ export function sourceLinkResolver(sources, parent, imports = null) {
72
196
  }
73
197
  }
74
198
 
75
- const bundleImports = new WeakMap()
199
+ const bundleLinks = new WeakMap()
76
200
 
77
201
  export function bundleSourceLinkResolver(details, parent) {
78
202
  const sources = bundleSourcesAsMap(details)
79
- let imports = bundleImports.get(sources)
80
- if (!imports) { imports = bundleSourceImports(details, sources); bundleImports.set(sources, imports) }
81
- return sourceLinkResolver(sources, parent, imports)
203
+ let links = bundleLinks.get(sources)
204
+ if (!links) {
205
+ links = { imports: bundleSourceImports(details, sources), packageDirs: bundlePackageDirs(details) }
206
+ bundleLinks.set(sources, links)
207
+ }
208
+ return sourceLinkResolver(sources, parent, links.imports, bundleFilesAsMap(details), links.packageDirs)
82
209
  }
@@ -1,3 +1,5 @@
1
1
  import type { BundleDetails } from './bundle-metadata.js'
2
2
  export function bundleSourcesAsMap(details: BundleDetails): Map<string, string>
3
+ export function bundleFilesAsMap(details: BundleDetails): Map<string, string | { format: 'base64'; data: string }>
4
+ export function bundlePackageDirs(details: BundleDetails): Map<string, string> | null
3
5
  export function bundlePackageVersions(details: BundleDetails, paths?: Iterable<string> | null): Map<string, Set<string>>
@@ -233,10 +233,8 @@ export function bundleSourceSizes(details) {
233
233
  //
234
234
  // The package views (overview, treemap, graph) otherwise classify
235
235
  // paths with `bundlePkgOf`'s string heuristic, which only knows
236
- // `node_modules/`/`dependencies/` and buckets everything else by
237
- // top-level dir — collapsing sibling workspace packages under their
238
- // shared parent (`vendor/aws/aws-crt-php` + `vendor/aws/aws-sdk-php`
239
- // both fall under `vendor`). Feeding each path's dir into
236
+ // `node_modules/`/`dependencies/` and treats everything else as own
237
+ // source. Feeding each path's recorded module directory into
240
238
  // `bundlePkgOf` (`packageDir` option) keeps those packages separate.
241
239
  //
242
240
  // Returns null for sourcemap bundles (and anything without parsed
@@ -0,0 +1,65 @@
1
+ export interface RepositoryAliasInput {
2
+ oldRepo: string
3
+ oldPath: string
4
+ repoId: number
5
+ newPath: string
6
+ }
7
+
8
+ function containsPath(parent: string, path: string) {
9
+ return !parent || path === parent || path.startsWith(`${parent}/`)
10
+ }
11
+
12
+ function aliasLocation(alias: RepositoryAliasInput, directory: string) {
13
+ if (containsPath(alias.oldPath, directory)) {
14
+ const suffix = directory.slice(alias.oldPath.length).replace(/^\//u, '')
15
+ return { directory: [alias.newPath, suffix].filter(Boolean).join('/'), filePrefix: '' }
16
+ }
17
+ if (!containsPath(directory, alias.oldPath)) return null
18
+ const filePrefix = alias.oldPath.slice(directory.length).replace(/^\//u, '')
19
+ // This suffix is already in every file path. The destination must preserve
20
+ // it, so mapping needs only a new directory, never a file-path rewrite.
21
+ if (alias.newPath === filePrefix) return { directory: '', filePrefix }
22
+ if (!alias.newPath.endsWith(`/${filePrefix}`)) return null
23
+ return { directory: alias.newPath.slice(0, -filePrefix.length - 1), filePrefix }
24
+ }
25
+
26
+ // Match directory boundaries, keeping any subdirectory below the alias. A
27
+ // shared file prefix can prove a more specific alias applies without changing
28
+ // those files. The most specific applicable source wins, once, never chained.
29
+ export function matchRepositoryAlias(github: string, directory: string, aliases: readonly RepositoryAliasInput[], filePrefix = '') {
30
+ let match: { repoId: number; directory: string } | null = null, specificity = -1
31
+ for (const alias of aliases) {
32
+ if (alias.oldRepo.toLowerCase() !== github.toLowerCase()) continue
33
+ const location = aliasLocation(alias, directory)
34
+ if (!location || !containsPath(location.filePrefix, filePrefix) || alias.oldPath.length <= specificity) continue
35
+ match = { repoId: alias.repoId, directory: location.directory }
36
+ specificity = alias.oldPath.length
37
+ }
38
+ return match
39
+ }
40
+
41
+ // Run before decoding a bundle's inventory: unrelated repos/directories and
42
+ // mappings without the required common suffix cannot benefit from its paths.
43
+ export function repositoryAliasNeedsFilePrefix(github: string, directory: string, aliases: readonly RepositoryAliasInput[]) {
44
+ return aliases.some(alias => alias.oldRepo.toLowerCase() === github.toLowerCase()
45
+ && Boolean(aliasLocation(alias, directory)?.filePrefix))
46
+ }
47
+
48
+ // Whole directory segments only; no source bodies, hashing, or path rewriting.
49
+ // A single file still proves its parent prefix. Stop as soon as none is shared.
50
+ export function commonFileDirectory(paths: Iterable<string>): string {
51
+ let prefix: string[] | undefined
52
+ for (const path of paths) {
53
+ const parts = path.split('/')
54
+ if (parts.some(part => !part || part === '.' || part === '..') || path.includes('\\')) return ''
55
+ parts.pop()
56
+ if (prefix === undefined) prefix = parts
57
+ else {
58
+ let length = 0
59
+ while (length < prefix.length && length < parts.length && prefix[length] === parts[length]) length++
60
+ prefix.length = length
61
+ }
62
+ if (prefix.length === 0) return ''
63
+ }
64
+ return prefix?.join('/') ?? ''
65
+ }
@@ -12,6 +12,7 @@ export const MANAGED_PAGES = Object.freeze({
12
12
  'manage-teams': '/manage/team',
13
13
  'manage-import': '/manage/import',
14
14
  'manage-links': '/manage/links',
15
+ 'manage-deduplication': '/manage/deduplication',
15
16
  'manage-history': '/manage/history',
16
17
  })
17
18
 
@@ -24,6 +25,9 @@ function findingPath(id) {
24
25
  export function managedRoutePath(route) {
25
26
  if (!route) return null
26
27
  if (route.view === 'home') return '/'
28
+ if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
29
+ return /^[A-Za-z0-9_-]+$/u.test(route.teamSlug ?? '') ? `/team/${route.teamSlug}/${route.view.slice(10)}` : null
30
+ }
27
31
  if (route.view === 'bundles') {
28
32
  if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
29
33
  || (route.teamSlug != null && !/^[A-Za-z0-9_-]+$/u.test(route.teamSlug))) return null
@@ -34,7 +38,9 @@ export function managedRoutePath(route) {
34
38
  }
35
39
  if (Object.hasOwn(MANAGED_PAGES, route.view)) {
36
40
  const path = MANAGED_PAGES[route.view]
41
+ if (route.view === 'manage-deduplication' && route.linkId) return `${path}?report=${encodeURIComponent(route.linkId)}`
37
42
  if (route.view === 'manage-bundles' && Number.isSafeInteger(route.createRepoId) && route.createRepoId > 0) return `${path}?createRepo=${route.createRepoId}`
43
+ if (route.view === 'manage-scans' && route.scanMode === 'link') return `${path}?mode=link`
38
44
  if (route.view === 'manage-scans' && route.bundleId) return `${path}?bundle=${encodeURIComponent(route.bundleId)}`
39
45
  return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
40
46
  }
@@ -56,9 +62,11 @@ export function parseManagedRoute(url) {
56
62
  if (view) {
57
63
  const createRepoId = Number(url.searchParams.get('createRepo'))
58
64
  return { view,
65
+ ...(view === 'manage-deduplication' && url.searchParams.get('report') ? { linkId: url.searchParams.get('report') } : {}),
59
66
  ...(view === 'manage-bundles' && Number.isSafeInteger(createRepoId) && createRepoId > 0 ? { createRepoId } : {}),
60
67
  ...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
61
- ...(view === 'manage-scans' && url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {}),
68
+ ...(view === 'manage-scans' ? url.searchParams.get('mode') === 'link' ? { scanMode: 'link' }
69
+ : url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle') } : {} : {}),
62
70
  }
63
71
  }
64
72
  const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?$/u.exec(path)
@@ -66,6 +74,8 @@ export function parseManagedRoute(url) {
66
74
  const bundleTab = bundle[3] ?? 'overview'
67
75
  return BUNDLE_TABS.has(bundleTab) ? { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab } : null
68
76
  }
77
+ const contentList = /^\/team\/([A-Za-z0-9_-]+)\/(reports|bundles)$/u.exec(path)
78
+ if (contentList) return { view: `workspace-${contentList[2]}`, teamSlug: contentList[1] }
69
79
  const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
70
80
  if (!match) return null
71
81
  try {
@@ -82,6 +92,10 @@ export function parseManagedRoute(url) {
82
92
  // URL tokens are exact server-assigned slugs. Application state and API calls
83
93
  // continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
84
94
  export function resolveManagedRoute(route, teams, adminBundles = []) {
95
+ if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
96
+ const matches = teams.filter(team => team.slug === route.teamSlug)
97
+ return matches.length === 1 ? { view: route.view, teamId: matches[0].id } : null
98
+ }
85
99
  if (route.view === 'bundles') {
86
100
  const { teamSlug, bundleSlug, ...rest } = route
87
101
  const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
@@ -105,6 +119,11 @@ export function resolveManagedRoute(route, teams, adminBundles = []) {
105
119
  }
106
120
 
107
121
  export function managedRouteForIds(route, teams, adminBundles = []) {
122
+ if (route.view === 'workspace-reports' || route.view === 'workspace-bundles') {
123
+ const team = teams.find(entry => entry.id === route.teamId)
124
+ const result = { view: route.view, teamSlug: team?.slug }
125
+ return team?.slug && resolveManagedRoute(result, teams) ? result : null
126
+ }
108
127
  if (route.view === 'bundles') {
109
128
  const { teamId, bundleId, ...rest } = route
110
129
  const team = teams.find(entry => entry.id === teamId)
@@ -8,9 +8,9 @@
8
8
  // fields below, or is null for a cleared entry (the server's tombstone); the
9
9
  // client's `ignoredReports` deliberately does NOT ride this wire — the
10
10
  // per-report ignore is a client-local concept keyed by report name.
11
- export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted'
11
+ export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted' | 'ignored'
12
12
 
13
- export const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted']
13
+ export const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted', 'ignored']
14
14
 
15
15
  // One finding's server-side triage entry (also the write shape — writes
16
16
  // replace the whole entry). Absent fields are unset; `flagged` is tri-state: