@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34
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.
- package/client/linked-findings.js +5 -4
- package/common/bundle-metadata.js +4 -3
- package/common/bundle-source-links.d.ts +2 -1
- package/common/bundle-source-links.js +133 -6
- package/common/bundle-sources.d.ts +2 -0
- package/common/bundle-sources.js +2 -4
- package/common/managed/repository-alias.ts +65 -0
- package/common/managed/routes.js +20 -1
- package/common/managed/triage.ts +2 -2
- package/out/client-managed-import.js +51 -46
- package/out/client-managed.js +252 -72
- package/out/client-sync.js +14 -14
- package/out/graph.js +11 -11
- package/out/index.html +1 -0
- package/out/terminal.js +169 -169
- package/out/view.css +1 -1
- package/out/view.js +417 -201
- package/package.json +23 -17
- package/server-managed/activity.ts +15 -1
- package/server-managed/brotli.ts +3 -3
- package/server-managed/bundle-advisories.ts +8 -3
- package/server-managed/bundle-advisory-inventory.ts +27 -25
- package/server-managed/bundle-build-worker.js +45 -9
- package/server-managed/bundle-build.ts +35 -11
- package/server-managed/bundle-cache.ts +1 -1
- package/server-managed/bundle.ts +17 -1
- package/server-managed/config.ts +7 -2
- package/server-managed/db-methods.ts +170 -49
- package/server-managed/db-neon.ts +59 -4
- package/server-managed/db-schema.ts +12 -3
- package/server-managed/db.ts +9 -1
- package/server-managed/github-issue-links.ts +127 -0
- package/server-managed/github-issues.ts +1 -1
- package/server-managed/http-response.ts +7 -1
- package/server-managed/http.ts +248 -80
- package/server-managed/index.ts +4 -0
- package/server-managed/link-reports.ts +158 -0
- package/server-managed/managed-issues.ts +31 -3
- package/server-managed/management.ts +17 -4
- package/server-managed/object-storage-disk.ts +7 -3
- package/server-managed/object-storage-vercel.ts +6 -2
- package/server-managed/object-storage.ts +3 -2
- package/server-managed/public-workspace.ts +5 -2
- package/server-managed/report-sources.ts +6 -5
- package/server-managed/report-store.ts +47 -0
- package/server-managed/repository-aliases.ts +91 -0
- package/server-managed/revisions.ts +2 -2
- package/server-managed/sql.ts +3 -2
- package/server-managed/storage-encryption.ts +72 -6
- package/server-managed/storage-maintenance.ts +64 -49
- package/server-managed/storage-payload.ts +11 -10
- package/server-managed/storage-stores.ts +3 -1
- package/server-managed/storage.ts +17 -0
- package/server-managed/team-feed.ts +3 -3
- package/server-managed/team-reports.ts +24 -15
- package/server-managed/upstream-cache.ts +73 -0
- 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
|
-
// [[
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
199
|
+
const bundleLinks = new WeakMap()
|
|
76
200
|
|
|
77
201
|
export function bundleSourceLinkResolver(details, parent) {
|
|
78
202
|
const sources = bundleSourcesAsMap(details)
|
|
79
|
-
let
|
|
80
|
-
if (!
|
|
81
|
-
|
|
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>>
|
package/common/bundle-sources.js
CHANGED
|
@@ -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
|
|
237
|
-
//
|
|
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
|
+
}
|
package/common/managed/routes.js
CHANGED
|
@@ -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'
|
|
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)
|
package/common/managed/triage.ts
CHANGED
|
@@ -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:
|