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.
Files changed (67) hide show
  1. package/README.md +118 -105
  2. package/dist/adapters/adapterRegistry.d.ts +7 -0
  3. package/dist/adapters/adapterRegistry.js +30 -0
  4. package/dist/adapters/astroAdapter.d.ts +17 -0
  5. package/dist/adapters/astroAdapter.js +191 -0
  6. package/dist/adapters/nextAppAdapter.d.ts +17 -0
  7. package/dist/adapters/nextAppAdapter.js +265 -0
  8. package/dist/adapters/nextPagesAdapter.d.ts +17 -0
  9. package/dist/adapters/nextPagesAdapter.js +209 -0
  10. package/dist/adapters/remixAdapter.d.ts +16 -0
  11. package/dist/adapters/remixAdapter.js +118 -0
  12. package/dist/adapters/staticAdapter.d.ts +16 -0
  13. package/dist/adapters/staticAdapter.js +117 -0
  14. package/dist/adapters/svelteKitAdapter.d.ts +16 -0
  15. package/dist/adapters/svelteKitAdapter.js +122 -0
  16. package/dist/adapters/types.d.ts +39 -0
  17. package/dist/adapters/types.js +1 -0
  18. package/dist/adapters/viteReactAdapter.d.ts +16 -0
  19. package/dist/adapters/viteReactAdapter.js +189 -0
  20. package/dist/benchmark/correlationBenchmark.d.ts +1 -0
  21. package/dist/benchmark/correlationBenchmark.js +96 -0
  22. package/dist/benchmark/invariantBenchmark.d.ts +1 -0
  23. package/dist/benchmark/invariantBenchmark.js +47 -0
  24. package/dist/cli.d.ts +8 -0
  25. package/dist/cli.js +162 -0
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +8 -646
  28. package/dist/invariants/registry.d.ts +12 -0
  29. package/dist/invariants/registry.js +178 -0
  30. package/dist/invariants/types.d.ts +38 -0
  31. package/dist/invariants/types.js +1 -0
  32. package/dist/providers/providerRegistry.d.ts +65 -0
  33. package/dist/providers/providerRegistry.js +64 -0
  34. package/dist/providers/types.d.ts +22 -0
  35. package/dist/providers/types.js +1 -0
  36. package/dist/server/registry.d.ts +3 -0
  37. package/dist/server/registry.js +559 -0
  38. package/dist/server/server.d.ts +70 -0
  39. package/dist/server/server.js +53 -0
  40. package/dist/server/types.d.ts +7 -0
  41. package/dist/server/types.js +1 -0
  42. package/dist/test.js +108 -138
  43. package/dist/tools/orchestration.js +23 -21
  44. package/dist/types/canonical.d.ts +64 -0
  45. package/dist/types/canonical.js +1 -0
  46. package/dist/types/findings.d.ts +22 -0
  47. package/dist/types/findings.js +1 -1
  48. package/dist/types/seo.d.ts +24 -0
  49. package/dist/utils/astLocator.d.ts +14 -0
  50. package/dist/utils/astLocator.js +137 -0
  51. package/dist/utils/cacheManager.d.ts +29 -0
  52. package/dist/utils/cacheManager.js +60 -0
  53. package/dist/utils/experimentEngine.d.ts +26 -0
  54. package/dist/utils/experimentEngine.js +41 -0
  55. package/dist/utils/findingEngine.d.ts +2 -1
  56. package/dist/utils/findingEngine.js +1 -0
  57. package/dist/utils/gitDiffEngine.d.ts +28 -0
  58. package/dist/utils/gitDiffEngine.js +109 -0
  59. package/dist/utils/opportunityEngine.d.ts +19 -0
  60. package/dist/utils/opportunityEngine.js +61 -0
  61. package/dist/utils/projectScanner.d.ts +4 -2
  62. package/dist/utils/projectScanner.js +14 -346
  63. package/dist/utils/sarifExporter.d.ts +64 -0
  64. package/dist/utils/sarifExporter.js +81 -0
  65. package/dist/utils/snapshotEngine.d.ts +3 -1
  66. package/dist/utils/snapshotEngine.js +232 -94
  67. package/package.json +8 -5
@@ -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, frameworkInfo?: ProjectFrameworkInfo): DiscoveredRoute[];
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;