ai-install-guard 0.0.0 → 0.2.0

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.
@@ -0,0 +1,362 @@
1
+ const EXCERPT_WIDTH = 100
2
+
3
+ export type Match = {
4
+ /** Where the match starts in the text. */
5
+ index: number
6
+ line: number
7
+ /** The matched text. */
8
+ text: string
9
+ /** A short window of the line around the match, for evidence. */
10
+ excerpt: string
11
+ }
12
+
13
+ /**
14
+ * Every match of `pattern` in `text`, with 1-based line numbers, up to
15
+ * `limit` of those `keep` accepts. Rejected matches are skipped before they
16
+ * count, so no number of decoys can use up the limit.
17
+ */
18
+ export function findAll(text: string, pattern: RegExp, limit = 20, keep?: (index: number) => boolean): Match[] {
19
+ const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`
20
+ const global = new RegExp(pattern.source, flags)
21
+ const starts = lineStarts(text)
22
+ const matches: Match[] = []
23
+ for (const found of text.matchAll(global)) {
24
+ const index = found.index ?? 0
25
+ if (keep !== undefined && !keep(index)) {
26
+ if (found[0].length === 0) global.lastIndex += 1
27
+ continue
28
+ }
29
+ const line = lineAt(starts, index)
30
+ const lineStart = starts[line - 1] as number
31
+ const lineEnd = line < starts.length ? (starts[line] as number) - 1 : text.length
32
+ // Only a window around the match is copied, so a match on a huge line stays cheap.
33
+ const from = Math.max(lineStart, index - EXCERPT_WIDTH)
34
+ const to = Math.min(lineEnd, index + found[0].length + EXCERPT_WIDTH)
35
+ const window = text.slice(from, to)
36
+ const cut = excerpt(window, index - from, found[0].length)
37
+ matches.push({ index, line, text: found[0], excerpt: `${from > lineStart && !cut.startsWith('…') ? '…' : ''}${cut}${to < lineEnd && !cut.endsWith('…') ? '…' : ''}` })
38
+ if (matches.length >= limit) break
39
+ if (found[0].length === 0) global.lastIndex += 1
40
+ }
41
+ return matches
42
+ }
43
+
44
+ /** The first match, or undefined. */
45
+ export function findFirst(text: string, pattern: RegExp): Match | undefined {
46
+ return findAll(text, pattern, 1)[0]
47
+ }
48
+
49
+ /** The 1-based line holding `needle`, or undefined. */
50
+ export function lineOf(text: string, needle: string): number | undefined {
51
+ const index = text.indexOf(needle)
52
+ return index === -1 ? undefined : lineAt(lineStarts(text), index)
53
+ }
54
+
55
+ /** A window of `line` around [start, start + length), trimmed and marked with … where cut. */
56
+ export function excerpt(line: string, start = 0, length = 0): string {
57
+ const trimmed = line.replace(/\r$/, '')
58
+ if (trimmed.length <= EXCERPT_WIDTH) {
59
+ return trimmed.trim()
60
+ }
61
+ const center = start + Math.floor(length / 2)
62
+ const from = Math.max(0, Math.min(center - EXCERPT_WIDTH / 2, trimmed.length - EXCERPT_WIDTH))
63
+ const to = from + EXCERPT_WIDTH
64
+ return `${from > 0 ? '…' : ''}${trimmed.slice(from, to).trim()}${to < trimmed.length ? '…' : ''}`
65
+ }
66
+
67
+ function lineStarts(text: string): number[] {
68
+ const starts = [0]
69
+ for (let i = 0; i < text.length; i += 1) {
70
+ if (text.charCodeAt(i) === 10) starts.push(i + 1)
71
+ }
72
+ return starts
73
+ }
74
+
75
+ function lineAt(starts: readonly number[], index: number): number {
76
+ let low = 0
77
+ let high = starts.length - 1
78
+ while (low < high) {
79
+ const mid = Math.ceil((low + high) / 2)
80
+ if ((starts[mid] as number) <= index) low = mid
81
+ else high = mid - 1
82
+ }
83
+ return low + 1
84
+ }
85
+
86
+ /** `a/b` + `../c` → `c`; undefined when the path climbs out of the root. */
87
+ export function joinPath(dir: string, rel: string): string | undefined {
88
+ const parts = rel.startsWith('/') ? [] : dir.split('/').filter(part => part !== '')
89
+ for (const part of rel.split('/')) {
90
+ if (part === '..') {
91
+ if (parts.length === 0) return undefined
92
+ parts.pop()
93
+ } else if (part !== '.' && part !== '') {
94
+ parts.push(part)
95
+ }
96
+ }
97
+ return parts.join('/')
98
+ }
99
+
100
+ export function dirname(path: string): string {
101
+ const slash = path.lastIndexOf('/')
102
+ return slash === -1 ? '' : path.slice(0, slash)
103
+ }
104
+
105
+ export function basename(path: string): string {
106
+ return path.slice(path.lastIndexOf('/') + 1)
107
+ }
108
+
109
+ export function extension(path: string): string {
110
+ const name = basename(path)
111
+ const dot = name.lastIndexOf('.')
112
+ return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
113
+ }
114
+
115
+ /**
116
+ * `comment` is a comment that fills its line from the start; `trailing-comment`
117
+ * starts after code on its line. The tokenizer can be fooled, and a comment
118
+ * hiding a command is a classic trick, so only a whole-line comment is fully
119
+ * trusted to be inert.
120
+ */
121
+ export type SpanKind = 'code' | 'string' | 'comment' | 'trailing-comment' | 'regex'
122
+
123
+ /**
124
+ * What each character of a source file is: code, inside a string, a
125
+ * comment or (in JavaScript) a regular expression literal. A lightweight
126
+ * tokenizer, good enough to tell a pattern or a remark about `.ssh` from code
127
+ * that opens it.
128
+ */
129
+ export function spanKinds(text: string, language: 'js' | 'python' | 'shell' | 'other'): (index: number) => SpanKind {
130
+ const kinds = new Uint8Array(text.length)
131
+ const CODE = 0
132
+ const STRING = 1
133
+ const COMMENT = 2
134
+ const REGEX = 3
135
+ const TRAILING = 4
136
+ const mark = (from: number, to: number, kind: number): void => {
137
+ kinds.fill(kind, from, Math.min(to, text.length))
138
+ }
139
+ // What ends a line comment: JavaScript also ends one at a lone CR, U+2028
140
+ // and U+2029, Python at a lone CR. Missing one would read a whole
141
+ // CR-only file as one comment.
142
+ const isLineEnd = (char: string | undefined): boolean =>
143
+ char === '\n' || (language !== 'shell' && char === '\r') || (language === 'js' && (char === '\u2028' || char === '\u2029'))
144
+ const lineEndFrom = (from: number): number => {
145
+ let j = from
146
+ while (j < text.length && !isLineEnd(text[j])) j += 1
147
+ return j
148
+ }
149
+ // Whether anything but blanks came earlier on the current line, kept as
150
+ // the scan goes, so telling a whole-line comment from a trailing one is
151
+ // constant time however many comments share a line.
152
+ let lineHasCode = false
153
+ const commentKind = (): number => (lineHasCode ? TRAILING : COMMENT)
154
+
155
+ let i = 0
156
+ let lastSignificant = ''
157
+ let lastWord = ''
158
+ // After a `/` that opened no literal on its line, the rest of that line is
159
+ // not tried again: one pass per line, so `[/[/[/...` cannot stall the scan.
160
+ let noRegexBefore = 0
161
+ // Which `(` were opened by if, while, for or with: after their `)` a
162
+ // statement starts, so a slash there opens a pattern, not a division.
163
+ const parens: boolean[] = []
164
+
165
+ while (i < text.length) {
166
+ const char = text[i] as string
167
+ const next = text[i + 1]
168
+ if (isLineEnd(char)) {
169
+ lineHasCode = false
170
+ i += 1
171
+ continue
172
+ }
173
+ if (language === 'js' && char === '/' && next === '/') {
174
+ const stop = lineEndFrom(i)
175
+ mark(i, stop, commentKind())
176
+ i = stop
177
+ continue
178
+ }
179
+ if (language === 'js' && char === '/' && next === '*') {
180
+ const close = text.indexOf('*/', i + 2)
181
+ const stop = close === -1 ? text.length : close + 2
182
+ mark(i, stop, commentKind())
183
+ // A block comment may end mid-line: what follows on that line is code.
184
+ if (text.slice(i, stop).includes('\n')) lineHasCode = false
185
+ i = stop
186
+ continue
187
+ }
188
+ // In shell, `#` starts a comment only at the start of a word: after an
189
+ // unescaped blank (`echo \ #` keeps the `#` in the word) or a line start.
190
+ const isShellComment = language === 'shell' && (i === 0 || (/\s/.test(text[i - 1] as string) && text[i - 2] !== '\\'))
191
+ if (char === '#' && (language === 'python' || isShellComment)) {
192
+ const stop = lineEndFrom(i)
193
+ mark(i, stop, commentKind())
194
+ i = stop
195
+ continue
196
+ }
197
+ if (/[ \t\f\v]/.test(char)) {
198
+ i += 1
199
+ continue
200
+ }
201
+ lineHasCode = true
202
+ // In shell a backslash escapes the next character outside strings too:
203
+ // `\'` is a quote character, not the start of a string.
204
+ if (language === 'shell' && char === '\\') {
205
+ i += 2
206
+ lastSignificant = 'a'
207
+ continue
208
+ }
209
+ if (char === '`' && language === 'js') {
210
+ // A template literal: its text is a string, each `${...}` inside it is
211
+ // code (and may hold strings and templates of its own).
212
+ i = templateEnd(text, i, mark, STRING, CODE)
213
+ lastSignificant = '`'
214
+ continue
215
+ }
216
+ if (char === '"' || char === "'") {
217
+ const triple = language === 'python' && text.startsWith(char.repeat(3), i)
218
+ const quote = triple ? char.repeat(3) : char
219
+ let j = i + quote.length
220
+ while (j < text.length) {
221
+ // A backslash escapes in every string but a shell's plain single-quoted
222
+ // one; in a shell `$'...'` string it escapes again.
223
+ if (text[j] === '\\' && !(language === 'shell' && char === "'" && text[i - 1] !== '$')) {
224
+ j += 2
225
+ continue
226
+ }
227
+ if (text.startsWith(quote, j)) {
228
+ j += quote.length
229
+ break
230
+ }
231
+ if (!triple && text[j] === '\n' && language !== 'shell') break
232
+ j += 1
233
+ }
234
+ mark(i, j, STRING)
235
+ i = j
236
+ lastSignificant = char
237
+ continue
238
+ }
239
+ if (language === 'js' && char === '/' && i >= noRegexBefore && regexCanStart(lastSignificant, lastWord)) {
240
+ let j = i + 1
241
+ let inClass = false
242
+ while (j < text.length && !isLineEnd(text[j])) {
243
+ if (text[j] === '\\') {
244
+ j += 2
245
+ continue
246
+ }
247
+ if (text[j] === '[') inClass = true
248
+ else if (text[j] === ']') inClass = false
249
+ else if (text[j] === '/' && !inClass) break
250
+ j += 1
251
+ }
252
+ if (text[j] === '/') {
253
+ // The literal is skipped as one unit either way, so a `/*` inside it
254
+ // never opens a comment. Only one with an escape counts as a pattern;
255
+ // without one, what it holds is judged as code.
256
+ const isPattern = text.slice(i + 1, j).includes('\\')
257
+ j += 1
258
+ while (j < text.length && /[a-z]/i.test(text[j] as string)) j += 1
259
+ mark(i, j, isPattern ? REGEX : CODE)
260
+ i = j
261
+ lastSignificant = '/'
262
+ continue
263
+ }
264
+ noRegexBefore = j
265
+ }
266
+ const before = lastSignificant
267
+ // `x++ / y` and `x-- / y` divide: a postfix operator ends an operand.
268
+ lastSignificant = (char === '+' || char === '-') && text[i - 1] === char ? 'a' : char
269
+ if (char === '(') parens.push(/^(?:if|while|for|with)$/.test(lastWord))
270
+ if (char === ')' && parens.pop() === true) lastSignificant = ';'
271
+ if (/[A-Za-z_$]/.test(char)) {
272
+ let j = i
273
+ while (j < text.length && /[\w$]/.test(text[j] as string)) j += 1
274
+ // `obj.in` or `x.return` is a property, an operand, never a keyword.
275
+ lastWord = before === '.' ? '' : text.slice(i, j)
276
+ lastSignificant = 'a'
277
+ i = j
278
+ continue
279
+ }
280
+ lastWord = ''
281
+ i += 1
282
+ }
283
+ return index => (['code', 'string', 'comment', 'regex', 'trailing-comment'] as const)[kinds[index] ?? CODE] as SpanKind
284
+ }
285
+
286
+ /**
287
+ * Marks a template literal starting at `start` (its backtick) and returns the
288
+ * index after its closing backtick: text as `string`, each `${...}` as `code`,
289
+ * with nested quotes and templates inside an interpolation skipped whole.
290
+ */
291
+ const MAX_TEMPLATE_DEPTH = 32
292
+
293
+ function templateEnd(text: string, start: number, mark: (from: number, to: number, kind: number) => void, STRING: number, CODE: number, depth = 0): number {
294
+ // Nested deeper than any real code: the rest is judged as code, never hidden.
295
+ if (depth > MAX_TEMPLATE_DEPTH) {
296
+ mark(start, text.length, CODE)
297
+ return text.length
298
+ }
299
+ let j = start + 1
300
+ let segment = start
301
+ while (j < text.length) {
302
+ if (text[j] === '\\') {
303
+ j += 2
304
+ continue
305
+ }
306
+ if (text[j] === '`') {
307
+ mark(segment, j + 1, STRING)
308
+ return j + 1
309
+ }
310
+ if (text[j] === '$' && text[j + 1] === '{') {
311
+ mark(segment, j + 2, STRING)
312
+ const close = interpolationEnd(text, j + 2, mark, STRING, CODE, depth + 1)
313
+ j = close + 1
314
+ segment = j
315
+ continue
316
+ }
317
+ j += 1
318
+ }
319
+ mark(segment, text.length, STRING)
320
+ return text.length
321
+ }
322
+
323
+ /** The index of the `}` closing an interpolation opened just before `start`. */
324
+ function interpolationEnd(text: string, start: number, mark: (from: number, to: number, kind: number) => void, STRING: number, CODE: number, nesting: number): number {
325
+ let depth = 1
326
+ let j = start
327
+ let codeFrom = start
328
+ while (j < text.length) {
329
+ const char = text[j]
330
+ if (char === '`') {
331
+ mark(codeFrom, j, CODE)
332
+ j = templateEnd(text, j, mark, STRING, CODE, nesting + 1)
333
+ codeFrom = j
334
+ continue
335
+ }
336
+ if (char === '"' || char === "'") {
337
+ let k = j + 1
338
+ while (k < text.length && text[k] !== char && text[k] !== '\n') k += text[k] === '\\' ? 2 : 1
339
+ j = k + 1
340
+ continue
341
+ }
342
+ if (char === '{') depth += 1
343
+ if (char === '}') {
344
+ depth -= 1
345
+ if (depth === 0) {
346
+ mark(codeFrom, j, CODE)
347
+ return j
348
+ }
349
+ }
350
+ j += 1
351
+ }
352
+ mark(codeFrom, text.length, CODE)
353
+ return text.length
354
+ }
355
+
356
+ function regexCanStart(lastSignificant: string, lastWord: string): boolean {
357
+ if (lastSignificant === '') return true
358
+ if (lastSignificant === 'a') return /^(?:return|typeof|case|do|else|in|of|new|delete|void|throw|yield|await|instanceof)$/.test(lastWord)
359
+ // Not after `)`, `]` or `}`: there a slash divides, and reading code as a
360
+ // pattern would hide it.
361
+ return /[(,=:[!&|?{;+\-*%<>~^]/.test(lastSignificant)
362
+ }
@@ -0,0 +1,105 @@
1
+ import { redactSecrets } from './patterns'
2
+
3
+ export type Status = 'pass' | 'warn' | 'fail' | 'info'
4
+
5
+ export type Severity = 'none' | 'low' | 'medium' | 'high' | 'critical'
6
+
7
+ export type GroupId =
8
+ | 'provenance'
9
+ | 'structure'
10
+ | 'mod'
11
+ | 'hooks'
12
+ | 'mcp'
13
+ | 'prompts'
14
+ | 'code'
15
+ | 'secrets'
16
+ | 'quality'
17
+ | 'update'
18
+
19
+ /** The report's groups, in the order it shows them. */
20
+ export const GROUPS: readonly { id: GroupId; title: string }[] = [
21
+ { id: 'provenance', title: 'Provenance and trust' },
22
+ { id: 'structure', title: 'Structure and manifest' },
23
+ { id: 'mod', title: 'Mod capabilities' },
24
+ { id: 'hooks', title: 'Classic hooks' },
25
+ { id: 'mcp', title: 'MCP servers' },
26
+ { id: 'prompts', title: 'Skills, commands and agents' },
27
+ { id: 'code', title: 'Code patterns' },
28
+ { id: 'secrets', title: 'Committed secrets' },
29
+ { id: 'quality', title: 'Quality signals' },
30
+ { id: 'update', title: 'New since the version you approved' },
31
+ ]
32
+
33
+ /**
34
+ * One place a check points at. `excerpt` is quoted from the candidate and is
35
+ * untrusted: reports pass it through `safeText` and never render it as
36
+ * markup. `status` says how bad this one place is, when a row mixes them.
37
+ */
38
+ export type Evidence = {
39
+ file: string
40
+ line?: number
41
+ excerpt: string
42
+ status?: Status
43
+ }
44
+
45
+ export type CheckResult = {
46
+ id: string
47
+ group: GroupId
48
+ title: string
49
+ status: Status
50
+ severity: Severity
51
+ evidence: Evidence[]
52
+ advice: string
53
+ /**
54
+ * What the evidence means, in plain words for a person: a headline, then a
55
+ * few lines. Shown before the evidence, which stays one step further in.
56
+ */
57
+ summary?: string[]
58
+ }
59
+
60
+ const RANK: Record<Status, number> = { info: 0, pass: 1, warn: 2, fail: 3 }
61
+
62
+ /** The worse of two statuses: fail > warn > pass > info. */
63
+ export function worse(a: Status, b: Status): Status {
64
+ return RANK[a] >= RANK[b] ? a : b
65
+ }
66
+
67
+ export function rankOf(status: Status): number {
68
+ return RANK[status]
69
+ }
70
+
71
+ export function severityFor(status: Status): Severity {
72
+ return status === 'fail' ? 'high' : status === 'warn' ? 'medium' : 'none'
73
+ }
74
+
75
+ /**
76
+ * At most `max` pieces of evidence, the worst first, and a line counting the
77
+ * rest. Every piece is judged before capping, so a late failure still counts.
78
+ */
79
+ export function capEvidence(evidence: Evidence[], max: number, fallback: Status = 'warn'): Evidence[] {
80
+ if (evidence.length <= max) return evidence
81
+ const worstFirst = [...evidence].sort((a, b) => RANK[b.status ?? fallback] - RANK[a.status ?? fallback])
82
+ return [...worstFirst.slice(0, max), { file: '-', excerpt: `and ${evidence.length - max} more`, status: 'info' }]
83
+ }
84
+
85
+ /**
86
+ * Builds a check's row from its evidence: the row takes the worst status any
87
+ * piece of evidence carries (`fallback` when none does), and `pass` when
88
+ * there is no evidence at all.
89
+ */
90
+ export function row(
91
+ base: { id: string; group: GroupId; title: string; advice: string; summary?: string[] },
92
+ evidence: Evidence[],
93
+ fallback: Status = 'warn',
94
+ severity?: Severity,
95
+ ): CheckResult {
96
+ if (evidence.length === 0) {
97
+ const { summary: _unused, ...rest } = base
98
+ return { ...rest, status: 'pass', severity: 'none', evidence: [] }
99
+ }
100
+ const status = evidence.reduce<Status>((acc, item) => worse(acc, item.status ?? fallback), 'info')
101
+ const worstFirst = [...evidence]
102
+ .sort((a, b) => RANK[b.status ?? fallback] - RANK[a.status ?? fallback])
103
+ .map(item => ({ ...item, excerpt: redactSecrets(item.excerpt) }))
104
+ return { ...base, status, severity: severity ?? severityFor(status), evidence: worstFirst }
105
+ }
@@ -0,0 +1,111 @@
1
+ import type { Host } from '../host'
2
+ import { isRecord, parseJson } from './context'
3
+
4
+ export type ValidatorProblem = { file: string; message: string }
5
+
6
+ export type ValidatorReport =
7
+ | {
8
+ ran: true
9
+ errors: ValidatorProblem[]
10
+ warnings: ValidatorProblem[]
11
+ /** Per module: the events it hooks and the `$` calls it makes, as Claude Code reads them. */
12
+ modules: { module: string; hooks: string[]; calls: string[] }[]
13
+ }
14
+ | { ran: false; reason: string }
15
+
16
+ const TIMEOUT_MS = 60_000
17
+
18
+ /**
19
+ * Runs `claude plugin validate --json` on the candidate. It reads the
20
+ * manifest and the hooks module's source the way Claude Code will and runs
21
+ * none of it, so it is the one external tool the scan uses on a candidate.
22
+ */
23
+ export async function runValidator(host: Host, root: string): Promise<ValidatorReport> {
24
+ const result = await host
25
+ .run(['claude', 'plugin', 'validate', '--json', root], { timeoutMs: TIMEOUT_MS })
26
+ .catch((error: unknown) => ({ exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }))
27
+ const parsed = parseJson(result.stdout)
28
+ // Only a report shaped like one counts: an error object is not a pass.
29
+ const isReport = parsed.ok && isRecord(parsed.value) && typeof parsed.value.success === 'boolean' && ('manifest' in parsed.value || 'contents' in parsed.value)
30
+ if (!isReport || !parsed.ok || !isRecord(parsed.value)) {
31
+ const reason = result.stderr.trim().split('\n')[0] ?? ''
32
+ return { ran: false, reason: reason === '' ? `exit code ${result.exitCode}` : reason.slice(0, 200) }
33
+ }
34
+ const report = relativeTo(root, parseValidatorReport(parsed.value))
35
+ if (report.ran && parsed.value.success === false && report.errors.length === 0) {
36
+ report.errors.push({ file: '-', message: `claude plugin validate failed (exit code ${result.exitCode})` })
37
+ }
38
+ return report
39
+ }
40
+
41
+ export function parseValidatorReport(report: Record<string, unknown>): ValidatorReport {
42
+ const errors: ValidatorProblem[] = []
43
+ const warnings: ValidatorProblem[] = []
44
+ const modules: { module: string; hooks: string[]; calls: string[] }[] = []
45
+ const sections = [report.manifest, ...(Array.isArray(report.contents) ? report.contents : [])]
46
+ for (const section of sections) {
47
+ if (!isRecord(section)) continue
48
+ const file = typeof section.file === 'string' ? section.file : '-'
49
+ for (const item of Array.isArray(section.errors) ? section.errors : []) errors.push(problem(file, item))
50
+ for (const item of Array.isArray(section.warnings) ? section.warnings : []) warnings.push(problem(file, item))
51
+ for (const note of Array.isArray(section.notes) ? section.notes : []) {
52
+ if (typeof note !== 'string') continue
53
+ const hooks = /^(\S+) hooks: (.*)$/.exec(note)
54
+ const calls = /^(\S+) calls: (.*)$/.exec(note)
55
+ const match = hooks ?? calls
56
+ if (match === null) continue
57
+ const name = match[1] as string
58
+ let entry = modules.find(item => item.module === name)
59
+ if (entry === undefined) {
60
+ entry = { module: name, hooks: [], calls: [] }
61
+ modules.push(entry)
62
+ }
63
+ const list = splitList(match[2] as string)
64
+ if (hooks !== null) entry.hooks.push(...list)
65
+ else entry.calls.push(...list.map(call => call.replace(/^\$\./, '').replace(/\s*\(via [^)]*\)$/, '')))
66
+ }
67
+ }
68
+ return { ran: true, errors, warnings, modules }
69
+ }
70
+
71
+ function problem(file: string, item: unknown): ValidatorProblem {
72
+ if (typeof item === 'string') return { file, message: item }
73
+ if (isRecord(item)) {
74
+ const path = typeof item.path === 'string' && item.path !== '' ? `${item.path}: ` : ''
75
+ return { file, message: `${path}${typeof item.message === 'string' ? item.message : JSON.stringify(item)}` }
76
+ }
77
+ return { file, message: String(item) }
78
+ }
79
+
80
+ /** Splits `a, b{x=1, y=2}, c` on top-level commas. */
81
+ function splitList(text: string): string[] {
82
+ const out: string[] = []
83
+ let depth = 0
84
+ let current = ''
85
+ for (const char of text) {
86
+ if (char === '{' || char === '(') depth += 1
87
+ if (char === '}' || char === ')') depth -= 1
88
+ if (char === ',' && depth === 0) {
89
+ if (current.trim() !== '') out.push(current.trim())
90
+ current = ''
91
+ } else {
92
+ current += char
93
+ }
94
+ }
95
+ if (current.trim() !== '') out.push(current.trim())
96
+ return out
97
+ }
98
+
99
+ /**
100
+ * The report with the scanned folder's own path taken out of file names and
101
+ * messages: a remote scan's folder is a temporary one, and every other place
102
+ * in the report is relative to the candidate.
103
+ */
104
+ function relativeTo(root: string, report: ValidatorReport): ValidatorReport {
105
+ if (!report.ran) return report
106
+ const roots = [root, `/private${root}`].map(path => path.replace(/\/+$/, ''))
107
+ const strip = (text: string): string =>
108
+ roots.reduce((out, path) => out.split(`${path}/`).join('').split(path).join('.'), text)
109
+ const fix = (item: ValidatorProblem): ValidatorProblem => ({ file: strip(item.file), message: strip(item.message) })
110
+ return { ...report, errors: report.errors.map(fix), warnings: report.warnings.map(fix) }
111
+ }