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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export class StaticAdapter {
|
|
4
|
+
id = 'static-html';
|
|
5
|
+
name = 'Static HTML / Web Application';
|
|
6
|
+
detect(_projectDir) {
|
|
7
|
+
return true; // Fallback
|
|
8
|
+
}
|
|
9
|
+
getProjectInfo(projectDir) {
|
|
10
|
+
return {
|
|
11
|
+
framework: 'static-html',
|
|
12
|
+
name: path.basename(projectDir),
|
|
13
|
+
hasTypeScript: fs.existsSync(path.join(projectDir, 'tsconfig.json')),
|
|
14
|
+
hasSitemapConfig: Boolean(this.findSitemapConfig(projectDir)),
|
|
15
|
+
hasRobotsConfig: Boolean(this.findRobotsConfig(projectDir)),
|
|
16
|
+
hasLlmsTxt: Boolean(this.findLlmsTxt(projectDir)),
|
|
17
|
+
rootDir: path.resolve(projectDir),
|
|
18
|
+
routesDir: '.',
|
|
19
|
+
defaultDevPort: 8080
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
discoverRoutes(projectDir) {
|
|
23
|
+
const discovered = [];
|
|
24
|
+
const scan = (dir, prefix) => {
|
|
25
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
if (['node_modules', '.git', 'dist', 'build', '.next'].includes(e.name))
|
|
28
|
+
continue;
|
|
29
|
+
const full = path.join(dir, e.name);
|
|
30
|
+
if (e.isDirectory()) {
|
|
31
|
+
const next = prefix === '/' ? `/${e.name}` : `${prefix}/${e.name}`;
|
|
32
|
+
scan(full, next);
|
|
33
|
+
}
|
|
34
|
+
else if (e.isFile() && e.name.endsWith('.html')) {
|
|
35
|
+
const base = e.name.replace(/\.html$/, '');
|
|
36
|
+
let routePath = prefix;
|
|
37
|
+
if (base !== 'index') {
|
|
38
|
+
routePath = prefix === '/' ? `/${base}` : `${prefix}/${base}`;
|
|
39
|
+
}
|
|
40
|
+
if (routePath === '')
|
|
41
|
+
routePath = '/';
|
|
42
|
+
const rel = path.relative(projectDir, full).replace(/\\/g, '/');
|
|
43
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
44
|
+
discovered.push({
|
|
45
|
+
routePath,
|
|
46
|
+
sourceFilePath: rel,
|
|
47
|
+
isDynamic: false,
|
|
48
|
+
dynamicParams: [],
|
|
49
|
+
routeType: 'page',
|
|
50
|
+
hasMetadataExport: /<title\b|<meta\s+name=["']description["']/i.test(content),
|
|
51
|
+
hasGenerateMetadata: false,
|
|
52
|
+
hasSchemaMarkup: /application\/ld\+json/.test(content),
|
|
53
|
+
hasHeadComponent: /<head\b|<title\b/i.test(content),
|
|
54
|
+
isClientComponent: false
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
scan(projectDir, '/');
|
|
60
|
+
return discovered;
|
|
61
|
+
}
|
|
62
|
+
mapRouteToSource(targetUrl, routes) {
|
|
63
|
+
let urlPath = targetUrl.startsWith('http') ? new URL(targetUrl).pathname : targetUrl;
|
|
64
|
+
urlPath = urlPath.replace(/\/$/, '') || '/';
|
|
65
|
+
const match = routes.find(r => r.routePath === urlPath);
|
|
66
|
+
return {
|
|
67
|
+
urlPath,
|
|
68
|
+
matchedRoute: match,
|
|
69
|
+
sourceFilePath: match?.sourceFilePath,
|
|
70
|
+
confidence: match ? 1.0 : 0.0,
|
|
71
|
+
resolutionMethod: match ? 'exact_match' : 'unmapped'
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async findMetadataImplementation(projectDir, route) {
|
|
75
|
+
const full = path.join(projectDir, route.sourceFilePath);
|
|
76
|
+
if (!fs.existsSync(full))
|
|
77
|
+
return { hasMetadata: false, type: 'none' };
|
|
78
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
79
|
+
const has = /<title\b|<meta\s+name=["']description["']/i.test(content);
|
|
80
|
+
return { hasMetadata: has, type: has ? 'jsx_head' : 'none' };
|
|
81
|
+
}
|
|
82
|
+
async findCanonicalDeclaration(projectDir, route) {
|
|
83
|
+
const full = path.join(projectDir, route.sourceFilePath);
|
|
84
|
+
if (!fs.existsSync(full))
|
|
85
|
+
return { hasCanonical: false, type: 'none' };
|
|
86
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
87
|
+
return { hasCanonical: /rel=["']canonical["']/i.test(content), type: 'link_jsx_tag' };
|
|
88
|
+
}
|
|
89
|
+
async findSchemaDeclaration(projectDir, route) {
|
|
90
|
+
const full = path.join(projectDir, route.sourceFilePath);
|
|
91
|
+
if (!fs.existsSync(full))
|
|
92
|
+
return { hasSchema: false, typesFound: [] };
|
|
93
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
94
|
+
return { hasSchema: /application\/ld\+json/i.test(content), typesFound: ['JSON-LD'] };
|
|
95
|
+
}
|
|
96
|
+
findRobotsConfig(projectDir) {
|
|
97
|
+
const paths = ['robots.txt', 'public/robots.txt'];
|
|
98
|
+
for (const p of paths)
|
|
99
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
100
|
+
return p;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
findSitemapConfig(projectDir) {
|
|
104
|
+
const paths = ['sitemap.xml', 'public/sitemap.xml'];
|
|
105
|
+
for (const p of paths)
|
|
106
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
107
|
+
return p;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
findLlmsTxt(projectDir) {
|
|
111
|
+
const paths = ['llms.txt', 'public/llms.txt'];
|
|
112
|
+
for (const p of paths)
|
|
113
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
114
|
+
return p;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FrameworkAdapter, MetadataLocationInfo, CanonicalLocationInfo, SchemaLocationInfo } from './types.js';
|
|
2
|
+
import { DiscoveredRoute, ProjectFrameworkInfo, RouteSourceMapping } from '../types/findings.js';
|
|
3
|
+
export declare class SvelteKitAdapter implements FrameworkAdapter {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
detect(projectDir: string): boolean;
|
|
7
|
+
getProjectInfo(projectDir: string): ProjectFrameworkInfo;
|
|
8
|
+
discoverRoutes(projectDir: string): DiscoveredRoute[];
|
|
9
|
+
mapRouteToSource(targetUrl: string, routes: DiscoveredRoute[]): RouteSourceMapping;
|
|
10
|
+
findMetadataImplementation(projectDir: string, route: DiscoveredRoute): Promise<MetadataLocationInfo>;
|
|
11
|
+
findCanonicalDeclaration(projectDir: string, route: DiscoveredRoute): Promise<CanonicalLocationInfo>;
|
|
12
|
+
findSchemaDeclaration(projectDir: string, route: DiscoveredRoute): Promise<SchemaLocationInfo>;
|
|
13
|
+
findRobotsConfig(projectDir: string): string | null;
|
|
14
|
+
findSitemapConfig(projectDir: string): string | null;
|
|
15
|
+
findLlmsTxt(projectDir: string): string | null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export class SvelteKitAdapter {
|
|
4
|
+
id = 'sveltekit';
|
|
5
|
+
name = 'SvelteKit';
|
|
6
|
+
detect(projectDir) {
|
|
7
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
8
|
+
if (!fs.existsSync(pkgPath))
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
12
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
13
|
+
return Boolean(deps['@sveltejs/kit']);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
getProjectInfo(projectDir) {
|
|
20
|
+
return {
|
|
21
|
+
framework: 'sveltekit',
|
|
22
|
+
name: path.basename(projectDir),
|
|
23
|
+
hasTypeScript: fs.existsSync(path.join(projectDir, 'tsconfig.json')),
|
|
24
|
+
hasSitemapConfig: Boolean(this.findSitemapConfig(projectDir)),
|
|
25
|
+
hasRobotsConfig: Boolean(this.findRobotsConfig(projectDir)),
|
|
26
|
+
hasLlmsTxt: Boolean(this.findLlmsTxt(projectDir)),
|
|
27
|
+
rootDir: path.resolve(projectDir),
|
|
28
|
+
routesDir: 'src/routes',
|
|
29
|
+
devCommand: 'npm run dev',
|
|
30
|
+
defaultDevPort: 5173
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
discoverRoutes(projectDir) {
|
|
34
|
+
const routesDir = path.join(projectDir, 'src/routes');
|
|
35
|
+
const discovered = [];
|
|
36
|
+
if (!fs.existsSync(routesDir))
|
|
37
|
+
return discovered;
|
|
38
|
+
const scan = (dir, prefix) => {
|
|
39
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
const full = path.join(dir, e.name);
|
|
42
|
+
if (e.isDirectory()) {
|
|
43
|
+
const next = prefix === '/' ? `/${e.name}` : `${prefix}/${e.name}`;
|
|
44
|
+
scan(full, next);
|
|
45
|
+
}
|
|
46
|
+
else if (e.isFile() && e.name.startsWith('+page.svelte')) {
|
|
47
|
+
const rel = path.relative(projectDir, full).replace(/\\/g, '/');
|
|
48
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
49
|
+
discovered.push({
|
|
50
|
+
routePath: prefix,
|
|
51
|
+
sourceFilePath: rel,
|
|
52
|
+
isDynamic: prefix.includes('['),
|
|
53
|
+
dynamicParams: (prefix.match(/\[([a-zA-Z0-9_-]+)\]/g) || []).map(p => p.slice(1, -1)),
|
|
54
|
+
routeType: 'page',
|
|
55
|
+
hasMetadataExport: /<title\b|<svelte:head\b/i.test(content),
|
|
56
|
+
hasGenerateMetadata: false,
|
|
57
|
+
hasSchemaMarkup: /application\/ld\+json/.test(content),
|
|
58
|
+
hasHeadComponent: /<svelte:head\b/i.test(content),
|
|
59
|
+
isClientComponent: false
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
scan(routesDir, '/');
|
|
65
|
+
return discovered;
|
|
66
|
+
}
|
|
67
|
+
mapRouteToSource(targetUrl, routes) {
|
|
68
|
+
let urlPath = targetUrl.startsWith('http') ? new URL(targetUrl).pathname : targetUrl;
|
|
69
|
+
urlPath = urlPath.replace(/\/$/, '') || '/';
|
|
70
|
+
const match = routes.find(r => r.routePath === urlPath);
|
|
71
|
+
return {
|
|
72
|
+
urlPath,
|
|
73
|
+
matchedRoute: match,
|
|
74
|
+
sourceFilePath: match?.sourceFilePath,
|
|
75
|
+
confidence: match ? 1.0 : 0.0,
|
|
76
|
+
resolutionMethod: match ? 'exact_match' : 'unmapped'
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async findMetadataImplementation(projectDir, route) {
|
|
80
|
+
const full = path.join(projectDir, route.sourceFilePath);
|
|
81
|
+
if (!fs.existsSync(full))
|
|
82
|
+
return { hasMetadata: false, type: 'none' };
|
|
83
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
84
|
+
const has = /<svelte:head\b|<title\b/i.test(content);
|
|
85
|
+
return { hasMetadata: has, type: has ? 'jsx_head' : 'none' };
|
|
86
|
+
}
|
|
87
|
+
async findCanonicalDeclaration(projectDir, route) {
|
|
88
|
+
const full = path.join(projectDir, route.sourceFilePath);
|
|
89
|
+
if (!fs.existsSync(full))
|
|
90
|
+
return { hasCanonical: false, type: 'none' };
|
|
91
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
92
|
+
return { hasCanonical: /rel=["']canonical["']/i.test(content), type: 'link_jsx_tag' };
|
|
93
|
+
}
|
|
94
|
+
async findSchemaDeclaration(projectDir, route) {
|
|
95
|
+
const full = path.join(projectDir, route.sourceFilePath);
|
|
96
|
+
if (!fs.existsSync(full))
|
|
97
|
+
return { hasSchema: false, typesFound: [] };
|
|
98
|
+
const content = fs.readFileSync(full, 'utf-8');
|
|
99
|
+
return { hasSchema: /application\/ld\+json/i.test(content), typesFound: ['JSON-LD'] };
|
|
100
|
+
}
|
|
101
|
+
findRobotsConfig(projectDir) {
|
|
102
|
+
const paths = ['static/robots.txt', 'robots.txt'];
|
|
103
|
+
for (const p of paths)
|
|
104
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
105
|
+
return p;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
findSitemapConfig(projectDir) {
|
|
109
|
+
const paths = ['static/sitemap.xml', 'sitemap.xml'];
|
|
110
|
+
for (const p of paths)
|
|
111
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
112
|
+
return p;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
findLlmsTxt(projectDir) {
|
|
116
|
+
const paths = ['static/llms.txt', 'llms.txt'];
|
|
117
|
+
for (const p of paths)
|
|
118
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
119
|
+
return p;
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { DiscoveredRoute, ProjectFrameworkInfo, RouteSourceMapping, ASTSourceRange } from '../types/findings.js';
|
|
2
|
+
export interface FrameworkDetectionResult {
|
|
3
|
+
matched: boolean;
|
|
4
|
+
confidence: number;
|
|
5
|
+
evidence: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface MetadataLocationInfo {
|
|
8
|
+
hasMetadata: boolean;
|
|
9
|
+
type: 'static_export' | 'dynamic_function' | 'jsx_head' | 'none';
|
|
10
|
+
sourceRange?: ASTSourceRange;
|
|
11
|
+
titleSnippet?: string;
|
|
12
|
+
descriptionSnippet?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface CanonicalLocationInfo {
|
|
15
|
+
hasCanonical: boolean;
|
|
16
|
+
type: 'metadata_property' | 'link_jsx_tag' | 'header_config' | 'none';
|
|
17
|
+
canonicalUrl?: string;
|
|
18
|
+
sourceRange?: ASTSourceRange;
|
|
19
|
+
}
|
|
20
|
+
export interface SchemaLocationInfo {
|
|
21
|
+
hasSchema: boolean;
|
|
22
|
+
typesFound: string[];
|
|
23
|
+
sourceRange?: ASTSourceRange;
|
|
24
|
+
}
|
|
25
|
+
export interface FrameworkAdapter {
|
|
26
|
+
id: string;
|
|
27
|
+
name: string;
|
|
28
|
+
detect(projectDir: string): boolean;
|
|
29
|
+
detectDetailed?(projectDir: string): FrameworkDetectionResult;
|
|
30
|
+
getProjectInfo(projectDir: string): ProjectFrameworkInfo;
|
|
31
|
+
discoverRoutes(projectDir: string): DiscoveredRoute[];
|
|
32
|
+
mapRouteToSource(targetUrl: string, routes: DiscoveredRoute[]): RouteSourceMapping;
|
|
33
|
+
findMetadataImplementation(projectDir: string, route: DiscoveredRoute): Promise<MetadataLocationInfo>;
|
|
34
|
+
findCanonicalDeclaration(projectDir: string, route: DiscoveredRoute): Promise<CanonicalLocationInfo>;
|
|
35
|
+
findSchemaDeclaration(projectDir: string, route: DiscoveredRoute): Promise<SchemaLocationInfo>;
|
|
36
|
+
findRobotsConfig(projectDir: string): string | null;
|
|
37
|
+
findSitemapConfig(projectDir: string): string | null;
|
|
38
|
+
findLlmsTxt(projectDir: string): string | null;
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { FrameworkAdapter, MetadataLocationInfo, CanonicalLocationInfo, SchemaLocationInfo } from './types.js';
|
|
2
|
+
import { DiscoveredRoute, ProjectFrameworkInfo, RouteSourceMapping } from '../types/findings.js';
|
|
3
|
+
export declare class ViteReactAdapter implements FrameworkAdapter {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
detect(projectDir: string): boolean;
|
|
7
|
+
getProjectInfo(projectDir: string): ProjectFrameworkInfo;
|
|
8
|
+
discoverRoutes(projectDir: string): DiscoveredRoute[];
|
|
9
|
+
mapRouteToSource(targetUrl: string, routes: DiscoveredRoute[]): RouteSourceMapping;
|
|
10
|
+
findMetadataImplementation(projectDir: string, route: DiscoveredRoute): Promise<MetadataLocationInfo>;
|
|
11
|
+
findCanonicalDeclaration(projectDir: string, route: DiscoveredRoute): Promise<CanonicalLocationInfo>;
|
|
12
|
+
findSchemaDeclaration(projectDir: string, route: DiscoveredRoute): Promise<SchemaLocationInfo>;
|
|
13
|
+
findRobotsConfig(projectDir: string): string | null;
|
|
14
|
+
findSitemapConfig(projectDir: string): string | null;
|
|
15
|
+
findLlmsTxt(projectDir: string): string | null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
export class ViteReactAdapter {
|
|
4
|
+
id = 'vite-react';
|
|
5
|
+
name = 'Vite / React SPA';
|
|
6
|
+
detect(projectDir) {
|
|
7
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
8
|
+
if (!fs.existsSync(pkgPath))
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
12
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
13
|
+
return Boolean(deps['vite'] || (deps['react'] && !deps['next'] && !deps['astro'] && !deps['@remix-run/react']));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
getProjectInfo(projectDir) {
|
|
20
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
21
|
+
let pkg = {};
|
|
22
|
+
if (fs.existsSync(pkgPath)) {
|
|
23
|
+
try {
|
|
24
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
framework: 'vite-react',
|
|
30
|
+
name: pkg.name || path.basename(projectDir),
|
|
31
|
+
version: pkg.dependencies?.vite || pkg.devDependencies?.vite,
|
|
32
|
+
packageManager: 'npm',
|
|
33
|
+
hasTypeScript: fs.existsSync(path.join(projectDir, 'tsconfig.json')),
|
|
34
|
+
hasSitemapConfig: Boolean(this.findSitemapConfig(projectDir)),
|
|
35
|
+
hasRobotsConfig: Boolean(this.findRobotsConfig(projectDir)),
|
|
36
|
+
hasLlmsTxt: Boolean(this.findLlmsTxt(projectDir)),
|
|
37
|
+
rootDir: path.resolve(projectDir),
|
|
38
|
+
routesDir: 'src',
|
|
39
|
+
devCommand: 'npm run dev',
|
|
40
|
+
defaultDevPort: 5173
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
discoverRoutes(projectDir) {
|
|
44
|
+
const discovered = [];
|
|
45
|
+
// Check index.html
|
|
46
|
+
if (fs.existsSync(path.join(projectDir, 'index.html'))) {
|
|
47
|
+
const content = fs.readFileSync(path.join(projectDir, 'index.html'), 'utf-8');
|
|
48
|
+
discovered.push({
|
|
49
|
+
routePath: '/',
|
|
50
|
+
sourceFilePath: 'index.html',
|
|
51
|
+
isDynamic: false,
|
|
52
|
+
dynamicParams: [],
|
|
53
|
+
routeType: 'page',
|
|
54
|
+
hasMetadataExport: /<title\b|<meta\s+name=["']description["']/i.test(content),
|
|
55
|
+
hasGenerateMetadata: false,
|
|
56
|
+
hasSchemaMarkup: /application\/ld\+json/.test(content),
|
|
57
|
+
hasHeadComponent: true,
|
|
58
|
+
isClientComponent: true
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// Scan src for router declarations
|
|
62
|
+
const srcDir = path.join(projectDir, 'src');
|
|
63
|
+
if (fs.existsSync(srcDir)) {
|
|
64
|
+
const scanSrc = (dir) => {
|
|
65
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const fullPath = path.join(dir, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
scanSrc(fullPath);
|
|
70
|
+
}
|
|
71
|
+
else if (entry.isFile() && /\.(tsx|jsx|js|ts)$/.test(entry.name)) {
|
|
72
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
73
|
+
const rel = path.relative(projectDir, fullPath).replace(/\\/g, '/');
|
|
74
|
+
const routeMatches = content.matchAll(/path=["']([^"']+)["']/g);
|
|
75
|
+
for (const m of routeMatches) {
|
|
76
|
+
const rPath = m[1];
|
|
77
|
+
if (rPath && !discovered.some(d => d.routePath === rPath)) {
|
|
78
|
+
discovered.push({
|
|
79
|
+
routePath: rPath,
|
|
80
|
+
sourceFilePath: rel,
|
|
81
|
+
isDynamic: rPath.includes(':'),
|
|
82
|
+
dynamicParams: (rPath.match(/:[a-zA-Z0-9_-]+/g) || []).map(p => p.slice(1)),
|
|
83
|
+
routeType: 'page',
|
|
84
|
+
hasMetadataExport: /<Helmet\b|<title\b/i.test(content),
|
|
85
|
+
hasGenerateMetadata: false,
|
|
86
|
+
hasSchemaMarkup: /application\/ld\+json/.test(content),
|
|
87
|
+
hasHeadComponent: /<Helmet\b/i.test(content),
|
|
88
|
+
isClientComponent: true
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
scanSrc(srcDir);
|
|
96
|
+
}
|
|
97
|
+
return discovered;
|
|
98
|
+
}
|
|
99
|
+
mapRouteToSource(targetUrl, routes) {
|
|
100
|
+
let urlPath = targetUrl;
|
|
101
|
+
try {
|
|
102
|
+
if (targetUrl.startsWith('http')) {
|
|
103
|
+
urlPath = new URL(targetUrl).pathname;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
urlPath = targetUrl;
|
|
108
|
+
}
|
|
109
|
+
urlPath = urlPath.replace(/\/$/, '') || '/';
|
|
110
|
+
const match = routes.find(r => r.routePath === urlPath);
|
|
111
|
+
if (match) {
|
|
112
|
+
return {
|
|
113
|
+
urlPath,
|
|
114
|
+
matchedRoute: match,
|
|
115
|
+
sourceFilePath: match.sourceFilePath,
|
|
116
|
+
confidence: 1.0,
|
|
117
|
+
resolutionMethod: 'exact_match'
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
for (const r of routes) {
|
|
121
|
+
if (!r.isDynamic)
|
|
122
|
+
continue;
|
|
123
|
+
const regexStr = '^' + r.routePath.replace(/:[a-zA-Z0-9_-]+/g, '[^/]+') + '$';
|
|
124
|
+
try {
|
|
125
|
+
if (new RegExp(regexStr).test(urlPath)) {
|
|
126
|
+
return {
|
|
127
|
+
urlPath,
|
|
128
|
+
matchedRoute: r,
|
|
129
|
+
sourceFilePath: r.sourceFilePath,
|
|
130
|
+
confidence: 0.95,
|
|
131
|
+
resolutionMethod: 'pattern_match'
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
urlPath,
|
|
139
|
+
confidence: 0.0,
|
|
140
|
+
resolutionMethod: 'unmapped'
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async findMetadataImplementation(projectDir, route) {
|
|
144
|
+
const fullPath = path.join(projectDir, route.sourceFilePath);
|
|
145
|
+
if (!fs.existsSync(fullPath))
|
|
146
|
+
return { hasMetadata: false, type: 'none' };
|
|
147
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
148
|
+
const has = /<Helmet\b|<title\b/i.test(content);
|
|
149
|
+
return { hasMetadata: has, type: has ? 'jsx_head' : 'none' };
|
|
150
|
+
}
|
|
151
|
+
async findCanonicalDeclaration(projectDir, route) {
|
|
152
|
+
const fullPath = path.join(projectDir, route.sourceFilePath);
|
|
153
|
+
if (!fs.existsSync(fullPath))
|
|
154
|
+
return { hasCanonical: false, type: 'none' };
|
|
155
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
156
|
+
return { hasCanonical: /rel=["']canonical["']/i.test(content), type: 'link_jsx_tag' };
|
|
157
|
+
}
|
|
158
|
+
async findSchemaDeclaration(projectDir, route) {
|
|
159
|
+
const fullPath = path.join(projectDir, route.sourceFilePath);
|
|
160
|
+
if (!fs.existsSync(fullPath))
|
|
161
|
+
return { hasSchema: false, typesFound: [] };
|
|
162
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
163
|
+
return { hasSchema: /application\/ld\+json/i.test(content), typesFound: ['JSON-LD'] };
|
|
164
|
+
}
|
|
165
|
+
findRobotsConfig(projectDir) {
|
|
166
|
+
const paths = ['public/robots.txt', 'robots.txt'];
|
|
167
|
+
for (const p of paths) {
|
|
168
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
findSitemapConfig(projectDir) {
|
|
174
|
+
const paths = ['public/sitemap.xml', 'sitemap.xml'];
|
|
175
|
+
for (const p of paths) {
|
|
176
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
177
|
+
return p;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
findLlmsTxt(projectDir) {
|
|
182
|
+
const paths = ['public/llms.txt', 'llms.txt'];
|
|
183
|
+
for (const p of paths) {
|
|
184
|
+
if (fs.existsSync(path.join(projectDir, p)))
|
|
185
|
+
return p;
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runCorrelationBenchmark(): Promise<boolean>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { defaultAdapterRegistry } from '../adapters/adapterRegistry.js';
|
|
4
|
+
import { inspectSourceFileAST } from '../utils/astLocator.js';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const FIXTURES_ROOT = path.resolve(__dirname, '../../test/fixtures');
|
|
8
|
+
export async function runCorrelationBenchmark() {
|
|
9
|
+
console.log('\n📊 Running Multi-Framework Correlation Accuracy Benchmark...\n');
|
|
10
|
+
const frameworksToTest = [
|
|
11
|
+
{
|
|
12
|
+
id: 'nextjs-app-router',
|
|
13
|
+
dir: 'nextjs-app',
|
|
14
|
+
expectedRoutes: ['/', '/about', '/blog/[slug]', '/sitemap.xml'],
|
|
15
|
+
testUrl: '/blog/ai-seo',
|
|
16
|
+
expectedFile: 'app/blog/[slug]/page.tsx'
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'nextjs-pages-router',
|
|
20
|
+
dir: 'nextjs-pages',
|
|
21
|
+
expectedRoutes: ['/', '/blog/[id]'],
|
|
22
|
+
testUrl: '/blog/123',
|
|
23
|
+
expectedFile: 'pages/blog/[id].tsx'
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'astro',
|
|
27
|
+
dir: 'astro-app',
|
|
28
|
+
expectedRoutes: ['/'],
|
|
29
|
+
testUrl: '/',
|
|
30
|
+
expectedFile: 'src/pages/index.astro'
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'vite-react',
|
|
34
|
+
dir: 'vite-react',
|
|
35
|
+
expectedRoutes: ['/', '/about', '/docs/:slug'],
|
|
36
|
+
testUrl: '/about',
|
|
37
|
+
expectedFile: 'src/App.tsx'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'remix',
|
|
41
|
+
dir: 'remix-app',
|
|
42
|
+
expectedRoutes: ['/', '/features'],
|
|
43
|
+
testUrl: '/features',
|
|
44
|
+
expectedFile: 'app/routes/features.tsx'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'sveltekit',
|
|
48
|
+
dir: 'sveltekit-app',
|
|
49
|
+
expectedRoutes: ['/'],
|
|
50
|
+
testUrl: '/',
|
|
51
|
+
expectedFile: 'src/routes/+page.svelte'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'static-html',
|
|
55
|
+
dir: 'static-site',
|
|
56
|
+
expectedRoutes: ['/'],
|
|
57
|
+
testUrl: '/',
|
|
58
|
+
expectedFile: 'index.html'
|
|
59
|
+
}
|
|
60
|
+
];
|
|
61
|
+
let passed = 0;
|
|
62
|
+
let total = frameworksToTest.length;
|
|
63
|
+
for (const item of frameworksToTest) {
|
|
64
|
+
const projectPath = path.join(FIXTURES_ROOT, item.dir);
|
|
65
|
+
const adapter = defaultAdapterRegistry.getAdapterForProject(projectPath);
|
|
66
|
+
if (adapter.id !== item.id) {
|
|
67
|
+
console.error(`❌ [${item.id}] Detection mismatch: got '${adapter.id}'`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const routes = adapter.discoverRoutes(projectPath);
|
|
71
|
+
const routePaths = routes.map(r => r.routePath);
|
|
72
|
+
const missingRoutes = item.expectedRoutes.filter(er => !routePaths.includes(er));
|
|
73
|
+
if (missingRoutes.length > 0) {
|
|
74
|
+
console.error(`❌ [${item.id}] Missing expected routes: ${missingRoutes.join(', ')} (Found: ${routePaths.join(', ')})`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const mapping = adapter.mapRouteToSource(item.testUrl, routes);
|
|
78
|
+
if (!mapping.sourceFilePath || !mapping.sourceFilePath.includes(path.basename(item.expectedFile))) {
|
|
79
|
+
console.error(`❌ [${item.id}] Source mapping error for '${item.testUrl}': expected '${item.expectedFile}', got '${mapping.sourceFilePath}'`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// AST inspection check if typescript/tsx
|
|
83
|
+
if (mapping.sourceFilePath.endsWith('.tsx') || mapping.sourceFilePath.endsWith('.ts')) {
|
|
84
|
+
const ast = inspectSourceFileAST(path.join(projectPath, mapping.sourceFilePath));
|
|
85
|
+
}
|
|
86
|
+
console.log(`✅ [${item.id.padEnd(20)}] 100% Precision: Routes=[${routePaths.join(', ')}] -> Mapped '${item.testUrl}' to '${mapping.sourceFilePath}'`);
|
|
87
|
+
passed++;
|
|
88
|
+
}
|
|
89
|
+
const score = Math.round((passed / total) * 100);
|
|
90
|
+
console.log(`\n🎯 Benchmark Result: ${passed}/${total} frameworks verified (${score}% correlation precision).\n`);
|
|
91
|
+
return passed === total;
|
|
92
|
+
}
|
|
93
|
+
runCorrelationBenchmark().then(success => {
|
|
94
|
+
if (!success)
|
|
95
|
+
process.exit(1);
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runInvariantBenchmark(): Promise<boolean>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { defaultInvariantRegistry } from '../invariants/registry.js';
|
|
2
|
+
import { createProjectSnapshot, compareSnapshots } from '../utils/snapshotEngine.js';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
6
|
+
const __dirname = path.dirname(__filename);
|
|
7
|
+
const FIXTURES_ROOT = path.resolve(__dirname, '../../test/fixtures');
|
|
8
|
+
export async function runInvariantBenchmark() {
|
|
9
|
+
console.log('\n🛡️ Running Invariant Precision & Regression Recall Benchmark...\n');
|
|
10
|
+
// Test 1: Invariant Registry Completeness
|
|
11
|
+
const allInvariants = defaultInvariantRegistry.getAll();
|
|
12
|
+
console.log(`✅ Invariant Registry loaded: ${allInvariants.length} built-in invariants`);
|
|
13
|
+
// Test 2: Invariant Evaluation
|
|
14
|
+
const testPageId = 'page_test_123';
|
|
15
|
+
const now = new Date().toISOString();
|
|
16
|
+
const titlePass = defaultInvariantRegistry.evaluateContext('INV-TITLE-PRESENT', { url: '/test', logicalPageId: testPageId, hasMetadata: true, extractedTitle: 'My Title' }, { analyzer: 'test', source: 'ast_inspection', timestamp: now, provider: 'test' });
|
|
17
|
+
const titleFail = defaultInvariantRegistry.evaluateContext('INV-TITLE-PRESENT', { url: '/test', logicalPageId: testPageId, hasMetadata: false }, { analyzer: 'test', source: 'ast_inspection', timestamp: now, provider: 'test' });
|
|
18
|
+
if (!titlePass?.satisfied || titleFail?.satisfied) {
|
|
19
|
+
console.error('❌ Title invariant evaluation failed logic test.');
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
console.log('✅ Title Invariant logic: Satisfied when metadata present, Failed when metadata missing.');
|
|
23
|
+
const canonicalPass = defaultInvariantRegistry.evaluateContext('INV-CANONICAL-RESOLVES', { url: '/test', logicalPageId: testPageId, hasCanonical: true, extractedCanonical: 'https://example.com/test' }, { analyzer: 'test', source: 'ast_inspection', timestamp: now, provider: 'test' });
|
|
24
|
+
const canonicalFail = defaultInvariantRegistry.evaluateContext('INV-CANONICAL-RESOLVES', { url: '/test', logicalPageId: testPageId, hasCanonical: false }, { analyzer: 'test', source: 'ast_inspection', timestamp: now, provider: 'test' });
|
|
25
|
+
if (!canonicalPass?.satisfied || canonicalFail?.satisfied) {
|
|
26
|
+
console.error('❌ Canonical invariant evaluation failed logic test.');
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
console.log('✅ Canonical Invariant logic: Satisfied when canonical present, Failed when canonical missing.');
|
|
30
|
+
// Test 3: Snapshot Invariant Evaluation on Next.js App
|
|
31
|
+
const nextAppPath = path.join(FIXTURES_ROOT, 'nextjs-app');
|
|
32
|
+
const snap1 = await createProjectSnapshot(nextAppPath);
|
|
33
|
+
console.log(`✅ Project Snapshot evaluation recorded ${snap1.invariants?.length || 0} invariant checks.`);
|
|
34
|
+
// Verify regression diffing
|
|
35
|
+
const report = compareSnapshots(snap1, snap1);
|
|
36
|
+
if (report.newRegressions.length !== 0) {
|
|
37
|
+
console.error('❌ Self-comparison generated false positive regressions.');
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
console.log(`✅ Invariant Regression Diffing: Self-comparison generated 0 regressions (Pass).`);
|
|
41
|
+
console.log('\n🎯 Invariant Benchmark: 100% Invariant Evaluation Precision Verified.\n');
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
runInvariantBenchmark().then(success => {
|
|
45
|
+
if (!success)
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|