dsh-capdiff 0.0.0-stage → 0.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.
package/src/cli.js ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-capdiff CLI. Argument parsing and IO only; all analysis lives in core.js.
4
+ */
5
+
6
+ import { readFile } from 'node:fs/promises'
7
+ import { relative, resolve } from 'node:path'
8
+ import { parseArgs } from 'node:util'
9
+
10
+ import {
11
+ TOOL_NAME,
12
+ TOOL_VERSION,
13
+ buildFindings,
14
+ diffCapabilities,
15
+ parseDeclared,
16
+ scanTarget,
17
+ toSarif,
18
+ } from './core.js'
19
+
20
+ const USAGE = `Usage: ${TOOL_NAME} <file-or-dir> [options]
21
+
22
+ Scan .js/.mjs/.cjs files for statically visible capabilities and compare them
23
+ with the dsh.capabilities entries in a plugin manifest.
24
+
25
+ Options:
26
+ --manifest <path> Manifest JSON containing dsh.capabilities
27
+ --json Print a machine-readable JSON report
28
+ --sarif Print a SARIF 2.1.0 log
29
+ -h, --help Show this help
30
+ -V, --version Show version
31
+
32
+ Directories are scanned recursively; node_modules and .git are skipped.
33
+ This is a static heuristic: code is never executed, and a clean report is not
34
+ proof that a plugin is safe.
35
+
36
+ Exit codes: 0 = scan completed (even with findings), 1 = unused (no business-negative
37
+ result; findings do not fail the command), 2 = bad arguments, input, or IO.`
38
+
39
+ /**
40
+ * @param {string|null|undefined} file
41
+ * @returns {string}
42
+ */
43
+ function displayPath(file) {
44
+ if (!file) return '(none)'
45
+ const rel = relative(process.cwd(), file)
46
+ if (!rel) return '.'
47
+ return rel.startsWith('..') ? file : rel
48
+ }
49
+
50
+ /**
51
+ * @param {string[]} values
52
+ * @returns {string}
53
+ */
54
+ function formatList(values) {
55
+ return values.length ? values.join(', ') : '(none)'
56
+ }
57
+
58
+ /**
59
+ * @param {{
60
+ * target: string,
61
+ * manifest: string|null,
62
+ * filesScanned: number,
63
+ * declared: string[],
64
+ * actual: string[],
65
+ * diff: { matched: string[], undeclared: string[], unused: string[] },
66
+ * findings: Array<{ kind: string, capability: string, file?: string, line?: number, evidence?: string }>
67
+ * }} result
68
+ * @returns {string}
69
+ */
70
+ function formatHuman(result) {
71
+ const lines = []
72
+ lines.push(`${TOOL_NAME} ${TOOL_VERSION} - static capability diff (heuristic)`)
73
+ lines.push(`Target: ${displayPath(result.target)}`)
74
+ lines.push(
75
+ `Manifest: ${result.manifest ? displayPath(result.manifest) : '(none; pass --manifest to compare)'}`,
76
+ )
77
+ lines.push(`Files: ${result.filesScanned} source file(s) scanned`)
78
+ lines.push(`Declared: ${formatList(result.declared)}`)
79
+ lines.push(`Detected: ${formatList(result.actual)}`)
80
+ lines.push('')
81
+
82
+ if (result.diff.undeclared.length) {
83
+ lines.push(`Undeclared (${result.diff.undeclared.length}) - used in code, absent from manifest:`)
84
+ for (const finding of result.findings) {
85
+ if (finding.kind !== 'undeclared') continue
86
+ const where = finding.file
87
+ ? `${displayPath(finding.file)}${finding.line ? `:${finding.line}` : ''}`
88
+ : 'scanned code'
89
+ const trigger = finding.evidence ? `, ${finding.evidence}` : ''
90
+ lines.push(` - ${finding.capability} (${where}${trigger})`)
91
+ }
92
+ lines.push('')
93
+ }
94
+
95
+ if (result.diff.unused.length) {
96
+ lines.push(
97
+ `Unused declarations (${result.diff.unused.length}) - declared, not statically visible:`,
98
+ )
99
+ for (const finding of result.findings) {
100
+ if (finding.kind !== 'unused') continue
101
+ lines.push(` - ${finding.capability}`)
102
+ }
103
+ lines.push('')
104
+ }
105
+
106
+ if (result.diff.matched.length) {
107
+ lines.push(`Matched (${result.diff.matched.length}): ${formatList(result.diff.matched)}`)
108
+ lines.push('')
109
+ }
110
+
111
+ if (!result.diff.undeclared.length && !result.diff.unused.length) {
112
+ lines.push('No static capability differences found.')
113
+ lines.push('')
114
+ }
115
+
116
+ if (result.filesScanned === 0) {
117
+ lines.push('Warning: no .js/.mjs/.cjs files were found.')
118
+ lines.push('')
119
+ }
120
+
121
+ lines.push('Note: static heuristic only - "matched" and zero findings are not proof of safety.')
122
+ return lines.join('\n')
123
+ }
124
+
125
+ /**
126
+ * @returns {Promise<number>} process exit code
127
+ */
128
+ async function main() {
129
+ let parsed
130
+ try {
131
+ parsed = parseArgs({
132
+ allowPositionals: true,
133
+ options: {
134
+ help: { type: 'boolean', short: 'h' },
135
+ version: { type: 'boolean', short: 'V' },
136
+ manifest: { type: 'string' },
137
+ json: { type: 'boolean' },
138
+ sarif: { type: 'boolean' },
139
+ },
140
+ })
141
+ } catch (error) {
142
+ console.error(`${TOOL_NAME}: ${/** @type {Error} */ (error).message}`)
143
+ console.error(`Try '${TOOL_NAME} --help'.`)
144
+ return 2
145
+ }
146
+
147
+ const { values, positionals } = parsed
148
+
149
+ if (values.help) {
150
+ console.log(USAGE)
151
+ return 0
152
+ }
153
+ if (values.version) {
154
+ console.log(`${TOOL_NAME} ${TOOL_VERSION}`)
155
+ return 0
156
+ }
157
+ if (positionals.length === 0) {
158
+ console.error(USAGE)
159
+ return 2
160
+ }
161
+ if (positionals.length > 1) {
162
+ console.error(`${TOOL_NAME}: expected exactly one <file-or-dir> argument.`)
163
+ console.error(`Try '${TOOL_NAME} --help'.`)
164
+ return 2
165
+ }
166
+ if (values.json && values.sarif) {
167
+ console.error(`${TOOL_NAME}: choose either --json or --sarif, not both.`)
168
+ return 2
169
+ }
170
+
171
+ const targetArg = positionals[0]
172
+ /** @type {string|null} */
173
+ let manifestPath = null
174
+ /** @type {string[]} */
175
+ let declared = []
176
+
177
+ if (values.manifest !== undefined) {
178
+ if (!values.manifest) {
179
+ console.error(`${TOOL_NAME}: --manifest requires a non-empty path.`)
180
+ return 2
181
+ }
182
+ manifestPath = resolve(values.manifest)
183
+ let text
184
+ try {
185
+ text = await readFile(manifestPath, 'utf8')
186
+ } catch (error) {
187
+ const detail =
188
+ /** @type {NodeJS.ErrnoException} */ (error).code ?? /** @type {Error} */ (error).message
189
+ console.error(`${TOOL_NAME}: cannot read manifest "${values.manifest}": ${detail}`)
190
+ return 2
191
+ }
192
+
193
+ let manifest
194
+ try {
195
+ manifest = JSON.parse(text)
196
+ } catch (error) {
197
+ console.error(
198
+ `${TOOL_NAME}: manifest "${values.manifest}" is not valid JSON: ${/** @type {Error} */ (error).message}`,
199
+ )
200
+ return 2
201
+ }
202
+ declared = parseDeclared(manifest)
203
+ }
204
+
205
+ let report
206
+ try {
207
+ report = await scanTarget(targetArg)
208
+ } catch (error) {
209
+ const err = /** @type {NodeJS.ErrnoException} */ (error)
210
+ const detail = err.code === 'ENOENT' ? 'path does not exist' : err.message
211
+ console.error(`${TOOL_NAME}: cannot scan "${targetArg}": ${detail}`)
212
+ return 2
213
+ }
214
+
215
+ const diff = diffCapabilities(declared, report.capabilities)
216
+ const findings = buildFindings(diff, report.firstEvidence, {
217
+ manifestFile: manifestPath ?? undefined,
218
+ target: report.target,
219
+ })
220
+
221
+ if (values.sarif) {
222
+ const sarif = toSarif(findings, { file: manifestPath ?? undefined })
223
+ console.log(JSON.stringify(sarif, null, 2))
224
+ return 0
225
+ }
226
+
227
+ const result = {
228
+ command: 'scan',
229
+ ok: true,
230
+ tool: TOOL_NAME,
231
+ version: TOOL_VERSION,
232
+ target: report.target,
233
+ manifest: manifestPath,
234
+ filesScanned: report.files.length,
235
+ declared,
236
+ actual: report.capabilities,
237
+ diff,
238
+ findings,
239
+ }
240
+
241
+ if (values.json) {
242
+ console.log(JSON.stringify(result, null, 2))
243
+ return 0
244
+ }
245
+
246
+ console.log(formatHuman(result))
247
+ return 0
248
+ }
249
+
250
+ main()
251
+ .then((code) => {
252
+ process.exitCode = code
253
+ })
254
+ .catch((error) => {
255
+ console.error(`${TOOL_NAME}: unexpected failure: ${error?.stack ?? error}`)
256
+ process.exitCode = 2
257
+ })