seo-gravity-mcp 1.0.2 → 1.2.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/README.md +118 -105
- package/dist/adapters/adapterRegistry.d.ts +7 -0
- package/dist/adapters/adapterRegistry.js +30 -0
- package/dist/adapters/astroAdapter.d.ts +17 -0
- package/dist/adapters/astroAdapter.js +191 -0
- package/dist/adapters/nextAppAdapter.d.ts +17 -0
- package/dist/adapters/nextAppAdapter.js +265 -0
- package/dist/adapters/nextPagesAdapter.d.ts +17 -0
- package/dist/adapters/nextPagesAdapter.js +209 -0
- package/dist/adapters/remixAdapter.d.ts +16 -0
- package/dist/adapters/remixAdapter.js +118 -0
- package/dist/adapters/staticAdapter.d.ts +16 -0
- package/dist/adapters/staticAdapter.js +117 -0
- package/dist/adapters/svelteKitAdapter.d.ts +16 -0
- package/dist/adapters/svelteKitAdapter.js +122 -0
- package/dist/adapters/types.d.ts +39 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/viteReactAdapter.d.ts +16 -0
- package/dist/adapters/viteReactAdapter.js +189 -0
- package/dist/benchmark/correlationBenchmark.d.ts +1 -0
- package/dist/benchmark/correlationBenchmark.js +96 -0
- package/dist/benchmark/invariantBenchmark.d.ts +1 -0
- package/dist/benchmark/invariantBenchmark.js +47 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +162 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +8 -646
- package/dist/invariants/registry.d.ts +12 -0
- package/dist/invariants/registry.js +178 -0
- package/dist/invariants/types.d.ts +38 -0
- package/dist/invariants/types.js +1 -0
- package/dist/providers/providerRegistry.d.ts +65 -0
- package/dist/providers/providerRegistry.js +64 -0
- package/dist/providers/types.d.ts +22 -0
- package/dist/providers/types.js +1 -0
- package/dist/server/registry.d.ts +3 -0
- package/dist/server/registry.js +559 -0
- package/dist/server/server.d.ts +70 -0
- package/dist/server/server.js +53 -0
- package/dist/server/types.d.ts +7 -0
- package/dist/server/types.js +1 -0
- package/dist/test.js +108 -138
- package/dist/tools/orchestration.js +23 -21
- package/dist/types/canonical.d.ts +64 -0
- package/dist/types/canonical.js +1 -0
- package/dist/types/findings.d.ts +22 -0
- package/dist/types/findings.js +1 -1
- package/dist/types/seo.d.ts +24 -0
- package/dist/utils/astLocator.d.ts +14 -0
- package/dist/utils/astLocator.js +137 -0
- package/dist/utils/cacheManager.d.ts +29 -0
- package/dist/utils/cacheManager.js +60 -0
- package/dist/utils/experimentEngine.d.ts +26 -0
- package/dist/utils/experimentEngine.js +41 -0
- package/dist/utils/findingEngine.d.ts +2 -1
- package/dist/utils/findingEngine.js +1 -0
- package/dist/utils/gitDiffEngine.d.ts +28 -0
- package/dist/utils/gitDiffEngine.js +109 -0
- package/dist/utils/opportunityEngine.d.ts +19 -0
- package/dist/utils/opportunityEngine.js +61 -0
- package/dist/utils/projectScanner.d.ts +4 -2
- package/dist/utils/projectScanner.js +14 -346
- package/dist/utils/sarifExporter.d.ts +64 -0
- package/dist/utils/sarifExporter.js +81 -0
- package/dist/utils/snapshotEngine.d.ts +3 -1
- package/dist/utils/snapshotEngine.js +232 -94
- package/package.json +8 -5
package/dist/types/seo.d.ts
CHANGED
|
@@ -366,4 +366,28 @@ export interface ContentDecayReport {
|
|
|
366
366
|
brokenOutboundLinks: string[];
|
|
367
367
|
suggestedUpdateChecklist: string[];
|
|
368
368
|
}
|
|
369
|
+
export interface PageSpeedAuditReport {
|
|
370
|
+
url: string;
|
|
371
|
+
strategy: string;
|
|
372
|
+
performanceScore: number;
|
|
373
|
+
coreWebVitals: {
|
|
374
|
+
lcp: {
|
|
375
|
+
value: string;
|
|
376
|
+
status: 'Good' | 'Needs Improvement' | 'Poor';
|
|
377
|
+
};
|
|
378
|
+
fcp: {
|
|
379
|
+
value: string;
|
|
380
|
+
status: 'Good' | 'Needs Improvement' | 'Poor';
|
|
381
|
+
};
|
|
382
|
+
cls: {
|
|
383
|
+
value: string;
|
|
384
|
+
status: 'Good' | 'Needs Improvement' | 'Poor';
|
|
385
|
+
};
|
|
386
|
+
ttfb: {
|
|
387
|
+
value: string;
|
|
388
|
+
status: 'Good' | 'Needs Improvement' | 'Poor';
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
topSpeedFixes: string[];
|
|
392
|
+
}
|
|
369
393
|
export * from './findings.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ASTSourceRange } from '../types/findings.js';
|
|
2
|
+
export interface ASTMetadataInspection {
|
|
3
|
+
hasMetadataExport: boolean;
|
|
4
|
+
hasGenerateMetadata: boolean;
|
|
5
|
+
hasCanonicalDeclaration: boolean;
|
|
6
|
+
hasSchemaMarkup: boolean;
|
|
7
|
+
metadataRange?: ASTSourceRange;
|
|
8
|
+
canonicalRange?: ASTSourceRange;
|
|
9
|
+
schemaRange?: ASTSourceRange;
|
|
10
|
+
extractedTitle?: string;
|
|
11
|
+
extractedDescription?: string;
|
|
12
|
+
extractedCanonical?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function inspectSourceFileAST(filePath: string): ASTMetadataInspection;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
export function inspectSourceFileAST(filePath) {
|
|
5
|
+
if (!fs.existsSync(filePath)) {
|
|
6
|
+
return {
|
|
7
|
+
hasMetadataExport: false,
|
|
8
|
+
hasGenerateMetadata: false,
|
|
9
|
+
hasCanonicalDeclaration: false,
|
|
10
|
+
hasSchemaMarkup: false
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
14
|
+
const sourceFile = ts.createSourceFile(path.basename(filePath), content, ts.ScriptTarget.Latest, true, filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
15
|
+
let hasMetadataExport = false;
|
|
16
|
+
let hasGenerateMetadata = false;
|
|
17
|
+
let hasCanonicalDeclaration = false;
|
|
18
|
+
let hasSchemaMarkup = false;
|
|
19
|
+
let metadataRange;
|
|
20
|
+
let canonicalRange;
|
|
21
|
+
let schemaRange;
|
|
22
|
+
let extractedTitle;
|
|
23
|
+
let extractedDescription;
|
|
24
|
+
let extractedCanonical;
|
|
25
|
+
const getRange = (node, exportName) => {
|
|
26
|
+
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
27
|
+
const end = sourceFile.getLineAndCharacterOfPosition(node.getEnd());
|
|
28
|
+
return {
|
|
29
|
+
filePath,
|
|
30
|
+
startLine: start.line + 1,
|
|
31
|
+
endLine: end.line + 1,
|
|
32
|
+
startColumn: start.character + 1,
|
|
33
|
+
endColumn: end.character + 1,
|
|
34
|
+
astNodeType: ts.SyntaxKind[node.kind],
|
|
35
|
+
exportName,
|
|
36
|
+
snippet: node.getText(sourceFile).slice(0, 300)
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
const visit = (node) => {
|
|
40
|
+
// 1. Check for `export const metadata = { ... }`
|
|
41
|
+
if (ts.isVariableStatement(node)) {
|
|
42
|
+
const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
43
|
+
if (isExported) {
|
|
44
|
+
for (const decl of node.declarationList.declarations) {
|
|
45
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === 'metadata') {
|
|
46
|
+
hasMetadataExport = true;
|
|
47
|
+
metadataRange = getRange(node, 'metadata');
|
|
48
|
+
if (decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
|
|
49
|
+
for (const prop of decl.initializer.properties) {
|
|
50
|
+
if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
|
|
51
|
+
if (prop.name.text === 'title') {
|
|
52
|
+
extractedTitle = prop.initializer.getText(sourceFile).replace(/['"]/g, '');
|
|
53
|
+
}
|
|
54
|
+
if (prop.name.text === 'description') {
|
|
55
|
+
extractedDescription = prop.initializer.getText(sourceFile).replace(/['"]/g, '');
|
|
56
|
+
}
|
|
57
|
+
if (prop.name.text === 'alternates' && ts.isObjectLiteralExpression(prop.initializer)) {
|
|
58
|
+
hasCanonicalDeclaration = true;
|
|
59
|
+
canonicalRange = getRange(prop, 'alternates.canonical');
|
|
60
|
+
for (const altProp of prop.initializer.properties) {
|
|
61
|
+
if (ts.isPropertyAssignment(altProp) && ts.isIdentifier(altProp.name) && altProp.name.text === 'canonical') {
|
|
62
|
+
extractedCanonical = altProp.initializer.getText(sourceFile).replace(/['"]/g, '');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 2. Check for `export async function generateMetadata(...)` or `export function generateMetadata(...)`
|
|
74
|
+
if (ts.isFunctionDeclaration(node) && node.name && node.name.text === 'generateMetadata') {
|
|
75
|
+
const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
76
|
+
if (isExported) {
|
|
77
|
+
hasGenerateMetadata = true;
|
|
78
|
+
metadataRange = getRange(node, 'generateMetadata');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// 3. Check for JSX <link rel="canonical" href="..." />
|
|
82
|
+
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
|
83
|
+
const tagName = node.tagName.getText(sourceFile);
|
|
84
|
+
if (tagName === 'link') {
|
|
85
|
+
const text = node.getText(sourceFile);
|
|
86
|
+
if (text.includes('rel="canonical"') || text.includes("rel='canonical'")) {
|
|
87
|
+
hasCanonicalDeclaration = true;
|
|
88
|
+
canonicalRange = getRange(node, 'link:canonical');
|
|
89
|
+
const hrefMatch = text.match(/href=["']([^"']+)["']/);
|
|
90
|
+
if (hrefMatch)
|
|
91
|
+
extractedCanonical = hrefMatch[1];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Check for <script type="application/ld+json">
|
|
95
|
+
if (tagName === 'script') {
|
|
96
|
+
const text = node.getText(sourceFile);
|
|
97
|
+
if (text.includes('application/ld+json')) {
|
|
98
|
+
hasSchemaMarkup = true;
|
|
99
|
+
schemaRange = getRange(node, 'schema:jsonld');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Fallback search for string literal indicators
|
|
104
|
+
if (ts.isStringLiteral(node) && node.text.includes('application/ld+json')) {
|
|
105
|
+
hasSchemaMarkup = true;
|
|
106
|
+
if (!schemaRange)
|
|
107
|
+
schemaRange = getRange(node, 'schema:jsonld');
|
|
108
|
+
}
|
|
109
|
+
ts.forEachChild(node, visit);
|
|
110
|
+
};
|
|
111
|
+
visit(sourceFile);
|
|
112
|
+
// Fallback regex detection if AST node missed subtle structures
|
|
113
|
+
if (!hasMetadataExport && /export\s+const\s+metadata\b/.test(content)) {
|
|
114
|
+
hasMetadataExport = true;
|
|
115
|
+
}
|
|
116
|
+
if (!hasGenerateMetadata && /export\s+(async\s+)?function\s+generateMetadata\b/.test(content)) {
|
|
117
|
+
hasGenerateMetadata = true;
|
|
118
|
+
}
|
|
119
|
+
if (!hasCanonicalDeclaration && /canonical/.test(content)) {
|
|
120
|
+
hasCanonicalDeclaration = true;
|
|
121
|
+
}
|
|
122
|
+
if (!hasSchemaMarkup && /application\/ld\+json/.test(content)) {
|
|
123
|
+
hasSchemaMarkup = true;
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
hasMetadataExport,
|
|
127
|
+
hasGenerateMetadata,
|
|
128
|
+
hasCanonicalDeclaration,
|
|
129
|
+
hasSchemaMarkup,
|
|
130
|
+
metadataRange,
|
|
131
|
+
canonicalRange,
|
|
132
|
+
schemaRange,
|
|
133
|
+
extractedTitle,
|
|
134
|
+
extractedDescription,
|
|
135
|
+
extractedCanonical
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
cachedAt: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
provider: string;
|
|
6
|
+
hash: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CacheMetadata {
|
|
9
|
+
isCached: boolean;
|
|
10
|
+
cachedAt: string;
|
|
11
|
+
ageMs: number;
|
|
12
|
+
ttlMs: number;
|
|
13
|
+
provider: string;
|
|
14
|
+
key: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class CacheManager {
|
|
17
|
+
private cache;
|
|
18
|
+
computeKey(namespace: string, input: any): string;
|
|
19
|
+
get<T>(key: string): T | null;
|
|
20
|
+
getWithMetadata<T>(key: string): {
|
|
21
|
+
value: T;
|
|
22
|
+
metadata: CacheMetadata;
|
|
23
|
+
} | null;
|
|
24
|
+
set<T>(key: string, value: T, ttlMs?: number, provider?: string): void;
|
|
25
|
+
has(key: string): boolean;
|
|
26
|
+
clear(): void;
|
|
27
|
+
size(): number;
|
|
28
|
+
}
|
|
29
|
+
export declare const defaultCacheManager: CacheManager;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
export class CacheManager {
|
|
3
|
+
cache = new Map();
|
|
4
|
+
computeKey(namespace, input) {
|
|
5
|
+
const serialized = typeof input === 'string' ? input : JSON.stringify(input);
|
|
6
|
+
const hash = crypto.createHash('sha256').update(serialized).digest('hex').substring(0, 16);
|
|
7
|
+
return `${namespace}:${hash}`;
|
|
8
|
+
}
|
|
9
|
+
get(key) {
|
|
10
|
+
const entry = this.cache.get(key);
|
|
11
|
+
if (!entry)
|
|
12
|
+
return null;
|
|
13
|
+
if (Date.now() > entry.expiresAt) {
|
|
14
|
+
this.cache.delete(key);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return entry.value;
|
|
18
|
+
}
|
|
19
|
+
getWithMetadata(key) {
|
|
20
|
+
const entry = this.cache.get(key);
|
|
21
|
+
if (!entry)
|
|
22
|
+
return null;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (now > entry.expiresAt) {
|
|
25
|
+
this.cache.delete(key);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
value: entry.value,
|
|
30
|
+
metadata: {
|
|
31
|
+
isCached: true,
|
|
32
|
+
cachedAt: entry.cachedAt,
|
|
33
|
+
ageMs: now - new Date(entry.cachedAt).getTime(),
|
|
34
|
+
ttlMs: entry.expiresAt - now,
|
|
35
|
+
provider: entry.provider,
|
|
36
|
+
key
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
set(key, value, ttlMs = 300000, provider = 'internal') {
|
|
41
|
+
const hash = crypto.createHash('md5').update(JSON.stringify(value)).digest('hex');
|
|
42
|
+
this.cache.set(key, {
|
|
43
|
+
value,
|
|
44
|
+
cachedAt: new Date().toISOString(),
|
|
45
|
+
expiresAt: Date.now() + ttlMs,
|
|
46
|
+
provider,
|
|
47
|
+
hash
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
has(key) {
|
|
51
|
+
return this.get(key) !== null;
|
|
52
|
+
}
|
|
53
|
+
clear() {
|
|
54
|
+
this.cache.clear();
|
|
55
|
+
}
|
|
56
|
+
size() {
|
|
57
|
+
return this.cache.size;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const defaultCacheManager = new CacheManager();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface SEOExperiment {
|
|
2
|
+
id: string;
|
|
3
|
+
createdAt: string;
|
|
4
|
+
hypothesis: string;
|
|
5
|
+
targetRoute: string;
|
|
6
|
+
baselineHealthScore: number;
|
|
7
|
+
baselineFindingsCount: number;
|
|
8
|
+
proposedChange: string;
|
|
9
|
+
verificationCriteria: {
|
|
10
|
+
targetMetric: string;
|
|
11
|
+
expectedOutcome: string;
|
|
12
|
+
};
|
|
13
|
+
status: 'DRAFT' | 'IN_PROGRESS' | 'VERIFIED_SUCCESS' | 'VERIFIED_FAILURE';
|
|
14
|
+
}
|
|
15
|
+
export interface ExperimentVerificationResult {
|
|
16
|
+
schemaVersion: 'seo.gravity/v1';
|
|
17
|
+
experimentId: string;
|
|
18
|
+
status: 'VERIFIED_SUCCESS' | 'VERIFIED_FAILURE';
|
|
19
|
+
baselineScore: number;
|
|
20
|
+
currentScore: number;
|
|
21
|
+
scoreDelta: number;
|
|
22
|
+
findingsResolvedCount: number;
|
|
23
|
+
summary: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function planExperiment(projectPath: string, targetRoute: string, hypothesis: string, proposedChange: string): Promise<SEOExperiment>;
|
|
26
|
+
export declare function verifyExperiment(projectPath: string, experiment: SEOExperiment): Promise<ExperimentVerificationResult>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createProjectSnapshot } from './snapshotEngine.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export async function planExperiment(projectPath, targetRoute, hypothesis, proposedChange) {
|
|
4
|
+
const resolved = path.resolve(projectPath);
|
|
5
|
+
const baseline = await createProjectSnapshot(resolved);
|
|
6
|
+
const routeFindings = baseline.findings.filter(f => f.affectedUrl === targetRoute);
|
|
7
|
+
return {
|
|
8
|
+
id: `exp_${Date.now()}`,
|
|
9
|
+
createdAt: new Date().toISOString(),
|
|
10
|
+
hypothesis,
|
|
11
|
+
targetRoute,
|
|
12
|
+
baselineHealthScore: baseline.scores.overallHealth,
|
|
13
|
+
baselineFindingsCount: routeFindings.length,
|
|
14
|
+
proposedChange,
|
|
15
|
+
verificationCriteria: {
|
|
16
|
+
targetMetric: 'Route SEO Health & Defect Resolution',
|
|
17
|
+
expectedOutcome: 'Zero regressions and defect resolved.'
|
|
18
|
+
},
|
|
19
|
+
status: 'DRAFT'
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function verifyExperiment(projectPath, experiment) {
|
|
23
|
+
const resolved = path.resolve(projectPath);
|
|
24
|
+
const current = await createProjectSnapshot(resolved);
|
|
25
|
+
const scoreDelta = current.scores.overallHealth - experiment.baselineHealthScore;
|
|
26
|
+
const currentRouteFindings = current.findings.filter(f => f.affectedUrl === experiment.targetRoute);
|
|
27
|
+
const resolvedCount = Math.max(0, experiment.baselineFindingsCount - currentRouteFindings.length);
|
|
28
|
+
const success = scoreDelta >= 0 && resolvedCount > 0;
|
|
29
|
+
return {
|
|
30
|
+
schemaVersion: 'seo.gravity/v1',
|
|
31
|
+
experimentId: experiment.id,
|
|
32
|
+
status: success ? 'VERIFIED_SUCCESS' : 'VERIFIED_FAILURE',
|
|
33
|
+
baselineScore: experiment.baselineHealthScore,
|
|
34
|
+
currentScore: current.scores.overallHealth,
|
|
35
|
+
scoreDelta,
|
|
36
|
+
findingsResolvedCount: resolvedCount,
|
|
37
|
+
summary: success
|
|
38
|
+
? `✅ Experiment succeeded: ${resolvedCount} issue(s) resolved, score improved by +${scoreDelta} points.`
|
|
39
|
+
: `❌ Experiment did not satisfy criteria: score delta is ${scoreDelta}, ${currentRouteFindings.length} issue(s) remain on '${experiment.targetRoute}'.`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Finding, FindingCategory, SeverityLevel, EvidenceType, EffortLevel, PriorityTier, ReliabilityState, SourceLocation, SuggestedFix, MultiDimensionalScores, SupportedFramework } from '../types/findings.js';
|
|
1
|
+
import { Finding, FindingCategory, SeverityLevel, EvidenceType, EffortLevel, PriorityTier, ReliabilityState, SourceLocation, SuggestedFix, MultiDimensionalScores, SupportedFramework, ASTSourceRange } from '../types/findings.js';
|
|
2
2
|
export interface CreateFindingParams {
|
|
3
3
|
id: string;
|
|
4
4
|
category: FindingCategory;
|
|
@@ -9,6 +9,7 @@ export interface CreateFindingParams {
|
|
|
9
9
|
evidence: string;
|
|
10
10
|
affectedUrl: string;
|
|
11
11
|
sourceLocation?: SourceLocation;
|
|
12
|
+
sourceRange?: ASTSourceRange;
|
|
12
13
|
likelyRootCause?: string;
|
|
13
14
|
recommendation: string;
|
|
14
15
|
suggestedFix?: SuggestedFix;
|
|
@@ -61,6 +61,7 @@ export function createFinding(params) {
|
|
|
61
61
|
evidence: params.evidence,
|
|
62
62
|
affectedUrl: params.affectedUrl,
|
|
63
63
|
sourceLocation: params.sourceLocation,
|
|
64
|
+
sourceRange: params.sourceRange,
|
|
64
65
|
likelyRootCause: params.likelyRootCause,
|
|
65
66
|
recommendation: params.recommendation,
|
|
66
67
|
suggestedFix: params.suggestedFix,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DiscoveredRoute, Finding } from '../types/findings.js';
|
|
2
|
+
export interface SemanticChangeCategory {
|
|
3
|
+
affectsMetadata: boolean;
|
|
4
|
+
affectsCanonical: boolean;
|
|
5
|
+
affectsSchema: boolean;
|
|
6
|
+
affectsLinks: boolean;
|
|
7
|
+
affectsRobotsOrSitemap: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface DifferentialAuditResult {
|
|
10
|
+
schemaVersion: 'seo.gravity/v1';
|
|
11
|
+
projectPath: string;
|
|
12
|
+
baseRef: string;
|
|
13
|
+
headRef: string;
|
|
14
|
+
changedFiles: string[];
|
|
15
|
+
affectedRoutes: DiscoveredRoute[];
|
|
16
|
+
unaffectedRoutesCount: number;
|
|
17
|
+
semanticImpacts: Record<string, SemanticChangeCategory>;
|
|
18
|
+
targetedFindings: Finding[];
|
|
19
|
+
regressionDetected: boolean;
|
|
20
|
+
summary: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function getChangedFilesSince(projectDir: string, baseRef?: string): string[];
|
|
23
|
+
export declare function analyzeSemanticFileChange(filePath: string, projectDir: string): SemanticChangeCategory;
|
|
24
|
+
export declare function mapChangedFilesToRoutes(changedFiles: string[], routes: DiscoveredRoute[]): {
|
|
25
|
+
affected: DiscoveredRoute[];
|
|
26
|
+
unaffected: DiscoveredRoute[];
|
|
27
|
+
};
|
|
28
|
+
export declare function runDifferentialAudit(projectDir: string, baseRef?: string, baseUrl?: string): Promise<DifferentialAuditResult>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { getProjectAdapter } from './projectScanner.js';
|
|
5
|
+
import { createProjectSnapshot } from './snapshotEngine.js';
|
|
6
|
+
import { inspectSourceFileAST } from './astLocator.js';
|
|
7
|
+
export function getChangedFilesSince(projectDir, baseRef = 'HEAD~1') {
|
|
8
|
+
try {
|
|
9
|
+
const output = execSync(`git diff --name-only ${baseRef}`, {
|
|
10
|
+
cwd: projectDir,
|
|
11
|
+
encoding: 'utf-8',
|
|
12
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
13
|
+
});
|
|
14
|
+
return output.split('\n').map(l => l.trim().replace(/\\/g, '/')).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
try {
|
|
18
|
+
const status = execSync('git status --porcelain', {
|
|
19
|
+
cwd: projectDir,
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
22
|
+
});
|
|
23
|
+
return status
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map(l => l.slice(3).trim().replace(/\\/g, '/'))
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function analyzeSemanticFileChange(filePath, projectDir) {
|
|
34
|
+
const fullPath = path.join(projectDir, filePath);
|
|
35
|
+
if (!fs.existsSync(fullPath)) {
|
|
36
|
+
return {
|
|
37
|
+
affectsMetadata: true,
|
|
38
|
+
affectsCanonical: true,
|
|
39
|
+
affectsSchema: true,
|
|
40
|
+
affectsLinks: true,
|
|
41
|
+
affectsRobotsOrSitemap: true
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const base = path.basename(filePath).toLowerCase();
|
|
45
|
+
if (base.includes('sitemap') || base.includes('robots') || base.includes('llms')) {
|
|
46
|
+
return {
|
|
47
|
+
affectsMetadata: false,
|
|
48
|
+
affectsCanonical: false,
|
|
49
|
+
affectsSchema: false,
|
|
50
|
+
affectsLinks: false,
|
|
51
|
+
affectsRobotsOrSitemap: true
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const ast = inspectSourceFileAST(fullPath);
|
|
55
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
56
|
+
return {
|
|
57
|
+
affectsMetadata: ast.hasMetadataExport || ast.hasGenerateMetadata || /<title\b|<meta\s+name/i.test(content),
|
|
58
|
+
affectsCanonical: ast.hasCanonicalDeclaration || /rel=["']canonical["']/i.test(content),
|
|
59
|
+
affectsSchema: ast.hasSchemaMarkup || /application\/ld\+json/i.test(content),
|
|
60
|
+
affectsLinks: /<Link\b|<a\b|href=/i.test(content),
|
|
61
|
+
affectsRobotsOrSitemap: false
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function mapChangedFilesToRoutes(changedFiles, routes) {
|
|
65
|
+
const affected = [];
|
|
66
|
+
const unaffected = [];
|
|
67
|
+
const isGlobalFile = changedFiles.some(f => f.includes('layout.') ||
|
|
68
|
+
f.includes('sitemap.') ||
|
|
69
|
+
f.includes('robots.') ||
|
|
70
|
+
f.includes('package.json') ||
|
|
71
|
+
f.includes('next.config') ||
|
|
72
|
+
f.includes('astro.config'));
|
|
73
|
+
for (const r of routes) {
|
|
74
|
+
if (isGlobalFile || changedFiles.some(f => r.sourceFilePath.includes(f) || f.includes(r.sourceFilePath))) {
|
|
75
|
+
affected.push(r);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
unaffected.push(r);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { affected, unaffected };
|
|
82
|
+
}
|
|
83
|
+
export async function runDifferentialAudit(projectDir, baseRef = 'HEAD~1', baseUrl) {
|
|
84
|
+
const resolved = path.resolve(projectDir);
|
|
85
|
+
const adapter = getProjectAdapter(resolved);
|
|
86
|
+
const routes = adapter.discoverRoutes(resolved);
|
|
87
|
+
const changedFiles = getChangedFilesSince(resolved, baseRef);
|
|
88
|
+
const { affected, unaffected } = mapChangedFilesToRoutes(changedFiles, routes);
|
|
89
|
+
const semanticImpacts = {};
|
|
90
|
+
for (const file of changedFiles) {
|
|
91
|
+
semanticImpacts[file] = analyzeSemanticFileChange(file, resolved);
|
|
92
|
+
}
|
|
93
|
+
const snapshot = await createProjectSnapshot(resolved, { baseUrl });
|
|
94
|
+
const targetedFindings = snapshot.findings.filter(f => affected.some(r => r.routePath === f.affectedUrl || f.sourceLocation?.filePath === r.sourceFilePath));
|
|
95
|
+
const hasCritical = targetedFindings.some(f => f.severity === 'critical' || f.severity === 'high');
|
|
96
|
+
return {
|
|
97
|
+
schemaVersion: 'seo.gravity/v1',
|
|
98
|
+
projectPath: resolved,
|
|
99
|
+
baseRef,
|
|
100
|
+
headRef: snapshot.gitMetadata?.shortHash || 'HEAD',
|
|
101
|
+
changedFiles,
|
|
102
|
+
affectedRoutes: affected,
|
|
103
|
+
unaffectedRoutesCount: unaffected.length,
|
|
104
|
+
semanticImpacts,
|
|
105
|
+
targetedFindings,
|
|
106
|
+
regressionDetected: hasCritical,
|
|
107
|
+
summary: `Differential audit completed: ${changedFiles.length} file(s) changed, ${affected.length} route(s) affected, ${targetedFindings.length} targeted finding(s) detected.`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface SEOpportunity {
|
|
2
|
+
id: string;
|
|
3
|
+
category: 'content_expansion' | 'entity_coverage' | 'schema_enhancement' | 'internal_link_hub';
|
|
4
|
+
title: string;
|
|
5
|
+
targetQueryOrTopic: string;
|
|
6
|
+
rationale: string;
|
|
7
|
+
recommendedRoutePath: string;
|
|
8
|
+
estimatedReach: 'high' | 'medium' | 'low';
|
|
9
|
+
effort: 'low' | 'medium' | 'high';
|
|
10
|
+
competitorEvidence?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface OpportunityReport {
|
|
13
|
+
schemaVersion: 'seo.gravity/v1';
|
|
14
|
+
projectPath: string;
|
|
15
|
+
totalOpportunitiesFound: number;
|
|
16
|
+
opportunities: SEOpportunity[];
|
|
17
|
+
summary: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function detectOpportunities(projectPath: string, focusKeywords?: string[]): Promise<OpportunityReport>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getProjectAdapter } from './projectScanner.js';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export async function detectOpportunities(projectPath, focusKeywords = []) {
|
|
4
|
+
const resolved = path.resolve(projectPath);
|
|
5
|
+
const adapter = getProjectAdapter(resolved);
|
|
6
|
+
const routes = adapter.discoverRoutes(resolved);
|
|
7
|
+
const opportunities = [];
|
|
8
|
+
const routePaths = routes.map(r => r.routePath);
|
|
9
|
+
// 1. Check for core programmatic SEO / comparison hubs
|
|
10
|
+
const hasDocs = routePaths.some(p => p.includes('doc') || p.includes('guide'));
|
|
11
|
+
const hasBlog = routePaths.some(p => p.includes('blog') || p.includes('post'));
|
|
12
|
+
const hasFaq = routePaths.some(p => p.includes('faq'));
|
|
13
|
+
if (!hasFaq) {
|
|
14
|
+
opportunities.push({
|
|
15
|
+
id: 'OPP-FAQ-001',
|
|
16
|
+
category: 'content_expansion',
|
|
17
|
+
title: 'Create Dedicated FAQ & Answer Hub',
|
|
18
|
+
targetQueryOrTopic: 'Frequently Asked Questions & Product Specs',
|
|
19
|
+
rationale: 'Sites with dedicated FAQ sections qualify for FAQPage rich snippets and capture high-intent long-tail search.',
|
|
20
|
+
recommendedRoutePath: '/faq',
|
|
21
|
+
estimatedReach: 'high',
|
|
22
|
+
effort: 'low'
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (!hasBlog && !hasDocs) {
|
|
26
|
+
opportunities.push({
|
|
27
|
+
id: 'OPP-CONTENT-001',
|
|
28
|
+
category: 'content_expansion',
|
|
29
|
+
title: 'Establish Top-of-Funnel Educational Hub (/blog or /docs)',
|
|
30
|
+
targetQueryOrTopic: 'Industry Guides & Use-Case Articles',
|
|
31
|
+
rationale: 'Adding educational subtopics increases domain topical authority and internal linking equity.',
|
|
32
|
+
recommendedRoutePath: '/blog',
|
|
33
|
+
estimatedReach: 'high',
|
|
34
|
+
effort: 'medium'
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
// 2. Keyword-driven route opportunities
|
|
38
|
+
for (const kw of focusKeywords) {
|
|
39
|
+
const slug = kw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
40
|
+
const isCovered = routePaths.some(r => r.includes(slug));
|
|
41
|
+
if (!isCovered) {
|
|
42
|
+
opportunities.push({
|
|
43
|
+
id: `OPP-KW-${slug.slice(0, 10).toUpperCase()}`,
|
|
44
|
+
category: 'content_expansion',
|
|
45
|
+
title: `Create Target Landing Page for "${kw}"`,
|
|
46
|
+
targetQueryOrTopic: kw,
|
|
47
|
+
rationale: `Target keyword "${kw}" has search intent but lacks a matching dedicated route on the site.`,
|
|
48
|
+
recommendedRoutePath: `/${slug}`,
|
|
49
|
+
estimatedReach: 'medium',
|
|
50
|
+
effort: 'low'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
schemaVersion: 'seo.gravity/v1',
|
|
56
|
+
projectPath: resolved,
|
|
57
|
+
totalOpportunitiesFound: opportunities.length,
|
|
58
|
+
opportunities,
|
|
59
|
+
summary: `Identified ${opportunities.length} strategic expansion opportunities to improve topical authority and organic reach.`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ProjectFrameworkInfo, DiscoveredRoute, RouteSourceMapping } from '../types/findings.js';
|
|
2
|
+
import { FrameworkAdapter } from '../adapters/types.js';
|
|
3
|
+
export declare function getProjectAdapter(projectDir: string): FrameworkAdapter;
|
|
2
4
|
export declare function detectFramework(projectDir: string): ProjectFrameworkInfo;
|
|
3
|
-
export declare function discoverRoutes(projectDir: string,
|
|
4
|
-
export declare function mapUrlToRouteSource(targetUrl: string, discoveredRoutes: DiscoveredRoute[]): RouteSourceMapping;
|
|
5
|
+
export declare function discoverRoutes(projectDir: string, _frameworkInfo?: ProjectFrameworkInfo): DiscoveredRoute[];
|
|
6
|
+
export declare function mapUrlToRouteSource(targetUrl: string, discoveredRoutes: DiscoveredRoute[], projectDir?: string): RouteSourceMapping;
|