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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Skinner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the Software), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # rankforge
2
+
3
+ Deterministic GEO/SEO readiness audits for websites and source repositories.
4
+
5
+ The CLI inspects crawlability, indexability, search appearance, structured data, content answerability, entity clarity, optional performance imports, and repository audit evidence. It reports readiness by default. Measured ranking or AI-answer visibility requires supplied Search Console, SERP, or AI-answer evidence; Lighthouse exports can add performance measurements.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g rankforge
11
+ ```
12
+
13
+ Playwright is optional. Install it in projects where rendered page evidence is needed.
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ rankforge --help
19
+ rankforge audit https://example.com --mode full --max-pages 25 --out audit.json --markdown audit.md --html audit.html
20
+ rankforge audit-repo ./site --static-dir dist --fail-on P1 --out repo-audit.json --markdown repo-audit.md --html repo-audit.html
21
+ rankforge explain-rule indexability.noindex
22
+ ```
23
+
24
+ Use `--security restricted` for untrusted live-site audits or hosted wrappers. Restricted mode applies guarded network and file access limits and disables local command execution.
25
+
26
+ ## Outputs
27
+
28
+ The CLI emits versioned JSON plus optional Markdown and standalone HTML reports. Findings include stable rule IDs, severity, evidence paths, implementation-task guidance, and source citations where applicable.
29
+
30
+ Readiness scores are not ranking guarantees. Treat scores and findings as deterministic implementation evidence, then combine them with measured visibility data when available.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "rankforge",
3
+ "version": "0.3.0",
4
+ "description": "Deterministic GEO/SEO readiness audit CLI for websites and source repositories.",
5
+ "private": false,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/PSkinnerTech/RankForge.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "homepage": "https://github.com/PSkinnerTech/RankForge#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/PSkinnerTech/RankForge/issues"
16
+ },
17
+ "keywords": [
18
+ "seo",
19
+ "geo",
20
+ "generative-engine-optimization",
21
+ "audit",
22
+ "cli",
23
+ "technical-seo",
24
+ "repository-audit"
25
+ ],
26
+ "bin": {
27
+ "rankforge": "src/index.mjs"
28
+ },
29
+ "files": [
30
+ "src",
31
+ "README.md",
32
+ "LICENSE",
33
+ "package.json"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test test/*.test.mjs"
37
+ },
38
+ "peerDependencies": {
39
+ "playwright": ">=1.40.0"
40
+ },
41
+ "peerDependenciesMeta": {
42
+ "playwright": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ }
49
+ }
@@ -0,0 +1,88 @@
1
+ export const auditOutputSchema = {
2
+ $schema: "https://json-schema.org/draft/2020-12/schema",
3
+ $id: "https://rankforge.dev/schemas/rankforge-output.schema.json",
4
+ title: "RankForge Output",
5
+ type: "object",
6
+ required: [
7
+ "schemaVersion",
8
+ "toolVersion",
9
+ "run",
10
+ "site",
11
+ "pages",
12
+ "integrations",
13
+ "scores",
14
+ "findings",
15
+ "evidenceGaps",
16
+ "sources",
17
+ ],
18
+ additionalProperties: true,
19
+ properties: {
20
+ schemaVersion: { type: "string" },
21
+ toolVersion: { type: "string" },
22
+ run: { type: "object" },
23
+ site: { type: "object" },
24
+ pages: { type: "array" },
25
+ integrations: { type: "object" },
26
+ scores: { type: "object" },
27
+ findings: { type: "array" },
28
+ evidenceGaps: { type: "array" },
29
+ sources: { type: "array" },
30
+ repo: { type: "object" },
31
+ },
32
+ };
33
+
34
+ const requiredTopLevelFields = [
35
+ "schemaVersion",
36
+ "toolVersion",
37
+ "run",
38
+ "site",
39
+ "pages",
40
+ "integrations",
41
+ "scores",
42
+ "findings",
43
+ "evidenceGaps",
44
+ "sources",
45
+ ];
46
+
47
+ const requiredFindingFields = [
48
+ "ruleId",
49
+ "title",
50
+ "severity",
51
+ "dimension",
52
+ "affectedUrls",
53
+ "evidence",
54
+ "impact",
55
+ "recommendation",
56
+ "owner",
57
+ "effort",
58
+ "confidence",
59
+ "sources",
60
+ ];
61
+
62
+ export const validateAuditOutput = (audit) => {
63
+ const errors = [];
64
+
65
+ if (!audit || typeof audit !== "object" || Array.isArray(audit)) {
66
+ return { ok: false, errors: ["audit output must be an object"] };
67
+ }
68
+
69
+ for (const field of requiredTopLevelFields) {
70
+ if (!(field in audit)) errors.push(`${field} is required`);
71
+ }
72
+
73
+ if ("pages" in audit && !Array.isArray(audit.pages)) errors.push("pages must be an array");
74
+ if ("repo" in audit && (!audit.repo || typeof audit.repo !== "object" || Array.isArray(audit.repo))) {
75
+ errors.push("repo must be an object");
76
+ }
77
+ if ("findings" in audit && !Array.isArray(audit.findings)) {
78
+ errors.push("findings must be an array");
79
+ } else {
80
+ for (const [index, finding] of (audit.findings || []).entries()) {
81
+ for (const field of requiredFindingFields) {
82
+ if (!(field in finding)) errors.push(`findings[${index}].${field} is required`);
83
+ }
84
+ }
85
+ }
86
+
87
+ return { ok: errors.length === 0, errors };
88
+ };
package/src/audit.mjs ADDED
@@ -0,0 +1,202 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { crawlSite } from "./crawl.mjs";
5
+ import { readTextFileLimited, resolveLimits } from "./io-guards.mjs";
6
+ import { readIntegrations } from "./integrations.mjs";
7
+ import { evaluatePerformance } from "./performance.mjs";
8
+ import { evaluatePage, scoreFindings } from "./rule-engine.mjs";
9
+ import { evaluateSite } from "./site-rule-engine.mjs";
10
+ import { collectSnapshot } from "./snapshot.mjs";
11
+ import { isHttpUrl } from "./url-utils.mjs";
12
+
13
+ const toolVersion = "0.3.0";
14
+
15
+ const readSourceMap = () => {
16
+ const candidates = [
17
+ new URL("./source-map.json", import.meta.url),
18
+ new URL("../../../skill/rankforge/source-map.json", import.meta.url),
19
+ ];
20
+
21
+ for (const file of candidates) {
22
+ try {
23
+ const sourceMap = JSON.parse(fs.readFileSync(file, "utf8"));
24
+ return Object.entries(sourceMap).map(([id, url]) => ({ id, url }));
25
+ } catch {
26
+ // Try the next source-map location.
27
+ }
28
+ }
29
+
30
+ return [];
31
+ };
32
+
33
+ const originFor = (target) => {
34
+ try {
35
+ return new URL(target).origin;
36
+ } catch {
37
+ return null;
38
+ }
39
+ };
40
+
41
+ const stableValue = (value) => {
42
+ if (Array.isArray(value)) return value.map(stableValue);
43
+ if (!value || typeof value !== "object") return typeof value === "function" ? undefined : value;
44
+
45
+ return Object.fromEntries(
46
+ Object.keys(value)
47
+ .sort()
48
+ .map((key) => [key, stableValue(value[key])])
49
+ .filter(([, item]) => item !== undefined),
50
+ );
51
+ };
52
+
53
+ const configHash = (config) => crypto.createHash("sha256").update(JSON.stringify(stableValue(config))).digest("hex");
54
+
55
+ const crawlSettings = (config) => ({
56
+ mode: config.crawl?.mode || "single",
57
+ maxPages: config.crawl?.maxPages ?? config.maxPages ?? null,
58
+ maxDepth: config.crawl?.maxDepth ?? config.maxDepth ?? null,
59
+ });
60
+
61
+ const readUrlList = (config) => {
62
+ const normalizeEntries = (entries, baseDir) =>
63
+ entries
64
+ .map((line) => line.trim())
65
+ .filter((line) => line && !line.startsWith("#"))
66
+ .map((line) => {
67
+ if (isHttpUrl(line)) return line;
68
+ if (path.isAbsolute(line) && fs.existsSync(line)) return line;
69
+ if (isHttpUrl(config.target)) return new URL(line, config.target).href;
70
+ if (path.isAbsolute(line)) return line;
71
+ return path.resolve(baseDir, line);
72
+ });
73
+
74
+ if (Array.isArray(config.urlListEntries)) {
75
+ return normalizeEntries(
76
+ config.urlListEntries.map((entry) => String(entry)),
77
+ process.cwd(),
78
+ );
79
+ }
80
+ if (!config.urlList) return [];
81
+ const baseDir = path.dirname(config.urlList);
82
+ const limits = resolveLimits(config.limits);
83
+ return normalizeEntries(
84
+ readTextFileLimited(config.urlList, {
85
+ security: config.security,
86
+ allowRestricted: true,
87
+ limits,
88
+ maxBytes: limits.maxFileBytes,
89
+ }).split(/\r?\n/),
90
+ baseDir,
91
+ );
92
+ };
93
+
94
+ const collectUrlList = async (config) => {
95
+ const maxPages = config.crawl?.maxPages ?? config.maxPages ?? 50;
96
+ const urls = readUrlList(config).slice(0, maxPages);
97
+ const pages = [];
98
+
99
+ for (const url of urls) {
100
+ pages.push(
101
+ await collectSnapshot(url, {
102
+ render: config.render?.mode,
103
+ renderer: config.renderer,
104
+ security: config.security,
105
+ limits: config.limits,
106
+ }),
107
+ );
108
+ }
109
+
110
+ return { pages, skipped: [], robots: null, sitemaps: [] };
111
+ };
112
+
113
+ export const runAudit = async (config) => {
114
+ if (!config?.target) throw new Error("target is required");
115
+
116
+ const startedAt = new Date().toISOString();
117
+ const settings = crawlSettings(config);
118
+ const shouldCrawl = isHttpUrl(config.target) && (settings.mode === "full" || settings.mode === "sample");
119
+ const hasUrlList = config.urlList || Array.isArray(config.urlListEntries);
120
+ const crawlResult = hasUrlList
121
+ ? await collectUrlList(config)
122
+ : shouldCrawl
123
+ ? await crawlSite(config)
124
+ : {
125
+ pages: [
126
+ await collectSnapshot(config.target, {
127
+ render: config.render?.mode,
128
+ renderer: config.renderer,
129
+ security: config.security,
130
+ limits: config.limits,
131
+ }),
132
+ ],
133
+ skipped: [],
134
+ robots: null,
135
+ sitemaps: [],
136
+ };
137
+ const endedAt = new Date().toISOString();
138
+ const pages = crawlResult.pages;
139
+ const sitemapUrls = (crawlResult.sitemaps || []).flatMap((sitemap) => sitemap.parsed?.urls || []);
140
+ const integrations = readIntegrations(config.integrations, { security: config.security, limits: config.limits });
141
+ const hasRankingEvidence = Boolean(
142
+ integrations.searchConsole?.rows?.length || integrations.serp?.rows?.length || integrations.aiAnswers?.rows?.length,
143
+ );
144
+ const findings = [
145
+ ...pages.flatMap((item, index) => evaluatePage(item, index)),
146
+ ...evaluateSite(pages, {
147
+ crawled: shouldCrawl,
148
+ origin: originFor(pages[0]?.finalUrl || config.target),
149
+ sitemapUrls,
150
+ sitemaps: crawlResult.sitemaps,
151
+ skipped: crawlResult.skipped,
152
+ }),
153
+ ...evaluatePerformance(integrations.lighthouse),
154
+ ];
155
+
156
+ return {
157
+ schemaVersion: "1.0.0",
158
+ toolVersion,
159
+ run: {
160
+ id: `audit-${Date.now()}`,
161
+ startedAt,
162
+ endedAt,
163
+ target: config.target,
164
+ configHash: configHash(config),
165
+ mode: settings.mode,
166
+ crawl: settings,
167
+ render: config.render || { mode: "never" },
168
+ security: config.security || { mode: "local" },
169
+ limits: config.limits || {},
170
+ userAgent: "RankForge GEO SEO audit snapshot",
171
+ environment: {
172
+ node: process.version,
173
+ platform: process.platform,
174
+ },
175
+ },
176
+ site: {
177
+ origin: originFor(pages[0]?.finalUrl || config.target),
178
+ robots: crawlResult.robots,
179
+ sitemaps: crawlResult.sitemaps,
180
+ skipped: crawlResult.skipped,
181
+ notes: hasUrlList
182
+ ? ["Audit output contains supplied URL-list evidence."]
183
+ : shouldCrawl
184
+ ? ["Audit output contains bounded same-origin crawl evidence."]
185
+ : ["Audit output contains single-page snapshot evidence."],
186
+ },
187
+ pages,
188
+ integrations,
189
+ scores: scoreFindings(findings),
190
+ findings,
191
+ evidenceGaps: hasRankingEvidence
192
+ ? []
193
+ : [
194
+ {
195
+ id: "ranking.integrations_missing",
196
+ message:
197
+ "Measured rankings, SERP positions, and AI answer visibility require Search Console, SERP, or AI answer evidence.",
198
+ },
199
+ ],
200
+ sources: readSourceMap(),
201
+ };
202
+ };