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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +11 -2
- package/CHANGELOG.md +47 -1
- package/README.md +96 -9
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +100 -0
- package/hooks/guard/pane.tsx +253 -0
- package/hooks/guard/view.ts +87 -0
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +171 -21
- package/hooks/scan/checks/code.ts +638 -0
- package/hooks/scan/checks/context.ts +655 -0
- package/hooks/scan/checks/hooks.ts +156 -0
- package/hooks/scan/checks/magic.ts +40 -0
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +374 -0
- package/hooks/scan/checks/patterns.ts +107 -0
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +319 -0
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/quality.ts +56 -0
- package/hooks/scan/checks/secrets.ts +107 -0
- package/hooks/scan/checks/structure.ts +449 -0
- package/hooks/scan/checks/text.ts +362 -0
- package/hooks/scan/checks/types.ts +105 -0
- package/hooks/scan/checks/validator.ts +111 -0
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +406 -0
- package/hooks/scan/report.ts +62 -31
- package/hooks/scan/scan.ts +360 -0
- package/hooks/scan/verdict.ts +64 -0
- package/package.json +8 -3
|
@@ -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
|
+
}
|