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/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
+ };