licenseguard-cli 1.2.2 → 2.1.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,421 @@
1
+ /**
2
+ * Go Ecosystem Plugin (Go Modules)
3
+ * Scans Go module dependencies for license information
4
+ *
5
+ * Features:
6
+ * - Dynamic module cache location via `go env GOMODCACHE` (AC #7)
7
+ * - Streaming NDJSON parsing via spawn for large projects (AC #8)
8
+ * - Jaccard Index license detection from LICENSE files (AC #6)
9
+ * - SPDX compatibility checking via compat-checker.js (AC #3)
10
+ */
11
+
12
+ const fs = require('fs')
13
+ const path = require('path')
14
+ const { spawn, execSync } = require('child_process')
15
+ const { checkCompatibility } = require('../compat-checker')
16
+ const { showProgress } = require('../progress')
17
+ const { detectLicenseFromText } = require('../license-detector')
18
+
19
+ /**
20
+ * Detect if this is a Go modules project
21
+ * @returns {boolean} True if go.mod exists
22
+ */
23
+ function detect() {
24
+ return fs.existsSync('go.mod')
25
+ }
26
+
27
+ /**
28
+ * Get Go module cache path dynamically via `go env GOMODCACHE`
29
+ * Does NOT hardcode $GOPATH/pkg/mod (AC #7)
30
+ *
31
+ * @returns {string} Module cache directory path
32
+ * @throws {Error} If Go is not installed
33
+ */
34
+ function getGoModuleCachePath() {
35
+ try {
36
+ const cachePath = execSync('go env GOMODCACHE', {
37
+ encoding: 'utf8',
38
+ timeout: 5000,
39
+ stdio: ['pipe', 'pipe', 'pipe']
40
+ }).trim()
41
+
42
+ return cachePath
43
+ } catch (error) {
44
+ if (error.message.includes('command not found') ||
45
+ error.message.includes('not recognized') ||
46
+ error.message.includes('ENOENT')) {
47
+ throw new Error(
48
+ 'Go project detected but Go is not installed.\n\n' +
49
+ 'Install Go: https://go.dev/doc/install\n' +
50
+ 'Or skip scanning: licenseguard init --noscan'
51
+ )
52
+ }
53
+ throw new Error('Failed to get Go module cache path: ' + error.message)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Get Go modules using streaming (spawn) for large projects (AC #8)
59
+ * Processes NDJSON output line-by-line to avoid maxBuffer limits
60
+ *
61
+ * @returns {Promise<Array>} Array of module objects from go list
62
+ * @throws {Error} If Go is not installed or command fails
63
+ */
64
+ async function getGoModulesStreaming() {
65
+ return new Promise((resolve, reject) => {
66
+ const modules = []
67
+ let buffer = ''
68
+ let stderr = ''
69
+
70
+ const goList = spawn('go', ['list', '-m', '-json', 'all'], {
71
+ cwd: process.cwd(),
72
+ stdio: ['pipe', 'pipe', 'pipe']
73
+ })
74
+
75
+ // Set timeout (30 seconds for large projects like Kubernetes)
76
+ const timeout = setTimeout(() => {
77
+ goList.kill('SIGTERM')
78
+ reject(new Error(
79
+ 'Go module listing timed out after 30 seconds.\n\n' +
80
+ 'This may indicate a very large project or network issues.\n' +
81
+ 'Try running `go mod download` first to cache dependencies.'
82
+ ))
83
+ }, 30000)
84
+
85
+ goList.stdout.on('data', (chunk) => {
86
+ buffer += chunk.toString()
87
+
88
+ // Process complete JSON objects (NDJSON format)
89
+ // Go outputs one JSON object per line, but objects may span multiple lines
90
+ // We need to parse complete JSON objects
91
+ let startIdx = 0
92
+ let braceCount = 0
93
+ let inString = false
94
+ let escape = false
95
+
96
+ for (let i = 0; i < buffer.length; i++) {
97
+ const char = buffer[i]
98
+
99
+ if (escape) {
100
+ escape = false
101
+ continue
102
+ }
103
+
104
+ if (char === '\\' && inString) {
105
+ escape = true
106
+ continue
107
+ }
108
+
109
+ if (char === '"') {
110
+ inString = !inString
111
+ continue
112
+ }
113
+
114
+ if (inString) continue
115
+
116
+ if (char === '{') {
117
+ if (braceCount === 0) startIdx = i
118
+ braceCount++
119
+ } else if (char === '}') {
120
+ braceCount--
121
+ if (braceCount === 0) {
122
+ // Complete JSON object found
123
+ const jsonStr = buffer.substring(startIdx, i + 1)
124
+ try {
125
+ const module = JSON.parse(jsonStr)
126
+ modules.push(module)
127
+ } catch (parseErr) {
128
+ // Skip malformed JSON
129
+ }
130
+ startIdx = i + 1
131
+ }
132
+ }
133
+ }
134
+
135
+ // Keep incomplete data in buffer
136
+ buffer = buffer.substring(startIdx)
137
+ })
138
+
139
+ goList.stderr.on('data', (chunk) => {
140
+ stderr += chunk.toString()
141
+ })
142
+
143
+ goList.on('error', (error) => {
144
+ clearTimeout(timeout)
145
+ if (error.code === 'ENOENT') {
146
+ reject(new Error(
147
+ 'Go project detected but Go is not installed.\n\n' +
148
+ 'Install Go: https://go.dev/doc/install\n' +
149
+ 'Or skip scanning: licenseguard init --noscan'
150
+ ))
151
+ } else {
152
+ reject(new Error('Failed to run go list: ' + error.message))
153
+ }
154
+ })
155
+
156
+ goList.on('close', (code) => {
157
+ clearTimeout(timeout)
158
+
159
+ if (code !== 0) {
160
+ // Check for common error patterns
161
+ if (stderr.includes('go.mod file not found')) {
162
+ reject(new Error(
163
+ 'No go.mod found in current directory.\n\n' +
164
+ 'Make sure you are in a Go modules project directory.'
165
+ ))
166
+ } else if (stderr.includes('not a valid module')) {
167
+ reject(new Error('Invalid Go module: ' + stderr))
168
+ } else {
169
+ reject(new Error(`go list exited with code ${code}: ${stderr}`))
170
+ }
171
+ return
172
+ }
173
+
174
+ resolve(modules)
175
+ })
176
+ })
177
+ }
178
+
179
+ /**
180
+ * Synchronous fallback for getting Go modules (for smaller projects)
181
+ * @returns {Array} Array of module objects
182
+ */
183
+ function getGoModulesSync() {
184
+ try {
185
+ const output = execSync('go list -m -json all', {
186
+ encoding: 'utf8',
187
+ timeout: 30000,
188
+ cwd: process.cwd(),
189
+ maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large projects
190
+ stdio: ['pipe', 'pipe', 'pipe']
191
+ })
192
+
193
+ // Parse NDJSON (newline-delimited JSON)
194
+ const modules = []
195
+ let buffer = ''
196
+ let braceCount = 0
197
+ let startIdx = 0
198
+ let inString = false
199
+ let escape = false
200
+
201
+ for (let i = 0; i < output.length; i++) {
202
+ const char = output[i]
203
+
204
+ if (escape) {
205
+ escape = false
206
+ continue
207
+ }
208
+
209
+ if (char === '\\' && inString) {
210
+ escape = true
211
+ continue
212
+ }
213
+
214
+ if (char === '"') {
215
+ inString = !inString
216
+ continue
217
+ }
218
+
219
+ if (inString) continue
220
+
221
+ if (char === '{') {
222
+ if (braceCount === 0) startIdx = i
223
+ braceCount++
224
+ } else if (char === '}') {
225
+ braceCount--
226
+ if (braceCount === 0) {
227
+ const jsonStr = output.substring(startIdx, i + 1)
228
+ try {
229
+ modules.push(JSON.parse(jsonStr))
230
+ } catch (parseErr) {
231
+ // Skip malformed JSON
232
+ }
233
+ startIdx = i + 1
234
+ }
235
+ }
236
+ }
237
+
238
+ return modules
239
+ } catch (error) {
240
+ if (error.message.includes('command not found') ||
241
+ error.message.includes('not recognized') ||
242
+ error.message.includes('ENOENT')) {
243
+ throw new Error(
244
+ 'Go project detected but Go is not installed.\n\n' +
245
+ 'Install Go: https://go.dev/doc/install\n' +
246
+ 'Or skip scanning: licenseguard init --noscan'
247
+ )
248
+ }
249
+
250
+ if (error.killed) {
251
+ throw new Error(
252
+ 'Go module listing timed out.\n\n' +
253
+ 'This may indicate a very large project.\n' +
254
+ 'Try running `go mod download` first.'
255
+ )
256
+ }
257
+
258
+ throw new Error('Failed to get Go modules: ' + error.message)
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Extract license from Go module in cache using Jaccard Index detection (AC #6)
264
+ *
265
+ * @param {Object} module - Module object with Path, Version, Dir
266
+ * @param {string} cachePath - Go module cache path
267
+ * @returns {string} SPDX license identifier or 'UNKNOWN'
268
+ */
269
+ function extractGoLicense(module, cachePath) {
270
+ // Priority 1: Use Dir from go list output if available
271
+ let modulePath = module.Dir
272
+
273
+ // Priority 2: Construct path from cache + module path + version
274
+ if (!modulePath && cachePath && module.Path && module.Version) {
275
+ // Go module cache uses lowercase paths and @ for version separator
276
+ // e.g., /home/user/go/pkg/mod/github.com/gin-gonic/gin@v1.9.1
277
+ modulePath = path.join(cachePath, `${module.Path}@${module.Version}`)
278
+ }
279
+
280
+ if (!modulePath) {
281
+ return 'UNKNOWN'
282
+ }
283
+
284
+ // License file names to check (in order of preference)
285
+ const licenseFiles = [
286
+ 'LICENSE',
287
+ 'LICENSE.txt',
288
+ 'LICENSE.md',
289
+ 'LICENCE', // British spelling
290
+ 'LICENCE.txt',
291
+ 'LICENCE.md',
292
+ 'COPYING',
293
+ 'COPYING.txt',
294
+ 'MIT-LICENSE',
295
+ 'MIT-LICENSE.txt',
296
+ 'Apache-License-2.0.txt'
297
+ ]
298
+
299
+ for (const filename of licenseFiles) {
300
+ const licensePath = path.join(modulePath, filename)
301
+ try {
302
+ if (fs.existsSync(licensePath)) {
303
+ const content = fs.readFileSync(licensePath, 'utf8')
304
+ // Use Jaccard Index algorithm from license-detector.js (AC #6)
305
+ const detected = detectLicenseFromText(content)
306
+ if (detected && detected !== 'UNKNOWN') {
307
+ return detected
308
+ }
309
+ }
310
+ } catch (err) {
311
+ // Permission error or other issue - continue to next file
312
+ continue
313
+ }
314
+ }
315
+
316
+ return 'UNKNOWN'
317
+ }
318
+
319
+ /**
320
+ * Filter modules to only include actual dependencies (not the root module)
321
+ *
322
+ * @param {Array} modules - Array of module objects from go list
323
+ * @returns {Array} Filtered array of dependency modules
324
+ */
325
+ function filterDependencies(modules) {
326
+ if (!modules || modules.length === 0) {
327
+ return []
328
+ }
329
+
330
+ // The first module is typically the root module (the project itself)
331
+ // It has Main: true flag
332
+ return modules.filter(mod => {
333
+ // Skip the main/root module
334
+ if (mod.Main === true) {
335
+ return false
336
+ }
337
+
338
+ // Skip modules without Path (shouldn't happen, but safety check)
339
+ if (!mod.Path) {
340
+ return false
341
+ }
342
+
343
+ return true
344
+ })
345
+ }
346
+
347
+ /**
348
+ * Scan all Go module dependencies for license compatibility
349
+ *
350
+ * @param {string} projectLicense - The project's license (SPDX format)
351
+ * @returns {Promise<Object>} Scan results matching plugin interface
352
+ */
353
+ async function scanDependencies(projectLicense) {
354
+ // Get module cache path dynamically (AC #7)
355
+ const cachePath = getGoModuleCachePath()
356
+
357
+ // Get modules using streaming for large projects (AC #8)
358
+ let allModules
359
+ try {
360
+ allModules = await getGoModulesStreaming()
361
+ } catch (streamErr) {
362
+ // Fallback to sync method for compatibility
363
+ allModules = getGoModulesSync()
364
+ }
365
+
366
+ // Filter to only actual dependencies
367
+ const modules = filterDependencies(allModules)
368
+
369
+ const results = {
370
+ timestamp: new Date().toISOString(),
371
+ totalDependencies: modules.length,
372
+ compatible: 0,
373
+ incompatible: 0,
374
+ unknown: 0,
375
+ issues: []
376
+ }
377
+
378
+ // Scan each dependency
379
+ for (let i = 0; i < modules.length; i++) {
380
+ showProgress(i + 1, modules.length)
381
+
382
+ const mod = modules[i]
383
+ const license = extractGoLicense(mod, cachePath)
384
+
385
+ // Check compatibility using universal compat-checker
386
+ const compatResult = checkCompatibility(projectLicense, license)
387
+
388
+ if (!compatResult.compatible) {
389
+ const isUnknown = license === 'UNKNOWN'
390
+
391
+ if (isUnknown) {
392
+ results.unknown++
393
+ } else {
394
+ results.incompatible++
395
+ }
396
+
397
+ results.issues.push({
398
+ package: `${mod.Path}@${mod.Version}`,
399
+ license: license,
400
+ type: isUnknown ? 'warning' : 'conflict',
401
+ reason: compatResult.reason,
402
+ location: mod.Dir || `${cachePath}/${mod.Path}@${mod.Version}`
403
+ })
404
+ } else {
405
+ results.compatible++
406
+ }
407
+ }
408
+
409
+ return results
410
+ }
411
+
412
+ module.exports = {
413
+ detect,
414
+ scanDependencies,
415
+ // Export internal functions for testing
416
+ getGoModuleCachePath,
417
+ getGoModulesStreaming,
418
+ getGoModulesSync,
419
+ extractGoLicense,
420
+ filterDependencies
421
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Node.js Ecosystem Plugin
3
+ * Scans node_modules for license information
4
+ */
5
+
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+ const { checkCompatibility } = require('../compat-checker')
9
+ const { showProgress } = require('../progress')
10
+
11
+ /**
12
+ * Detect if this is a Node.js project
13
+ * @returns {boolean} True if package.json exists
14
+ */
15
+ function detect() {
16
+ return fs.existsSync('package.json')
17
+ }
18
+
19
+ /**
20
+ * Parse package.json to get dependencies
21
+ * @returns {{deps: string[], packageJson: Object}} Dependency list and package.json
22
+ */
23
+ function parsePackageJson() {
24
+ try {
25
+ const content = fs.readFileSync('package.json', 'utf8')
26
+ const packageJson = JSON.parse(content)
27
+
28
+ // Only scan production dependencies
29
+ const deps = Object.keys(packageJson.dependencies || {})
30
+
31
+ return { deps, packageJson }
32
+ } catch (error) {
33
+ throw new Error('Failed to read package.json: ' + error.message)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Extract license info from a dependency
39
+ * @param {string} depName - Dependency name
40
+ * @returns {{name: string, version: string, license: string, path: string}} License info
41
+ */
42
+ function extractLicense(depName) {
43
+ const depPath = path.join('node_modules', depName, 'package.json')
44
+
45
+ if (!fs.existsSync(depPath)) {
46
+ return {
47
+ name: depName,
48
+ version: 'unknown',
49
+ license: 'NOT_INSTALLED',
50
+ path: depPath
51
+ }
52
+ }
53
+
54
+ try {
55
+ const content = fs.readFileSync(depPath, 'utf8')
56
+ const depPackageJson = JSON.parse(content)
57
+
58
+ return {
59
+ name: depName,
60
+ version: depPackageJson.version || 'unknown',
61
+ license: depPackageJson.license || 'UNKNOWN',
62
+ path: depPath
63
+ }
64
+ } catch (error) {
65
+ return {
66
+ name: depName,
67
+ version: 'unknown',
68
+ license: 'PARSE_ERROR',
69
+ path: depPath
70
+ }
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Scan all Node.js dependencies for license compatibility
76
+ * @param {string} projectLicense - The project's license
77
+ * @returns {Promise<Object>} Scan results
78
+ */
79
+ async function scanDependencies(projectLicense) {
80
+ // 1. Read project package.json
81
+ const { deps } = parsePackageJson()
82
+
83
+ const results = {
84
+ timestamp: new Date().toISOString(),
85
+ totalDependencies: deps.length,
86
+ compatible: 0,
87
+ incompatible: 0,
88
+ unknown: 0,
89
+ issues: []
90
+ }
91
+
92
+ // 2. Scan each dependency
93
+ for (let i = 0; i < deps.length; i++) {
94
+ showProgress(i + 1, deps.length)
95
+
96
+ const depName = deps[i]
97
+ const depInfo = extractLicense(depName)
98
+
99
+ // Skip if not installed
100
+ if (depInfo.license === 'NOT_INSTALLED') {
101
+ continue
102
+ }
103
+
104
+ // Handle parse errors as unknown
105
+ if (depInfo.license === 'PARSE_ERROR') {
106
+ results.unknown++
107
+ results.issues.push({
108
+ package: `${depName}@${depInfo.version}`,
109
+ license: 'UNKNOWN',
110
+ type: 'warning',
111
+ reason: 'Failed to parse package.json',
112
+ location: depInfo.path
113
+ })
114
+ continue
115
+ }
116
+
117
+ // 3. Check compatibility
118
+ const compatResult = checkCompatibility(projectLicense, depInfo.license)
119
+
120
+ if (!compatResult.compatible) {
121
+ const isUnknown = depInfo.license === 'UNKNOWN'
122
+
123
+ if (isUnknown) {
124
+ results.unknown++
125
+ } else {
126
+ results.incompatible++
127
+ }
128
+
129
+ results.issues.push({
130
+ package: `${depName}@${depInfo.version}`,
131
+ license: depInfo.license,
132
+ type: isUnknown ? 'warning' : 'conflict',
133
+ reason: compatResult.reason,
134
+ location: depInfo.path
135
+ })
136
+ } else {
137
+ results.compatible++
138
+ }
139
+ }
140
+
141
+ return results
142
+ }
143
+
144
+ module.exports = {
145
+ detect,
146
+ scanDependencies,
147
+ parsePackageJson,
148
+ extractLicense
149
+ }