rankforge 0.3.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/LICENSE +21 -0
- package/README.md +30 -0
- package/package.json +49 -0
- package/src/audit-output-schema.mjs +88 -0
- package/src/audit.mjs +202 -0
- package/src/cli.mjs +508 -0
- package/src/config-schema.mjs +292 -0
- package/src/crawl.mjs +188 -0
- package/src/finding-task.mjs +9 -0
- package/src/html-extract.mjs +226 -0
- package/src/index.mjs +9 -0
- package/src/integrations.mjs +78 -0
- package/src/io-guards.mjs +196 -0
- package/src/performance.mjs +112 -0
- package/src/regex-guards.mjs +52 -0
- package/src/render-parity.mjs +149 -0
- package/src/render.mjs +45 -0
- package/src/repo-audit.mjs +429 -0
- package/src/repo-detect.mjs +87 -0
- package/src/repo-findings.mjs +9 -0
- package/src/repo-manifests.mjs +169 -0
- package/src/repo-process.mjs +298 -0
- package/src/repo-routes.mjs +46 -0
- package/src/report.mjs +898 -0
- package/src/robots.mjs +60 -0
- package/src/rule-depth.mjs +190 -0
- package/src/rule-engine.mjs +360 -0
- package/src/rules.mjs +350 -0
- package/src/site-rule-engine.mjs +177 -0
- package/src/sitemap.mjs +30 -0
- package/src/snapshot.mjs +119 -0
- package/src/source-map.json +28 -0
- package/src/structured-data.mjs +59 -0
- package/src/url-utils.mjs +25 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { runAudit } from "./audit.mjs";
|
|
4
|
+
import { readAuditConfig, resolveAuditConfigPaths, validateAuditConfig } from "./config-schema.mjs";
|
|
5
|
+
import { generateHtmlReport, generateMarkdownReport } from "./report.mjs";
|
|
6
|
+
import { runRepoAudit } from "./repo-audit.mjs";
|
|
7
|
+
import { detectRepo } from "./repo-detect.mjs";
|
|
8
|
+
import { getRule } from "./rules.mjs";
|
|
9
|
+
import { collectSnapshot } from "./snapshot.mjs";
|
|
10
|
+
|
|
11
|
+
const version = "rankforge 0.3.0";
|
|
12
|
+
|
|
13
|
+
const help = `Usage: rankforge <command> [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
audit <target> Run a deterministic GEO/SEO readiness audit
|
|
17
|
+
snapshot <target> Capture single-page audit evidence
|
|
18
|
+
detect-repo [path] Inspect source repository audit metadata; defaults to current directory
|
|
19
|
+
audit-repo <path> Audit static output or explicit preview server from a source repo
|
|
20
|
+
validate-config <file> Validate an audit.config.json file
|
|
21
|
+
explain-rule <rule-id> Print rule metadata and citations as JSON
|
|
22
|
+
|
|
23
|
+
Audit options:
|
|
24
|
+
--config <file> Read audit options from an audit.config.json file
|
|
25
|
+
--mode full|sample|single Crawl mode
|
|
26
|
+
--max-pages <n> Maximum pages to crawl
|
|
27
|
+
--max-depth <n> Maximum crawl depth
|
|
28
|
+
--sitemap <url> Seed crawl with a sitemap URL
|
|
29
|
+
--url-list <file> Audit URLs listed one per line
|
|
30
|
+
--respect-robots true|false Skip robots-disallowed URLs when true
|
|
31
|
+
--render auto|always|never Render pages when Playwright or a renderer is available
|
|
32
|
+
--search-console <file> Import Google Search Console CSV evidence
|
|
33
|
+
--serp <file> Import SERP JSON evidence
|
|
34
|
+
--ai-answers <file> Import AI answer JSON evidence
|
|
35
|
+
--lighthouse <file> Import Lighthouse JSON performance evidence
|
|
36
|
+
--security local|restricted Apply local CLI or restricted wrapper network/file policy
|
|
37
|
+
--timeout-ms <n> Per-request timeout in milliseconds
|
|
38
|
+
--max-html-bytes <n> Maximum HTML bytes to read per page
|
|
39
|
+
--max-text-bytes <n> Maximum robots/sitemap text bytes to read
|
|
40
|
+
--max-file-bytes <n> Maximum URL-list file bytes to read
|
|
41
|
+
--max-integration-bytes <n> Maximum imported evidence file bytes to read
|
|
42
|
+
--fail-on <severity> Return exit code 2 when findings include P0, P1, P2, or P3 threshold
|
|
43
|
+
--out <file> Write audit JSON
|
|
44
|
+
--markdown <file> Write Markdown report
|
|
45
|
+
--html <file> Write standalone HTML report
|
|
46
|
+
--help Show this help
|
|
47
|
+
--version Show CLI version
|
|
48
|
+
|
|
49
|
+
Repo audit options:
|
|
50
|
+
--config <file> Read repo audit options from an audit.config.json file
|
|
51
|
+
--static-dir <dir> Audit prebuilt static HTML output relative to repo path
|
|
52
|
+
--build-command <command> Run an explicit local build command before auditing static output
|
|
53
|
+
--max-build-ms <n> Maximum time to wait for build command completion
|
|
54
|
+
--preview-command <command> Start an explicit local preview server command
|
|
55
|
+
--preview-url <url> URL to wait for and audit after preview startup
|
|
56
|
+
--max-preview-ms <n> Maximum time to wait for preview startup
|
|
57
|
+
--route-list <file> Audit generated routes listed one per line
|
|
58
|
+
--mode full|sample|single Crawl mode for preview audits
|
|
59
|
+
--max-pages <n> Maximum pages to crawl for preview audits
|
|
60
|
+
--max-depth <n> Maximum crawl depth for preview audits
|
|
61
|
+
--security local|restricted Apply local CLI or restricted wrapper network/file policy
|
|
62
|
+
--fail-on <severity> Return exit code 2 when repo or page findings meet P0, P1, P2, or P3 threshold
|
|
63
|
+
--out <file> Write repository audit JSON
|
|
64
|
+
--markdown <file> Write repository audit Markdown report
|
|
65
|
+
--html <file> Write repository audit HTML report
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const writeJson = (io, value) => {
|
|
69
|
+
io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const optionValue = (options, name) => {
|
|
73
|
+
const index = options.indexOf(name);
|
|
74
|
+
return index === -1 ? null : options[index + 1] || null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const outputFileOption = (options, name) => {
|
|
78
|
+
const index = options.indexOf(name);
|
|
79
|
+
if (index === -1) return null;
|
|
80
|
+
const value = options[index + 1];
|
|
81
|
+
if (!value || value.startsWith("--")) throw new Error(`${name} requires a file path.`);
|
|
82
|
+
return value;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const auditOptionsWithValues = new Set([
|
|
86
|
+
"--config",
|
|
87
|
+
"--mode",
|
|
88
|
+
"--max-pages",
|
|
89
|
+
"--max-depth",
|
|
90
|
+
"--sitemap",
|
|
91
|
+
"--url-list",
|
|
92
|
+
"--respect-robots",
|
|
93
|
+
"--render",
|
|
94
|
+
"--search-console",
|
|
95
|
+
"--serp",
|
|
96
|
+
"--ai-answers",
|
|
97
|
+
"--lighthouse",
|
|
98
|
+
"--security",
|
|
99
|
+
"--timeout-ms",
|
|
100
|
+
"--max-html-bytes",
|
|
101
|
+
"--max-text-bytes",
|
|
102
|
+
"--max-file-bytes",
|
|
103
|
+
"--max-integration-bytes",
|
|
104
|
+
"--fail-on",
|
|
105
|
+
"--out",
|
|
106
|
+
"--markdown",
|
|
107
|
+
"--html",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const repoOptionsWithValues = new Set([
|
|
111
|
+
"--config",
|
|
112
|
+
"--static-dir",
|
|
113
|
+
"--build-command",
|
|
114
|
+
"--max-build-ms",
|
|
115
|
+
"--preview-command",
|
|
116
|
+
"--preview-url",
|
|
117
|
+
"--max-preview-ms",
|
|
118
|
+
"--route-list",
|
|
119
|
+
"--mode",
|
|
120
|
+
"--max-pages",
|
|
121
|
+
"--max-depth",
|
|
122
|
+
"--security",
|
|
123
|
+
"--fail-on",
|
|
124
|
+
"--out",
|
|
125
|
+
"--markdown",
|
|
126
|
+
"--html",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const severityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
130
|
+
|
|
131
|
+
const failsThreshold = (findings, threshold) => {
|
|
132
|
+
if (!threshold) return false;
|
|
133
|
+
if (!(threshold in severityRank)) throw new Error("--fail-on must be one of: P0, P1, P2, P3");
|
|
134
|
+
return (findings || []).some((finding) => severityRank[finding.severity] <= severityRank[threshold]);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const splitAuditArgs = (args) => {
|
|
138
|
+
const options = [];
|
|
139
|
+
let target = null;
|
|
140
|
+
|
|
141
|
+
for (let index = 0; index < args.length; index++) {
|
|
142
|
+
const arg = args[index];
|
|
143
|
+
if (auditOptionsWithValues.has(arg)) {
|
|
144
|
+
options.push(arg);
|
|
145
|
+
if (index + 1 < args.length) options.push(args[++index]);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (arg.startsWith("--")) {
|
|
149
|
+
options.push(arg);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!target) target = arg;
|
|
153
|
+
else options.push(arg);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { target, options };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const splitRepoArgs = (args) => {
|
|
160
|
+
const options = [];
|
|
161
|
+
let repoPath = null;
|
|
162
|
+
|
|
163
|
+
for (let index = 0; index < args.length; index++) {
|
|
164
|
+
const arg = args[index];
|
|
165
|
+
if (repoOptionsWithValues.has(arg)) {
|
|
166
|
+
options.push(arg);
|
|
167
|
+
if (index + 1 < args.length && !args[index + 1].startsWith("--")) options.push(args[++index]);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (arg.startsWith("--")) {
|
|
171
|
+
options.push(arg);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!repoPath) repoPath = arg;
|
|
175
|
+
else options.push(arg);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { repoPath, options };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const numberOption = (options, name, fallback) => {
|
|
182
|
+
const value = optionValue(options, name);
|
|
183
|
+
return value ? Number(value) : fallback;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const repoOptionValue = (options, name, fallback = null, errorMessage = `${name} requires a value.`) => {
|
|
187
|
+
const index = options.indexOf(name);
|
|
188
|
+
if (index === -1) return fallback;
|
|
189
|
+
const value = options[index + 1];
|
|
190
|
+
if (!value || value.startsWith("--")) throw new Error(errorMessage);
|
|
191
|
+
return value;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const repoEnumOption = (options, name, fallback, allowedValues) => {
|
|
195
|
+
const value = repoOptionValue(options, name, fallback);
|
|
196
|
+
if (!allowedValues.includes(value)) throw new Error(`${name} must be one of: ${allowedValues.join(", ")}`);
|
|
197
|
+
return value;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const repoNumberOption = (options, name, fallback, { minimum, minimumDescription }) => {
|
|
201
|
+
const index = options.indexOf(name);
|
|
202
|
+
if (index === -1) return fallback;
|
|
203
|
+
const value = repoOptionValue(options, name);
|
|
204
|
+
const number = Number(value);
|
|
205
|
+
if (!Number.isFinite(number)) throw new Error(`${name} must be a number.`);
|
|
206
|
+
if (!Number.isInteger(number) || number < minimum) {
|
|
207
|
+
throw new Error(`${name} must be a ${minimumDescription}.`);
|
|
208
|
+
}
|
|
209
|
+
return number;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const mergeAuditConfig = (target, options) => {
|
|
213
|
+
const configPath = optionValue(options, "--config");
|
|
214
|
+
const baseDir = configPath ? path.dirname(path.resolve(configPath)) : process.cwd();
|
|
215
|
+
const fileConfig = configPath ? resolveAuditConfigPaths(readAuditConfig(configPath), baseDir) : {};
|
|
216
|
+
const merged = {
|
|
217
|
+
...fileConfig,
|
|
218
|
+
target: target || fileConfig.target,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const mode = optionValue(options, "--mode");
|
|
222
|
+
const maxPages = optionValue(options, "--max-pages");
|
|
223
|
+
const maxDepth = optionValue(options, "--max-depth");
|
|
224
|
+
const sitemap = optionValue(options, "--sitemap");
|
|
225
|
+
const urlList = optionValue(options, "--url-list");
|
|
226
|
+
const respectRobots = optionValue(options, "--respect-robots");
|
|
227
|
+
const render = optionValue(options, "--render");
|
|
228
|
+
const searchConsole = optionValue(options, "--search-console");
|
|
229
|
+
const serp = optionValue(options, "--serp");
|
|
230
|
+
const aiAnswers = optionValue(options, "--ai-answers");
|
|
231
|
+
const lighthouse = optionValue(options, "--lighthouse");
|
|
232
|
+
const security = optionValue(options, "--security");
|
|
233
|
+
const timeoutMs = optionValue(options, "--timeout-ms");
|
|
234
|
+
const maxHtmlBytes = optionValue(options, "--max-html-bytes");
|
|
235
|
+
const maxTextBytes = optionValue(options, "--max-text-bytes");
|
|
236
|
+
const maxFileBytes = optionValue(options, "--max-file-bytes");
|
|
237
|
+
const maxIntegrationBytes = optionValue(options, "--max-integration-bytes");
|
|
238
|
+
|
|
239
|
+
merged.crawl = {
|
|
240
|
+
...(fileConfig.crawl || {}),
|
|
241
|
+
mode: mode || fileConfig.crawl?.mode || "single",
|
|
242
|
+
maxPages: numberOption(options, "--max-pages", fileConfig.crawl?.maxPages),
|
|
243
|
+
maxDepth: numberOption(options, "--max-depth", fileConfig.crawl?.maxDepth),
|
|
244
|
+
};
|
|
245
|
+
if (maxPages === null && fileConfig.crawl?.maxPages === undefined) delete merged.crawl.maxPages;
|
|
246
|
+
if (maxDepth === null && fileConfig.crawl?.maxDepth === undefined) delete merged.crawl.maxDepth;
|
|
247
|
+
|
|
248
|
+
merged.render = {
|
|
249
|
+
...(fileConfig.render || {}),
|
|
250
|
+
mode: render || fileConfig.render?.mode || "never",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
merged.integrations = {
|
|
254
|
+
...(fileConfig.integrations || {}),
|
|
255
|
+
searchConsole: searchConsole || fileConfig.integrations?.searchConsole || null,
|
|
256
|
+
serp: serp || fileConfig.integrations?.serp || null,
|
|
257
|
+
aiAnswers: aiAnswers || fileConfig.integrations?.aiAnswers || null,
|
|
258
|
+
lighthouse: lighthouse || fileConfig.integrations?.lighthouse || null,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
merged.security = {
|
|
262
|
+
...(fileConfig.security || {}),
|
|
263
|
+
mode: security || fileConfig.security?.mode || "local",
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
merged.limits = {
|
|
267
|
+
...(fileConfig.limits || {}),
|
|
268
|
+
timeoutMs: numberOption(options, "--timeout-ms", fileConfig.limits?.timeoutMs),
|
|
269
|
+
maxHtmlBytes: numberOption(options, "--max-html-bytes", fileConfig.limits?.maxHtmlBytes),
|
|
270
|
+
maxTextBytes: numberOption(options, "--max-text-bytes", fileConfig.limits?.maxTextBytes),
|
|
271
|
+
maxFileBytes: numberOption(options, "--max-file-bytes", fileConfig.limits?.maxFileBytes),
|
|
272
|
+
maxIntegrationBytes: numberOption(options, "--max-integration-bytes", fileConfig.limits?.maxIntegrationBytes),
|
|
273
|
+
};
|
|
274
|
+
if (timeoutMs === null && fileConfig.limits?.timeoutMs === undefined) delete merged.limits.timeoutMs;
|
|
275
|
+
if (maxHtmlBytes === null && fileConfig.limits?.maxHtmlBytes === undefined) delete merged.limits.maxHtmlBytes;
|
|
276
|
+
if (maxTextBytes === null && fileConfig.limits?.maxTextBytes === undefined) delete merged.limits.maxTextBytes;
|
|
277
|
+
if (maxFileBytes === null && fileConfig.limits?.maxFileBytes === undefined) delete merged.limits.maxFileBytes;
|
|
278
|
+
if (maxIntegrationBytes === null && fileConfig.limits?.maxIntegrationBytes === undefined) {
|
|
279
|
+
delete merged.limits.maxIntegrationBytes;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (sitemap) merged.sitemap = sitemap;
|
|
283
|
+
if (urlList) merged.urlList = urlList;
|
|
284
|
+
if (respectRobots === "true") merged.respectRobots = true;
|
|
285
|
+
if (respectRobots === "false") merged.respectRobots = false;
|
|
286
|
+
|
|
287
|
+
const validation = validateAuditConfig(merged, { baseDir, checkFiles: Boolean(configPath) });
|
|
288
|
+
if (!validation.ok) throw new Error(validation.errors.join("\n"));
|
|
289
|
+
|
|
290
|
+
return merged;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const mergeRepoConfig = (repoPath, options) => {
|
|
294
|
+
const configPath = repoOptionValue(options, "--config");
|
|
295
|
+
const baseDir = configPath ? path.dirname(path.resolve(configPath)) : process.cwd();
|
|
296
|
+
const fileConfig = configPath ? resolveAuditConfigPaths(readAuditConfig(configPath), baseDir) : {};
|
|
297
|
+
const repoConfig = fileConfig.repo && typeof fileConfig.repo === "object" && !Array.isArray(fileConfig.repo) ? fileConfig.repo : {};
|
|
298
|
+
const securityMode = repoEnumOption(options, "--security", fileConfig.security?.mode || "local", ["local", "restricted"]);
|
|
299
|
+
|
|
300
|
+
const merged = {
|
|
301
|
+
...fileConfig,
|
|
302
|
+
repoPath,
|
|
303
|
+
staticDir: repoOptionValue(options, "--static-dir", repoConfig.staticDir),
|
|
304
|
+
routeList: repoOptionValue(options, "--route-list", repoConfig.routeList),
|
|
305
|
+
buildCommand: repoOptionValue(options, "--build-command", repoConfig.buildCommand),
|
|
306
|
+
previewCommand: repoOptionValue(options, "--preview-command", repoConfig.previewCommand),
|
|
307
|
+
previewUrl: repoOptionValue(options, "--preview-url", repoConfig.previewUrl),
|
|
308
|
+
maxBuildMs: repoNumberOption(options, "--max-build-ms", repoConfig.maxBuildMs ?? 120000, {
|
|
309
|
+
minimum: 1,
|
|
310
|
+
minimumDescription: "positive integer",
|
|
311
|
+
}),
|
|
312
|
+
maxPreviewMs: repoNumberOption(options, "--max-preview-ms", repoConfig.maxPreviewMs ?? 30000, {
|
|
313
|
+
minimum: 1,
|
|
314
|
+
minimumDescription: "positive integer",
|
|
315
|
+
}),
|
|
316
|
+
crawl: {
|
|
317
|
+
...(fileConfig.crawl || {}),
|
|
318
|
+
mode: repoEnumOption(options, "--mode", fileConfig.crawl?.mode || "full", ["full", "sample", "single"]),
|
|
319
|
+
maxPages: repoNumberOption(options, "--max-pages", fileConfig.crawl?.maxPages ?? 25, {
|
|
320
|
+
minimum: 1,
|
|
321
|
+
minimumDescription: "positive integer",
|
|
322
|
+
}),
|
|
323
|
+
maxDepth: repoNumberOption(options, "--max-depth", fileConfig.crawl?.maxDepth ?? 2, {
|
|
324
|
+
minimum: 0,
|
|
325
|
+
minimumDescription: "non-negative integer",
|
|
326
|
+
}),
|
|
327
|
+
},
|
|
328
|
+
security: {
|
|
329
|
+
...(fileConfig.security || {}),
|
|
330
|
+
mode: securityMode,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (configPath) {
|
|
335
|
+
const validation = validateAuditConfig(fileConfig, { baseDir, checkFiles: true });
|
|
336
|
+
if (!validation.ok) throw new Error(validation.errors.join("\n"));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return merged;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export const runCli = async (args, io = { stdout: process.stdout, stderr: process.stderr }) => {
|
|
343
|
+
const [command, ...rest] = args;
|
|
344
|
+
|
|
345
|
+
if (!command || command === "--help" || command === "-h") {
|
|
346
|
+
io.stdout.write(help);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (command === "--version" || command === "-v") {
|
|
351
|
+
io.stdout.write(`${version}\n`);
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (command === "validate-config") {
|
|
356
|
+
const [filePath] = rest;
|
|
357
|
+
if (!filePath) {
|
|
358
|
+
io.stderr.write("validate-config requires a config file path.\n");
|
|
359
|
+
return 1;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const baseDir = path.dirname(path.resolve(filePath));
|
|
364
|
+
const config = resolveAuditConfigPaths(readAuditConfig(filePath), baseDir);
|
|
365
|
+
writeJson(io, validateAuditConfig(config, { baseDir, checkFiles: true }));
|
|
366
|
+
return 0;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
writeJson(io, { ok: false, errors: [error.message] });
|
|
369
|
+
return 1;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (command === "snapshot") {
|
|
374
|
+
const [target] = rest;
|
|
375
|
+
if (!target) {
|
|
376
|
+
io.stderr.write("snapshot requires a target URL or file path.\n");
|
|
377
|
+
return 1;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
writeJson(io, await collectSnapshot(target));
|
|
382
|
+
return 0;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
io.stderr.write(`${error.message}\n`);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (command === "audit") {
|
|
390
|
+
const { target, options } = splitAuditArgs(rest);
|
|
391
|
+
const configPath = optionValue(options, "--config");
|
|
392
|
+
if (!target && !configPath) {
|
|
393
|
+
io.stderr.write("audit requires a target URL or file path.\n");
|
|
394
|
+
return 1;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const output = await runAudit(mergeAuditConfig(target, options));
|
|
399
|
+
const outPath = outputFileOption(options, "--out");
|
|
400
|
+
const markdownPath = outputFileOption(options, "--markdown");
|
|
401
|
+
const htmlPath = outputFileOption(options, "--html");
|
|
402
|
+
const failOn = optionValue(options, "--fail-on");
|
|
403
|
+
const failedThreshold = failsThreshold(output.findings, failOn);
|
|
404
|
+
const result = { ok: true };
|
|
405
|
+
|
|
406
|
+
if (outPath) {
|
|
407
|
+
fs.writeFileSync(outPath, `${JSON.stringify(output, null, 2)}\n`);
|
|
408
|
+
result.out = outPath;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (markdownPath) {
|
|
412
|
+
fs.writeFileSync(markdownPath, generateMarkdownReport(output));
|
|
413
|
+
result.markdown = markdownPath;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (htmlPath) {
|
|
417
|
+
fs.writeFileSync(htmlPath, generateHtmlReport(output));
|
|
418
|
+
result.html = htmlPath;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (outPath || markdownPath || htmlPath) {
|
|
422
|
+
if (failedThreshold) result.failedThreshold = failOn;
|
|
423
|
+
writeJson(io, result);
|
|
424
|
+
return failedThreshold ? 2 : 0;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
writeJson(io, output);
|
|
428
|
+
return failedThreshold ? 2 : 0;
|
|
429
|
+
} catch (error) {
|
|
430
|
+
io.stderr.write(`${error.message}\n`);
|
|
431
|
+
return 1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (command === "detect-repo") {
|
|
436
|
+
const [repoPath = "."] = rest;
|
|
437
|
+
try {
|
|
438
|
+
writeJson(io, detectRepo(repoPath));
|
|
439
|
+
return 0;
|
|
440
|
+
} catch (error) {
|
|
441
|
+
io.stderr.write(`${error.message}\n`);
|
|
442
|
+
return 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (command === "audit-repo") {
|
|
447
|
+
const { repoPath, options } = splitRepoArgs(rest);
|
|
448
|
+
if (!repoPath) {
|
|
449
|
+
io.stderr.write("audit-repo requires a repository path.\n");
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const outRequested = options.includes("--out");
|
|
455
|
+
const markdownRequested = options.includes("--markdown");
|
|
456
|
+
const htmlRequested = options.includes("--html");
|
|
457
|
+
const outPath = outRequested ? repoOptionValue(options, "--out", null, "--out requires a file path.") : null;
|
|
458
|
+
const markdownPath = markdownRequested
|
|
459
|
+
? repoOptionValue(options, "--markdown", null, "--markdown requires a file path.")
|
|
460
|
+
: null;
|
|
461
|
+
const htmlPath = htmlRequested ? repoOptionValue(options, "--html", null, "--html requires a file path.") : null;
|
|
462
|
+
const failOn = repoOptionValue(options, "--fail-on");
|
|
463
|
+
if (failOn && !(failOn in severityRank)) throw new Error("--fail-on must be one of: P0, P1, P2, P3");
|
|
464
|
+
|
|
465
|
+
const output = await runRepoAudit(mergeRepoConfig(repoPath, options));
|
|
466
|
+
const failedThreshold =
|
|
467
|
+
failsThreshold(output.findings, failOn) || failsThreshold(output.repo?.sourceFindings || [], failOn);
|
|
468
|
+
|
|
469
|
+
if (outPath) fs.writeFileSync(outPath, `${JSON.stringify(output, null, 2)}\n`);
|
|
470
|
+
if (markdownPath) fs.writeFileSync(markdownPath, generateMarkdownReport(output));
|
|
471
|
+
if (htmlPath) fs.writeFileSync(htmlPath, generateHtmlReport(output));
|
|
472
|
+
if (outPath || markdownPath || htmlPath) {
|
|
473
|
+
const result = { ok: true };
|
|
474
|
+
if (outRequested) result.out = outPath;
|
|
475
|
+
if (markdownRequested) result.markdown = markdownPath;
|
|
476
|
+
if (htmlRequested) result.html = htmlPath;
|
|
477
|
+
if (failedThreshold) result.failedThreshold = failOn;
|
|
478
|
+
writeJson(io, result);
|
|
479
|
+
} else {
|
|
480
|
+
writeJson(io, output);
|
|
481
|
+
}
|
|
482
|
+
return failedThreshold || output.repo?.sourceFindings?.length ? 2 : 0;
|
|
483
|
+
} catch (error) {
|
|
484
|
+
io.stderr.write(`${error.message}\n`);
|
|
485
|
+
return 1;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (command === "explain-rule") {
|
|
490
|
+
const [ruleId] = rest;
|
|
491
|
+
if (!ruleId) {
|
|
492
|
+
io.stderr.write("explain-rule requires a rule ID.\n");
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const rule = getRule(ruleId);
|
|
497
|
+
if (!rule) {
|
|
498
|
+
io.stderr.write(`Unknown rule: ${ruleId}\n`);
|
|
499
|
+
return 1;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
writeJson(io, rule);
|
|
503
|
+
return 0;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
io.stderr.write(`Unknown command: ${command}\n`);
|
|
507
|
+
return 1;
|
|
508
|
+
};
|