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,655 @@
1
+ import type { InventoryFile, ScannedTree } from '../inventory'
2
+ import { magicOf } from './magic'
3
+ import { basename, dirname, extension, findAll, joinPath, lineOf, spanKinds, type Match } from './text'
4
+
5
+ /**
6
+ * What a file is to Claude Code, which decides how much a finding in it
7
+ * matters. A file anything loads or runs (a hooks module, a script a hook or
8
+ * skill names) counts in full; a test or doc file nothing refers to is only
9
+ * reported.
10
+ */
11
+ export type FileRole =
12
+ | 'module'
13
+ | 'hook-script'
14
+ | 'server'
15
+ | 'prompt-script'
16
+ | 'hooks-config'
17
+ | 'mcp-config'
18
+ | 'manifest'
19
+ | 'prompt'
20
+ | 'bin'
21
+ | 'code'
22
+ | 'test'
23
+ | 'doc'
24
+ | 'other'
25
+
26
+ /** Earlier wins when a file has several roles. */
27
+ const ROLE_ORDER: readonly FileRole[] = [
28
+ 'module',
29
+ 'hook-script',
30
+ 'server',
31
+ 'prompt-script',
32
+ 'hooks-config',
33
+ 'mcp-config',
34
+ 'manifest',
35
+ 'prompt',
36
+ 'bin',
37
+ 'code',
38
+ 'test',
39
+ 'doc',
40
+ 'other',
41
+ ]
42
+
43
+ export type CandidateKind = 'plugin' | 'skill' | 'marketplace' | 'folder'
44
+
45
+ export type ModuleUse = { name: string; file: string; line: number; detail?: string }
46
+
47
+ export type ModuleInfo = {
48
+ /** The module hooks.json names, resolved from the root. */
49
+ entry: string
50
+ /** As hooks.json spells it (`./register.tsx`), which is how the validator names it. */
51
+ declaredAs: string
52
+ /** It and every plugin file it imports. */
53
+ files: string[]
54
+ events: ModuleUse[]
55
+ calls: ModuleUse[]
56
+ envNames: ModuleUse[]
57
+ urls: ModuleUse[]
58
+ }
59
+
60
+ export type HookCommand = {
61
+ file: string
62
+ line?: number
63
+ event: string
64
+ matcher?: string
65
+ type: string
66
+ command: string
67
+ }
68
+
69
+ export type McpServer = {
70
+ name: string
71
+ file: string
72
+ line?: number
73
+ type?: string
74
+ command?: string
75
+ args: string[]
76
+ url?: string
77
+ env: Record<string, string>
78
+ headers: Record<string, string>
79
+ }
80
+
81
+ export type ScanContext = {
82
+ tree: ScannedTree
83
+ files: InventoryFile[]
84
+ kind: CandidateKind
85
+ /** The parsed `.claude-plugin/plugin.json`, when there is one and it parses. */
86
+ manifest?: Record<string, unknown>
87
+ manifestError?: string
88
+ readme?: { path: string; text: string }
89
+ modules: ModuleInfo[]
90
+ hookCommands: HookCommand[]
91
+ /** Every file a classic hook command names, whatever role it ends up with. */
92
+ hookScripts: Set<string>
93
+ /**
94
+ * The interpreter a hook or server runs a file with (`sh hooks/x.js` runs
95
+ * it as shell), which decides how it is read whatever its extension says.
96
+ */
97
+ interpreters: Map<string, 'js' | 'python' | 'shell' | 'other'>
98
+ hookConfigErrors: { file: string; error: string }[]
99
+ mcpServers: McpServer[]
100
+ /** Markdown the model reads: skills, commands, agents, output styles. */
101
+ prompts: string[]
102
+ text: (path: string) => string | undefined
103
+ roleOf: (path: string) => FileRole
104
+ }
105
+
106
+ const decoder = new TextDecoder('utf-8')
107
+
108
+ const CODE_EXTENSIONS = new Set([
109
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'py', 'sh', 'bash', 'zsh', 'fish', 'rb', 'pl',
110
+ 'php', 'ps1', 'psm1', 'bat', 'cmd', 'go', 'rs', 'java', 'kt', 'swift', 'lua', 'applescript', 'vbs',
111
+ ])
112
+
113
+ const MARKDOWN_EXTENSIONS = new Set(['md', 'mdx', 'markdown'])
114
+
115
+ const PROMPT_DIRS = ['skills', 'commands', 'agents', 'output-styles']
116
+
117
+ const TEST_SEGMENTS = new Set(['test', 'tests', '__tests__', 'fixtures', '__fixtures__', 'testdata', 'spec', 'specs', 'e2e', 'test-support', 'testing', 'test-utils', 'test-helpers', '__mocks__', 'mocks'])
118
+
119
+ const DOC_NAMES = /^(readme|changelog|changes|history|license|licence|copying|notice|contributing|code_of_conduct|security|authors)(\..*)?$/i
120
+
121
+ export function isCodeFile(path: string, text?: string): boolean {
122
+ return CODE_EXTENSIONS.has(extension(path)) || (text !== undefined && text.startsWith('#!'))
123
+ }
124
+
125
+ export function isMarkdown(path: string): boolean {
126
+ return MARKDOWN_EXTENSIONS.has(extension(path))
127
+ }
128
+
129
+ export function isTestPath(path: string): boolean {
130
+ const segments = path.split('/')
131
+ const name = basename(path)
132
+ return (
133
+ segments.slice(0, -1).some(segment => TEST_SEGMENTS.has(segment.toLowerCase())) ||
134
+ /\.(test|spec)\.[a-z0-9]+$/i.test(name) ||
135
+ /^test_.*\.py$/i.test(name) ||
136
+ /_test\.(go|py)$/i.test(name)
137
+ )
138
+ }
139
+
140
+ /** Repository files Claude Code never loads: CI configuration and git and editor dotfiles. */
141
+ const REPO_FILES = /^(?:\.github|\.gitlab|\.circleci|\.husky|\.vscode|\.idea|\.devcontainer)\/|^(?:\.gitignore|\.gitattributes|\.gitmodules|\.npmignore|\.editorconfig|\.prettierrc[\w.]*|\.eslintrc[\w.]*|\.gitlab-ci\.yml|\.travis\.yml|renovate\.json|dependabot\.yml)$/
142
+
143
+ export function isDocPath(path: string): boolean {
144
+ const first = path.split('/')[0]?.toLowerCase()
145
+ // A doc name on a code file (history.js, security.ts) is still code.
146
+ const isDocName = DOC_NAMES.test(basename(path)) && !CODE_EXTENSIONS.has(extension(path))
147
+ return isDocName || first === 'docs' || first === 'doc' || REPO_FILES.test(path)
148
+ }
149
+
150
+ export function buildContext(tree: ScannedTree): ScanContext {
151
+ const files = tree.inventory.entries.filter((entry): entry is InventoryFile => entry.kind === 'file')
152
+ const paths = new Set(files.map(file => file.path))
153
+ // An in-folder link stands for the file it lands on: Claude Code follows it.
154
+ const aliases = new Map<string, string>()
155
+ const dirAliases: { link: string; target: string }[] = []
156
+ const dirs = new Set(tree.inventory.entries.filter(entry => entry.kind === 'dir').map(entry => entry.path))
157
+ for (const entry of tree.inventory.entries) {
158
+ if (entry.kind !== 'link' || !entry.isInside || entry.landsAt === undefined) continue
159
+ if (paths.has(entry.landsAt)) aliases.set(entry.path, entry.landsAt)
160
+ else if (dirs.has(entry.landsAt)) dirAliases.push({ link: entry.path, target: entry.landsAt })
161
+ }
162
+ /** The file a path reaches, through a link to a file or a link to a folder on the way. */
163
+ const realOf = (path: string): string | undefined => {
164
+ if (paths.has(path)) return path
165
+ const viaFile = aliases.get(path)
166
+ if (viaFile !== undefined) return viaFile
167
+ for (const { link, target } of dirAliases) {
168
+ if (path.startsWith(`${link}/`)) {
169
+ const real = `${target}/${path.slice(link.length + 1)}`
170
+ if (paths.has(real)) return real
171
+ }
172
+ }
173
+ return undefined
174
+ }
175
+ const texts = new Map<string, string>()
176
+ const byPath = new Map(files.map(file => [file.path, file]))
177
+ // Any file that is not a known binary format is read as text, NUL bytes
178
+ // and all: a NUL in a comment must not hide the code after it. Paths are
179
+ // read through in-folder links, as Claude Code reads them.
180
+ const text = (asked: string): string | undefined => {
181
+ const path = realOf(asked) ?? asked
182
+ const cached = texts.get(path)
183
+ if (cached !== undefined) return cached
184
+ const file = byPath.get(path)
185
+ const bytes = tree.contents.get(path)
186
+ if (file === undefined || bytes === undefined) return undefined
187
+ if (!file.isText && magicOf(bytes) !== undefined) return undefined
188
+ const decoded = file.isText ? decoder.decode(bytes) : decoder.decode(bytes).replace(/\u0000/g, ' ')
189
+ texts.set(path, decoded)
190
+ return decoded
191
+ }
192
+
193
+ const roles = new Map<string, FileRole>()
194
+ const assign = (path: string, role: FileRole): void => {
195
+ const current = roles.get(path)
196
+ if (current === undefined || ROLE_ORDER.indexOf(role) < ROLE_ORDER.indexOf(current)) {
197
+ roles.set(path, role)
198
+ }
199
+ }
200
+
201
+ const manifestPath = '.claude-plugin/plugin.json'
202
+ let manifest: Record<string, unknown> | undefined
203
+ let manifestError: string | undefined
204
+ const realManifest = realOf(manifestPath)
205
+ if (realManifest !== undefined) {
206
+ assign(realManifest, 'manifest')
207
+ const parsed = parseJson(text(realManifest))
208
+ if (parsed.ok && isRecord(parsed.value)) manifest = parsed.value
209
+ else manifestError = parsed.ok ? 'plugin.json is not a JSON object' : parsed.error
210
+ }
211
+ if (paths.has('.claude-plugin/marketplace.json')) assign('.claude-plugin/marketplace.json', 'manifest')
212
+
213
+ const hasPluginLayout =
214
+ realManifest !== undefined ||
215
+ realOf('hooks/hooks.json') !== undefined ||
216
+ realOf('.mcp.json') !== undefined ||
217
+ files.some(file => PROMPT_DIRS.some(dir => file.path.startsWith(`${dir}/`)))
218
+ const kind: CandidateKind = realOf('SKILL.md') !== undefined
219
+ ? 'skill'
220
+ : hasPluginLayout
221
+ ? 'plugin'
222
+ : paths.has('.claude-plugin/marketplace.json')
223
+ ? 'marketplace'
224
+ : 'folder'
225
+
226
+ // Markdown the model reads.
227
+ const promptRoots = kind === 'skill' ? [''] : [...PROMPT_DIRS, ...manifestPaths(manifest, ['commands', 'agents', 'skills', 'outputStyles'])]
228
+ // Claude Code loads these by place, whatever they are named: a skill in
229
+ // skills/testing/ is still a skill. Only a standalone skill's own README
230
+ // and similar files are left out.
231
+ // A folder link under a prompt root makes its target's files prompts too.
232
+ const roots = [...promptRoots]
233
+ for (const { link, target } of dirAliases) {
234
+ if (promptRoots.some(root => root !== '' && (link === root || link.startsWith(`${root}/`)))) roots.push(target)
235
+ }
236
+ const underRoot = (path: string): boolean =>
237
+ roots.some(root => (root === '' ? !DOC_NAMES.test(basename(path)) || path.includes('/') : path === root || path.startsWith(`${root}/`)))
238
+ const prompts = files.map(file => file.path).filter(path => isMarkdown(path) && underRoot(path))
239
+ // A markdown link under a prompt root (skills/x/SKILL.md -> ../../docs/x.md)
240
+ // makes the file it lands on a prompt too.
241
+ for (const [link, real] of aliases) {
242
+ if (isMarkdown(link) && underRoot(link) && !prompts.includes(real)) prompts.push(real)
243
+ }
244
+ for (const path of prompts) assign(path, 'prompt')
245
+
246
+ // Classic hooks and hooks modules.
247
+ const hookCommands: HookCommand[] = []
248
+ const hookConfigErrors: { file: string; error: string }[] = []
249
+ const moduleEntries: { path: string; declaredAs: string }[] = []
250
+ const hookFiles = new Set<string>(['hooks/hooks.json', ...manifestPaths(manifest, ['hooks'])])
251
+ for (const file of hookFiles) {
252
+ const real = realOf(file)
253
+ if (real === undefined) continue
254
+ assign(real, 'hooks-config')
255
+ const parsed = parseJson(text(real))
256
+ if (!parsed.ok || !isRecord(parsed.value)) {
257
+ hookConfigErrors.push({ file: real, error: parsed.ok ? 'not a JSON object' : parsed.error })
258
+ continue
259
+ }
260
+ collectHookCommands(parsed.value, real, text(real) ?? '', hookCommands)
261
+ for (const entry of stringList(parsed.value.modules)) {
262
+ // Module paths are relative to where Claude Code reads hooks.json from.
263
+ const resolved = joinPath(dirname(file), entry)
264
+ if (resolved !== undefined) moduleEntries.push({ path: resolved, declaredAs: entry })
265
+ }
266
+ }
267
+ if (manifest !== undefined && isRecord(manifest.hooks) && realManifest !== undefined) {
268
+ // Inline hooks: `{ "hooks": { Event: [...] } }` or the event map itself,
269
+ // and hooks modules named there (relative to the plugin root).
270
+ const inline = manifest.hooks
271
+ const events = isRecord(inline.hooks) ? inline : { hooks: Object.fromEntries(Object.entries(inline).filter(([key]) => key !== 'modules' && key !== 'description')) }
272
+ collectHookCommands(events, realManifest, text(realManifest) ?? '', hookCommands)
273
+ for (const entry of stringList(inline.modules)) {
274
+ const resolved = joinPath('', entry)
275
+ if (resolved !== undefined) moduleEntries.push({ path: resolved, declaredAs: entry })
276
+ }
277
+ }
278
+
279
+ const modules = moduleEntries.map(entry => readModule(entry.path, entry.declaredAs, realOf, text))
280
+ for (const info of modules) for (const file of info.files) assign(file, 'module')
281
+
282
+ // MCP servers.
283
+ const mcpServers: McpServer[] = []
284
+ const mcpFiles = new Set<string>(['.mcp.json', ...manifestPaths(manifest, ['mcpServers'])])
285
+ for (const file of mcpFiles) {
286
+ const real = realOf(file)
287
+ if (real === undefined) continue
288
+ assign(real, 'mcp-config')
289
+ const parsed = parseJson(text(real))
290
+ if (parsed.ok && isRecord(parsed.value)) collectMcpServers(parsed.value, real, text(real) ?? '', mcpServers)
291
+ }
292
+ if (manifest !== undefined && isRecord(manifest.mcpServers) && realManifest !== undefined) {
293
+ collectMcpServers({ mcpServers: manifest.mcpServers }, realManifest, text(realManifest) ?? '', mcpServers)
294
+ }
295
+
296
+ // Files (and links) that hooks, servers or prompts name are run, not just shipped.
297
+ const linkPaths = tree.inventory.entries.filter(entry => entry.kind === 'link').map(entry => entry.path)
298
+ // `hooks//run.sh`, `"hooks/run.sh"` and `cd hooks && sh run.sh` all name
299
+ // hooks/run.sh: paths are compared with quotes and doubled slashes
300
+ // removed, and a bare file name counts when one file has it.
301
+ const nameIndexes = new Map<string, { normalized: string; words: Set<string> }>()
302
+ const indexOf = (haystack: string): { normalized: string; words: Set<string> } => {
303
+ const cached = nameIndexes.get(haystack)
304
+ if (cached !== undefined) return cached
305
+ const normalized = haystack.replace(/["'`]/g, ' ').replace(/\/{2,}/g, '/')
306
+ const words = new Set(normalized.split(/[\s;&|()<>=,]+/).map(word => word.split('/').pop() ?? word))
307
+ const index = { normalized, words }
308
+ nameIndexes.set(haystack, index)
309
+ return index
310
+ }
311
+ const namedIn = (haystack: string, path: string): boolean => {
312
+ const { normalized, words } = indexOf(haystack)
313
+ // The full path, as a whole: openapi.md does not name api.md.
314
+ for (let at = normalized.indexOf(path); at !== -1; at = normalized.indexOf(path, at + 1)) {
315
+ const before = normalized[at - 1]
316
+ const after = normalized[at + path.length]
317
+ if ((before === undefined || !/[\w.-]/.test(before)) && (after === undefined || !/[\w-]/.test(after))) return true
318
+ }
319
+ return words.has(basename(path)) && nameCount.get(basename(path)) === 1
320
+ }
321
+ const referenced = (haystack: string, role: FileRole, skipMarkdown: boolean): void => {
322
+ for (const path of [...paths, ...linkPaths]) {
323
+ if (skipMarkdown && isMarkdown(path)) continue
324
+ const named = namedIn(haystack, path)
325
+ if (!named) continue
326
+ assign(path, role)
327
+ const real = aliases.get(path)
328
+ if (real !== undefined) assign(real, role)
329
+ }
330
+ }
331
+ const nameCount = new Map<string, number>()
332
+ for (const path of [...paths, ...linkPaths]) nameCount.set(basename(path), (nameCount.get(basename(path)) ?? 0) + 1)
333
+ const hookScripts = new Set<string>()
334
+ const interpreters = new Map<string, 'js' | 'python' | 'shell' | 'other'>()
335
+ // Run by two interpreters, a file is read as no language at all: no
336
+ // comment is trusted, so neither reading can hide what the other runs.
337
+ const noteInterpreter = (command: string, path: string): void => {
338
+ const language = interpreterNaming(command, path)
339
+ if (language === undefined) return
340
+ const real = aliases.get(path) ?? path
341
+ const known = interpreters.get(real)
342
+ interpreters.set(real, known === undefined || known === language ? language : 'other')
343
+ }
344
+ for (const hook of hookCommands) {
345
+ referenced(hook.command, 'hook-script', false)
346
+ for (const path of [...paths, ...linkPaths]) {
347
+ if (!namedIn(hook.command, path)) continue
348
+ hookScripts.add(aliases.get(path) ?? path)
349
+ noteInterpreter(hook.command, path)
350
+ }
351
+ }
352
+ for (const server of mcpServers) {
353
+ const command = [server.command ?? '', ...server.args].join(' ')
354
+ referenced(command, 'server', false)
355
+ for (const path of [...paths, ...linkPaths]) if (namedIn(command, path)) noteInterpreter(command, path)
356
+ }
357
+ // Markdown a prompt tells the model to read is a prompt too, however it is
358
+ // named (a skill's own README): followed until nothing new is named.
359
+ const promptSet = new Set(prompts)
360
+ for (let index = 0; index < prompts.length; index += 1) {
361
+ const body = text(prompts[index] as string) ?? ''
362
+ for (const path of [...paths, ...linkPaths]) {
363
+ if (!isMarkdown(path) || !namedIn(body, path)) continue
364
+ const real = aliases.get(path) ?? path
365
+ if (paths.has(real) && !promptSet.has(real)) {
366
+ promptSet.add(real)
367
+ prompts.push(real)
368
+ }
369
+ }
370
+ }
371
+ for (const path of prompts) assign(path, 'prompt')
372
+ for (const prompt of prompts) {
373
+ const body = text(prompt) ?? ''
374
+ referenced(body, 'prompt-script', true)
375
+ // A script named relative to the skill's own folder.
376
+ for (const path of paths) {
377
+ if (!isMarkdown(path) && path.startsWith(`${dirname(prompt)}/`) && body.includes(path.slice(dirname(prompt).length + 1))) {
378
+ assign(path, 'prompt-script')
379
+ }
380
+ }
381
+ }
382
+ if (kind === 'skill') {
383
+ for (const file of files) if (!isMarkdown(file.path) && isCodeFile(file.path, text(file.path))) assign(file.path, 'prompt-script')
384
+ } else {
385
+ for (const file of files) {
386
+ const skillDir = file.path.match(/^skills\/[^/]+\//)
387
+ if (skillDir !== null && !isMarkdown(file.path) && isCodeFile(file.path, text(file.path))) assign(file.path, 'prompt-script')
388
+ }
389
+ }
390
+ for (const file of files) if (file.path.startsWith('bin/')) assign(file.path, 'bin')
391
+
392
+ for (const file of files) {
393
+ if (isTestPath(file.path)) assign(file.path, 'test')
394
+ else if (isDocPath(file.path)) assign(file.path, 'doc')
395
+ else if (isCodeFile(file.path, text(file.path))) assign(file.path, 'code')
396
+ }
397
+
398
+ // Whatever live code loads is live too, even when it sits in a test or
399
+ // docs folder: a hook script that requires ./test/helper.js runs it.
400
+ const queue = files.map(file => file.path).filter(path => LIVE_CODE_ROLES.has(roles.get(path) ?? 'other'))
401
+ const visited = new Set(queue)
402
+ while (queue.length > 0) {
403
+ const from = queue.shift() as string
404
+ for (const target of localImports(from, text(from) ?? '', realOf)) {
405
+ assign(target, 'code')
406
+ if (!visited.has(target)) {
407
+ visited.add(target)
408
+ queue.push(target)
409
+ }
410
+ }
411
+ }
412
+
413
+ const readmePath = files.map(file => file.path).find(path => /^readme(\.md|\.markdown|\.txt)?$/i.test(path))
414
+ const readmeText = readmePath === undefined ? undefined : text(readmePath)
415
+
416
+ return {
417
+ tree,
418
+ files,
419
+ kind,
420
+ manifest,
421
+ manifestError,
422
+ readme: readmePath !== undefined && readmeText !== undefined ? { path: readmePath, text: readmeText } : undefined,
423
+ modules,
424
+ hookCommands,
425
+ hookScripts,
426
+ interpreters,
427
+ hookConfigErrors,
428
+ mcpServers,
429
+ prompts,
430
+ text,
431
+ roleOf: path => roles.get(path) ?? 'other',
432
+ }
433
+ }
434
+
435
+ /** The interpreter a command runs `path` with, when it names one right before it. */
436
+ function interpreterNaming(command: string, path: string): 'js' | 'python' | 'shell' | undefined {
437
+ const normalized = command.replace(/["']/g, ' ').replace(/\/{2,}/g, '/')
438
+ const at = normalized.includes(path) ? normalized.indexOf(path) : normalized.indexOf(basename(path))
439
+ if (at === -1) return undefined
440
+ const languageOfWord = (word: string): 'js' | 'python' | 'shell' | undefined => {
441
+ const program = basename(word).toLowerCase()
442
+ if (/^(?:sh|bash|zsh|dash|ksh|fish|source|\.)$/.test(program)) return 'shell'
443
+ if (/^(?:node|deno|bun|tsx|ts-node)$/.test(program)) return 'js'
444
+ if (/^python\d*(?:\.\d+)?$/.test(program)) return 'python'
445
+ return undefined
446
+ }
447
+ // `cat x.js | sh` pipes the file into an interpreter.
448
+ const piped = /^[^;&|]*\|\s*(\S+)/.exec(normalized.slice(at + path.length))?.[1]
449
+ if (piped !== undefined && languageOfWord(piped) !== undefined) return languageOfWord(piped)
450
+ // Otherwise the nearest interpreter before it in the same command, past
451
+ // options and their values (`bash -o pipefail x.js` is bash).
452
+ const before = normalized.slice(0, at).split(/[;&|]/).pop() ?? ''
453
+ const words = before.trim().split(/\s+/).reverse()
454
+ for (const word of words) {
455
+ const language = languageOfWord(word)
456
+ if (language !== undefined) return language
457
+ }
458
+ return undefined
459
+ }
460
+
461
+ const LIVE_CODE_ROLES: ReadonlySet<FileRole> = new Set(['module', 'hook-script', 'server', 'prompt-script', 'bin', 'code'])
462
+
463
+ /** Relative files a source file loads: JS/TS imports and requires, Python relative imports, shell source. */
464
+ function localImports(path: string, source: string, realOf: (path: string) => string | undefined): string[] {
465
+ const out: string[] = []
466
+ const dir = dirname(path)
467
+ const add = (spec: string, suffixes: readonly string[]): void => {
468
+ const base = joinPath(dir, spec)
469
+ if (base === undefined) return
470
+ for (const suffix of suffixes) {
471
+ const real = realOf(base + suffix)
472
+ if (real !== undefined) {
473
+ out.push(real)
474
+ return
475
+ }
476
+ }
477
+ }
478
+ for (const match of findAll(source, /\b(?:from|import)\s*['"](\.{1,2}\/[^'"\n]{1,300})['"]|\b(?:require|import)\s*\(\s*['"](\.{1,2}\/[^'"\n]{1,300})['"]\s*\)/g, Number.MAX_SAFE_INTEGER)) {
479
+ const spec = /['"](\.{1,2}\/[^'"\n]{1,300})['"]/.exec(match.text)?.[1]
480
+ const base = spec === undefined ? undefined : joinPath(dir, spec)
481
+ // The same resolution as a hooks module's imports: `./x.js` written for `./x.ts`.
482
+ const real = base === undefined ? undefined : resolveModuleFile(base, realOf)
483
+ if (real !== undefined) out.push(real)
484
+ }
485
+ for (const match of findAll(source, /^[ \t]*from[ \t]+(\.+)([\w.]*)[ \t]+import[ \t]+([\w, ]+)/gm, Number.MAX_SAFE_INTEGER)) {
486
+ const parts = /from\s+(\.+)([\w.]*)\s+import\s+([\w, ]+)/.exec(match.text)
487
+ if (parts === null) continue
488
+ const up = '../'.repeat(Math.max(0, (parts[1] as string).length - 1))
489
+ const module = (parts[2] as string).replace(/\./g, '/')
490
+ if (module !== '') add(`./${up}${module}`, ['.py', '/__init__.py'])
491
+ else for (const name of (parts[3] as string).split(',')) add(`./${up}${name.trim()}`, ['.py', '/__init__.py'])
492
+ }
493
+ for (const match of findAll(source, /(?:^|[;&|]\s*)(?:source|\.)\s+["']?(\.{1,2}\/[^\s"';]{1,300})/gm, Number.MAX_SAFE_INTEGER)) {
494
+ const spec = /(\.{1,2}\/[^\s"';]{1,300})/.exec(match.text)?.[1]
495
+ if (spec !== undefined) add(spec, [''])
496
+ }
497
+ return out
498
+ }
499
+
500
+ /** Roles whose findings count in full. Tests and docs nothing refers to are only reported. */
501
+ export function isLive(role: FileRole): boolean {
502
+ return role !== 'test' && role !== 'doc'
503
+ }
504
+
505
+ function readModule(
506
+ entry: string,
507
+ declaredAs: string,
508
+ realOf: (path: string) => string | undefined,
509
+ text: (path: string) => string | undefined,
510
+ ): ModuleInfo {
511
+ const info: ModuleInfo = { entry, declaredAs, files: [], events: [], calls: [], envNames: [], urls: [] }
512
+ const queue = [entry]
513
+ const seen = new Set<string>()
514
+ while (queue.length > 0) {
515
+ const file = resolveModuleFile(queue.shift() as string, realOf)
516
+ if (file === undefined || seen.has(file)) continue
517
+ seen.add(file)
518
+ info.files.push(file)
519
+ const source = text(file) ?? ''
520
+ // `from './x'`, `import './x'`, `require('./x')`, `import('./x')`: each a short,
521
+ // linear pattern, so a module full of import tokens cannot stall the scan.
522
+ for (const match of findAll(source, /\b(?:from|import)\s*['"](\.{1,2}\/[^'"\n]{1,300})['"]|\b(?:require|import)\s*\(\s*['"](\.{1,2}\/[^'"\n]{1,300})['"]\s*\)/g, Number.MAX_SAFE_INTEGER)) {
523
+ const spec = /['"](\.{1,2}\/[^'"\n]{1,300})['"]/.exec(match.text)?.[1]
524
+ const target = spec === undefined ? undefined : joinPath(dirname(file), spec)
525
+ if (target !== undefined) queue.push(target)
526
+ }
527
+ // A whole-line comment that names `$.process.run` runs nothing. Strings
528
+ // still count: a template literal's `${...}` holds code.
529
+ const kindAt = spanKinds(source, 'js')
530
+ const inCode = (match: Match): boolean => kindAt(match.index) !== 'comment'
531
+ for (const match of findAll(source, /\bon\s*\(\s*(['"`])([^'"`\n]{1,200})\1(\s*,\s*\{[^}]{0,500}\})?/g, 10_000).filter(inCode)) {
532
+ const parts = /\(\s*(['"`])([^'"`\n]{1,200})\1(?:\s*,\s*(\{[^}]{0,500}\}))?/.exec(match.text)
533
+ if (parts?.[2] !== undefined) {
534
+ info.events.push({ name: parts[2], file, line: match.line, detail: parts[3]?.replace(/\s+/g, ' ') })
535
+ }
536
+ }
537
+ // `$.noun.method`, `$.noun?.method`, `$['noun'].method`, and a bare `$.noun`
538
+ // (kept in a variable and called later), which counts as every method of it.
539
+ for (const match of findAll(source, DOLLAR_CALL, 10_000).filter(inCode)) {
540
+ const parts = DOLLAR_PARTS.exec(match.text)
541
+ const noun = parts?.[1] ?? parts?.[2]
542
+ const method = parts?.[3] ?? parts?.[4]
543
+ if (noun === undefined) continue
544
+ info.calls.push({ name: method === undefined ? `${noun}.*` : `${noun}.${method}`, file, line: match.line })
545
+ }
546
+ for (const match of findAll(source, /\$\s*\.\s*env\s*\.\s*(?:get|set)\s*\(\s*(['"`])([^'"`\n]{1,200})\1/g, 100)) {
547
+ const name = /(['"`])([^'"`\n]{1,200})\1/.exec(match.text)?.[2]
548
+ if (name !== undefined) info.envNames.push({ name, file, line: match.line })
549
+ }
550
+ for (const match of findAll(source, /https?:\/\/[^\s'"`)<>]+/g, 100)) {
551
+ info.urls.push({ name: match.text, file, line: match.line })
552
+ }
553
+ }
554
+ return info
555
+ }
556
+
557
+ /** A use of a noun on `$`, with the method when one follows. */
558
+ const DOLLAR_CALL = /\$\s*(?:\?\.\s*|\.\s*)?(?:\.\s*[A-Za-z_]\w{0,40}|\[\s*['"`][A-Za-z_]\w{0,40}['"`]\s*\])(?:\s*(?:\?\.|\.)\s*[A-Za-z_]\w{0,40}|\s*\[\s*['"`][A-Za-z_]\w{0,40}['"`]\s*\])?/g
559
+ const DOLLAR_PARTS = /\$\s*(?:\?\.\s*|\.\s*)?(?:\.?\s*([A-Za-z_]\w{0,40})|\[\s*['"`]([A-Za-z_]\w{0,40})['"`]\s*\])(?:\s*(?:\?\.|\.)\s*([A-Za-z_]\w{0,40})|\s*\[\s*['"`]([A-Za-z_]\w{0,40})['"`]\s*\])?/
560
+
561
+ const MODULE_SUFFIXES = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '/index.ts', '/index.tsx', '/index.js']
562
+
563
+ function resolveModuleFile(path: string, realOf: (path: string) => string | undefined): string | undefined {
564
+ for (const suffix of MODULE_SUFFIXES) {
565
+ const real = realOf(path + suffix)
566
+ if (real !== undefined) return real
567
+ }
568
+ // `./x.js` written for a `./x.ts` source.
569
+ const stripped = path.replace(/\.(js|jsx|mjs|cjs)$/, '')
570
+ if (stripped === path) return undefined
571
+ for (const suffix of MODULE_SUFFIXES) {
572
+ const real = realOf(stripped + suffix)
573
+ if (real !== undefined) return real
574
+ }
575
+ return undefined
576
+ }
577
+
578
+ function collectHookCommands(config: Record<string, unknown>, file: string, raw: string, out: HookCommand[]): void {
579
+ const events = isRecord(config.hooks) ? config.hooks : {}
580
+ for (const [event, groups] of Object.entries(events)) {
581
+ if (!Array.isArray(groups)) continue
582
+ for (const group of groups) {
583
+ if (!isRecord(group)) continue
584
+ const matcher = typeof group.matcher === 'string' ? group.matcher : undefined
585
+ for (const hook of Array.isArray(group.hooks) ? group.hooks : []) {
586
+ if (!isRecord(hook)) continue
587
+ const type = typeof hook.type === 'string' ? hook.type : 'command'
588
+ const command =
589
+ typeof hook.command === 'string'
590
+ ? hook.command
591
+ : typeof hook.prompt === 'string'
592
+ ? hook.prompt
593
+ : typeof hook.url === 'string'
594
+ ? hook.url
595
+ : JSON.stringify(hook)
596
+ out.push({ file, line: lineOf(raw, JSON.stringify(command).slice(1, -1)), event, matcher, type, command })
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ function collectMcpServers(config: Record<string, unknown>, file: string, raw: string, out: McpServer[]): void {
603
+ const servers = isRecord(config.mcpServers) ? config.mcpServers : {}
604
+ for (const [name, value] of Object.entries(servers)) {
605
+ if (!isRecord(value)) continue
606
+ out.push({
607
+ name,
608
+ file,
609
+ line: lineOf(raw, JSON.stringify(name)),
610
+ type: typeof value.type === 'string' ? value.type : undefined,
611
+ command: typeof value.command === 'string' ? value.command : undefined,
612
+ args: stringList(value.args),
613
+ url: typeof value.url === 'string' ? value.url : undefined,
614
+ env: stringRecord(value.env),
615
+ headers: stringRecord(value.headers),
616
+ })
617
+ }
618
+ }
619
+
620
+ /** Paths the manifest names for these keys, relative to the root. */
621
+ function manifestPaths(manifest: Record<string, unknown> | undefined, keys: readonly string[]): string[] {
622
+ if (manifest === undefined) return []
623
+ const out: string[] = []
624
+ for (const key of keys) {
625
+ const value = manifest[key]
626
+ const list = typeof value === 'string' ? [value] : stringList(value)
627
+ for (const item of list) {
628
+ const resolved = joinPath('', item)
629
+ if (resolved !== undefined && resolved !== '') out.push(resolved.replace(/\/$/, ''))
630
+ }
631
+ }
632
+ return out
633
+ }
634
+
635
+ export function parseJson(raw: string | undefined): { ok: true; value: unknown } | { ok: false; error: string } {
636
+ if (raw === undefined) return { ok: false, error: 'not readable as text' }
637
+ try {
638
+ return { ok: true, value: JSON.parse(raw) }
639
+ } catch (error) {
640
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
641
+ }
642
+ }
643
+
644
+ export function isRecord(value: unknown): value is Record<string, unknown> {
645
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
646
+ }
647
+
648
+ function stringList(value: unknown): string[] {
649
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
650
+ }
651
+
652
+ function stringRecord(value: unknown): Record<string, string> {
653
+ if (!isRecord(value)) return {}
654
+ return Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'))
655
+ }