seo-gravity-mcp 1.3.2 → 1.3.3
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 +1 -1
- package/dist/adapters/adapterRegistry.d.ts +1 -0
- package/dist/adapters/adapterRegistry.js +6 -2
- package/dist/adapters/staticAdapter.d.ts +1 -1
- package/dist/adapters/staticAdapter.js +3 -2
- package/dist/adapters/unknownAdapter.d.ts +16 -0
- package/dist/adapters/unknownAdapter.js +49 -0
- package/dist/benchmark/methodology.js +1 -1
- package/dist/invariants/registry.js +5 -5
- package/dist/invariants/types.d.ts +6 -0
- package/dist/policy/loader.d.ts +124 -1
- package/dist/policy/loader.js +52 -79
- package/dist/test.js +90 -20
- package/dist/tools/orchestration.js +3 -7
- package/dist/types/evidence.d.ts +2 -2
- package/dist/types/seo.d.ts +2 -0
- package/dist/utils/astLocator.d.ts +2 -0
- package/dist/utils/astLocator.js +10 -1
- package/dist/utils/jsdomRenderer.js +2 -0
- package/dist/utils/projectScanner.d.ts +1 -1
- package/dist/utils/projectScanner.js +4 -2
- package/dist/utils/snapshotEngine.d.ts +1 -1
- package/dist/utils/snapshotEngine.js +60 -36
- package/dist/utils/urlNormalizer.d.ts +2 -0
- package/dist/utils/urlNormalizer.js +60 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -15,7 +15,9 @@ import { PhpClassicAdapter } from './phpClassicAdapter.js';
|
|
|
15
15
|
import { SsgAdapter } from './ssgAdapter.js';
|
|
16
16
|
import { ViteReactAdapter } from './viteReactAdapter.js';
|
|
17
17
|
import { StaticAdapter } from './staticAdapter.js';
|
|
18
|
+
import { UnknownAdapter } from './unknownAdapter.js';
|
|
18
19
|
export class AdapterRegistry {
|
|
20
|
+
unknownAdapter = new UnknownAdapter();
|
|
19
21
|
adapters = [
|
|
20
22
|
// Specialized JS/TS Frameworks
|
|
21
23
|
new NextAppAdapter(),
|
|
@@ -36,7 +38,7 @@ export class AdapterRegistry {
|
|
|
36
38
|
// Static Site Generators & SPAs
|
|
37
39
|
new SsgAdapter(),
|
|
38
40
|
new ViteReactAdapter(),
|
|
39
|
-
//
|
|
41
|
+
// Static HTML Sites
|
|
40
42
|
new StaticAdapter()
|
|
41
43
|
];
|
|
42
44
|
getAdapterForProject(projectDir) {
|
|
@@ -45,9 +47,11 @@ export class AdapterRegistry {
|
|
|
45
47
|
return adapter;
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
|
-
return this.
|
|
50
|
+
return this.unknownAdapter;
|
|
49
51
|
}
|
|
50
52
|
getAdapterById(id) {
|
|
53
|
+
if (id === 'unknown')
|
|
54
|
+
return this.unknownAdapter;
|
|
51
55
|
return this.adapters.find(a => a.id === id);
|
|
52
56
|
}
|
|
53
57
|
getAllAdapters() {
|
|
@@ -3,7 +3,7 @@ import { DiscoveredRoute, ProjectFrameworkInfo, RouteSourceMapping } from '../ty
|
|
|
3
3
|
export declare class StaticAdapter implements FrameworkAdapter {
|
|
4
4
|
id: string;
|
|
5
5
|
name: string;
|
|
6
|
-
detect(
|
|
6
|
+
detect(projectDir: string): boolean;
|
|
7
7
|
getProjectInfo(projectDir: string): ProjectFrameworkInfo;
|
|
8
8
|
discoverRoutes(projectDir: string): DiscoveredRoute[];
|
|
9
9
|
mapRouteToSource(targetUrl: string, routes: DiscoveredRoute[]): RouteSourceMapping;
|
|
@@ -3,8 +3,9 @@ import * as path from 'path';
|
|
|
3
3
|
export class StaticAdapter {
|
|
4
4
|
id = 'static-html';
|
|
5
5
|
name = 'Static HTML / Web Application';
|
|
6
|
-
detect(
|
|
7
|
-
return
|
|
6
|
+
detect(projectDir) {
|
|
7
|
+
return fs.existsSync(path.join(projectDir, 'index.html')) ||
|
|
8
|
+
fs.existsSync(path.join(projectDir, 'public/index.html'));
|
|
8
9
|
}
|
|
9
10
|
getProjectInfo(projectDir) {
|
|
10
11
|
return {
|
|
@@ -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 UnknownAdapter 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,49 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
export class UnknownAdapter {
|
|
3
|
+
id = 'unknown';
|
|
4
|
+
name = 'Unknown / Unrecognized Framework';
|
|
5
|
+
detect(_projectDir) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
getProjectInfo(projectDir) {
|
|
9
|
+
return {
|
|
10
|
+
framework: 'unknown',
|
|
11
|
+
name: path.basename(projectDir),
|
|
12
|
+
hasTypeScript: false,
|
|
13
|
+
hasSitemapConfig: false,
|
|
14
|
+
hasRobotsConfig: false,
|
|
15
|
+
hasLlmsTxt: false,
|
|
16
|
+
rootDir: path.resolve(projectDir),
|
|
17
|
+
routesDir: '.',
|
|
18
|
+
defaultDevPort: 3000
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
discoverRoutes(_projectDir) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
mapRouteToSource(targetUrl, _routes) {
|
|
25
|
+
return {
|
|
26
|
+
urlPath: targetUrl,
|
|
27
|
+
confidence: 0.0,
|
|
28
|
+
resolutionMethod: 'unmapped'
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async findMetadataImplementation(_projectDir, _route) {
|
|
32
|
+
return { hasMetadata: false, type: 'none' };
|
|
33
|
+
}
|
|
34
|
+
async findCanonicalDeclaration(_projectDir, _route) {
|
|
35
|
+
return { hasCanonical: false, type: 'none' };
|
|
36
|
+
}
|
|
37
|
+
async findSchemaDeclaration(_projectDir, _route) {
|
|
38
|
+
return { hasSchema: false, typesFound: [] };
|
|
39
|
+
}
|
|
40
|
+
findRobotsConfig(_projectDir) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
findSitemapConfig(_projectDir) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
findLlmsTxt(_projectDir) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -179,7 +179,7 @@ export function generateBenchmarkMethodologyReport() {
|
|
|
179
179
|
}
|
|
180
180
|
return {
|
|
181
181
|
title: 'SEO Gravity Multi-Framework Correlation & Invariant Benchmark Methodology',
|
|
182
|
-
version: '1.3.
|
|
182
|
+
version: '1.3.3',
|
|
183
183
|
evaluatedAt: new Date().toISOString(),
|
|
184
184
|
definitionOfCorrelation: 'A correlation is defined as a verified mapping connecting an observed URL or Route Pattern to its exact physical source file, template, route configuration, and AST symbol coordinate range.',
|
|
185
185
|
totalFrameworksTested: BENCHMARK_FIXTURES.length,
|
|
@@ -49,8 +49,8 @@ export const BUILTIN_INVARIANTS = [
|
|
|
49
49
|
type: 'ast',
|
|
50
50
|
description: has ? 'Canonical declaration found in AST' : 'Canonical missing from source AST',
|
|
51
51
|
sourceFile: ctx.sourceFilePath,
|
|
52
|
-
startLine:
|
|
53
|
-
endLine:
|
|
52
|
+
startLine: ctx.sourceRange?.startLine,
|
|
53
|
+
endLine: ctx.sourceRange?.endLine,
|
|
54
54
|
timestamp: new Date().toISOString()
|
|
55
55
|
} : undefined
|
|
56
56
|
};
|
|
@@ -69,7 +69,7 @@ export const BUILTIN_INVARIANTS = [
|
|
|
69
69
|
remediationGuide: 'Export title in page metadata, define @section/block title, or add <title> in component head.',
|
|
70
70
|
verificationMethod: 'Inspect page <title> in AST, template, or rendered DOM.',
|
|
71
71
|
evaluate(ctx) {
|
|
72
|
-
const has = Boolean(ctx.
|
|
72
|
+
const has = ctx.hasTitle !== undefined ? ctx.hasTitle : Boolean(ctx.extractedTitle || ctx.hasMetadata);
|
|
73
73
|
return {
|
|
74
74
|
satisfied: has,
|
|
75
75
|
observedCondition: has ? `Title present ("${ctx.extractedTitle || 'Declared'}")` : 'Title missing',
|
|
@@ -78,8 +78,8 @@ export const BUILTIN_INVARIANTS = [
|
|
|
78
78
|
type: 'ast',
|
|
79
79
|
description: has ? `Title metadata: "${ctx.extractedTitle || 'Declared'}"` : 'Title metadata missing',
|
|
80
80
|
sourceFile: ctx.sourceFilePath,
|
|
81
|
-
startLine:
|
|
82
|
-
endLine:
|
|
81
|
+
startLine: ctx.sourceRange?.startLine,
|
|
82
|
+
endLine: ctx.sourceRange?.endLine,
|
|
83
83
|
timestamp: new Date().toISOString()
|
|
84
84
|
} : undefined
|
|
85
85
|
};
|
|
@@ -7,7 +7,13 @@ export interface InvariantEvaluationContext {
|
|
|
7
7
|
url: string;
|
|
8
8
|
logicalPageId: string;
|
|
9
9
|
sourceFilePath?: string;
|
|
10
|
+
sourceRange?: {
|
|
11
|
+
startLine: number;
|
|
12
|
+
endLine: number;
|
|
13
|
+
};
|
|
10
14
|
statusCode?: number;
|
|
15
|
+
hasTitle?: boolean;
|
|
16
|
+
hasDescription?: boolean;
|
|
11
17
|
hasMetadata?: boolean;
|
|
12
18
|
hasCanonical?: boolean;
|
|
13
19
|
isIndexable?: boolean;
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -1,9 +1,132 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { PolicyConfig } from './types.js';
|
|
2
3
|
import { InvariantDiffItem } from '../types/canonical.js';
|
|
4
|
+
export declare const InvariantRuleSchema: z.ZodObject<{
|
|
5
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
requirementLevel: z.ZodOptional<z.ZodEnum<["REQUIRED", "CONDITIONAL", "RECOMMENDED", "OPTIONAL"]>>;
|
|
7
|
+
severity: z.ZodOptional<z.ZodEnum<["critical", "high", "medium", "low", "info"]>>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
requirementLevel?: "REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL" | undefined;
|
|
10
|
+
severity?: "critical" | "high" | "medium" | "low" | "info" | undefined;
|
|
11
|
+
enabled?: boolean | undefined;
|
|
12
|
+
}, {
|
|
13
|
+
requirementLevel?: "REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL" | undefined;
|
|
14
|
+
severity?: "critical" | "high" | "medium" | "low" | "info" | undefined;
|
|
15
|
+
enabled?: boolean | undefined;
|
|
16
|
+
}>;
|
|
17
|
+
export declare const RegressionPolicySchema: z.ZodObject<{
|
|
18
|
+
failOnLevels: z.ZodOptional<z.ZodArray<z.ZodEnum<["REQUIRED", "CONDITIONAL", "RECOMMENDED", "OPTIONAL"]>, "many">>;
|
|
19
|
+
failOnSeverities: z.ZodOptional<z.ZodArray<z.ZodEnum<["critical", "high", "medium", "low", "info"]>, "many">>;
|
|
20
|
+
allowExpectedChanges: z.ZodOptional<z.ZodBoolean>;
|
|
21
|
+
maxAllowedRegressions: z.ZodOptional<z.ZodNumber>;
|
|
22
|
+
}, "strip", z.ZodTypeAny, {
|
|
23
|
+
allowExpectedChanges?: boolean | undefined;
|
|
24
|
+
failOnLevels?: ("REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL")[] | undefined;
|
|
25
|
+
failOnSeverities?: ("critical" | "high" | "medium" | "low" | "info")[] | undefined;
|
|
26
|
+
maxAllowedRegressions?: number | undefined;
|
|
27
|
+
}, {
|
|
28
|
+
allowExpectedChanges?: boolean | undefined;
|
|
29
|
+
failOnLevels?: ("REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL")[] | undefined;
|
|
30
|
+
failOnSeverities?: ("critical" | "high" | "medium" | "low" | "info")[] | undefined;
|
|
31
|
+
maxAllowedRegressions?: number | undefined;
|
|
32
|
+
}>;
|
|
33
|
+
export declare const PolicyConfigSchema: z.ZodObject<{
|
|
34
|
+
version: z.ZodOptional<z.ZodUnion<[z.ZodNumber, z.ZodString]>>;
|
|
35
|
+
profile: z.ZodOptional<z.ZodEnum<["strict", "balanced", "startup", "ecommerce", "documentation", "custom"]>>;
|
|
36
|
+
regression: z.ZodOptional<z.ZodObject<{
|
|
37
|
+
failOnLevels: z.ZodOptional<z.ZodArray<z.ZodEnum<["REQUIRED", "CONDITIONAL", "RECOMMENDED", "OPTIONAL"]>, "many">>;
|
|
38
|
+
failOnSeverities: z.ZodOptional<z.ZodArray<z.ZodEnum<["critical", "high", "medium", "low", "info"]>, "many">>;
|
|
39
|
+
allowExpectedChanges: z.ZodOptional<z.ZodBoolean>;
|
|
40
|
+
maxAllowedRegressions: z.ZodOptional<z.ZodNumber>;
|
|
41
|
+
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
allowExpectedChanges?: boolean | undefined;
|
|
43
|
+
failOnLevels?: ("REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL")[] | undefined;
|
|
44
|
+
failOnSeverities?: ("critical" | "high" | "medium" | "low" | "info")[] | undefined;
|
|
45
|
+
maxAllowedRegressions?: number | undefined;
|
|
46
|
+
}, {
|
|
47
|
+
allowExpectedChanges?: boolean | undefined;
|
|
48
|
+
failOnLevels?: ("REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL")[] | undefined;
|
|
49
|
+
failOnSeverities?: ("critical" | "high" | "medium" | "low" | "info")[] | undefined;
|
|
50
|
+
maxAllowedRegressions?: number | undefined;
|
|
51
|
+
}>>;
|
|
52
|
+
invariants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
53
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
54
|
+
requirementLevel: z.ZodOptional<z.ZodEnum<["REQUIRED", "CONDITIONAL", "RECOMMENDED", "OPTIONAL"]>>;
|
|
55
|
+
severity: z.ZodOptional<z.ZodEnum<["critical", "high", "medium", "low", "info"]>>;
|
|
56
|
+
}, "strip", z.ZodTypeAny, {
|
|
57
|
+
requirementLevel?: "REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL" | undefined;
|
|
58
|
+
severity?: "critical" | "high" | "medium" | "low" | "info" | undefined;
|
|
59
|
+
enabled?: boolean | undefined;
|
|
60
|
+
}, {
|
|
61
|
+
requirementLevel?: "REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL" | undefined;
|
|
62
|
+
severity?: "critical" | "high" | "medium" | "low" | "info" | undefined;
|
|
63
|
+
enabled?: boolean | undefined;
|
|
64
|
+
}>>>;
|
|
65
|
+
policy: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodAny>>;
|
|
66
|
+
framework: z.ZodOptional<z.ZodObject<{
|
|
67
|
+
adapter: z.ZodOptional<z.ZodString>;
|
|
68
|
+
force: z.ZodOptional<z.ZodBoolean>;
|
|
69
|
+
entrypoint: z.ZodOptional<z.ZodString>;
|
|
70
|
+
}, "strip", z.ZodTypeAny, {
|
|
71
|
+
adapter?: string | undefined;
|
|
72
|
+
force?: boolean | undefined;
|
|
73
|
+
entrypoint?: string | undefined;
|
|
74
|
+
}, {
|
|
75
|
+
adapter?: string | undefined;
|
|
76
|
+
force?: boolean | undefined;
|
|
77
|
+
entrypoint?: string | undefined;
|
|
78
|
+
}>>;
|
|
79
|
+
}, "strip", z.ZodTypeAny, {
|
|
80
|
+
framework?: {
|
|
81
|
+
adapter?: string | undefined;
|
|
82
|
+
force?: boolean | undefined;
|
|
83
|
+
entrypoint?: string | undefined;
|
|
84
|
+
} | undefined;
|
|
85
|
+
invariants?: Record<string, {
|
|
86
|
+
requirementLevel?: "REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL" | undefined;
|
|
87
|
+
severity?: "critical" | "high" | "medium" | "low" | "info" | undefined;
|
|
88
|
+
enabled?: boolean | undefined;
|
|
89
|
+
}> | undefined;
|
|
90
|
+
version?: string | number | undefined;
|
|
91
|
+
profile?: "strict" | "balanced" | "startup" | "ecommerce" | "documentation" | "custom" | undefined;
|
|
92
|
+
regression?: {
|
|
93
|
+
allowExpectedChanges?: boolean | undefined;
|
|
94
|
+
failOnLevels?: ("REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL")[] | undefined;
|
|
95
|
+
failOnSeverities?: ("critical" | "high" | "medium" | "low" | "info")[] | undefined;
|
|
96
|
+
maxAllowedRegressions?: number | undefined;
|
|
97
|
+
} | undefined;
|
|
98
|
+
policy?: Record<string, any> | undefined;
|
|
99
|
+
}, {
|
|
100
|
+
framework?: {
|
|
101
|
+
adapter?: string | undefined;
|
|
102
|
+
force?: boolean | undefined;
|
|
103
|
+
entrypoint?: string | undefined;
|
|
104
|
+
} | undefined;
|
|
105
|
+
invariants?: Record<string, {
|
|
106
|
+
requirementLevel?: "REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL" | undefined;
|
|
107
|
+
severity?: "critical" | "high" | "medium" | "low" | "info" | undefined;
|
|
108
|
+
enabled?: boolean | undefined;
|
|
109
|
+
}> | undefined;
|
|
110
|
+
version?: string | number | undefined;
|
|
111
|
+
profile?: "strict" | "balanced" | "startup" | "ecommerce" | "documentation" | "custom" | undefined;
|
|
112
|
+
regression?: {
|
|
113
|
+
allowExpectedChanges?: boolean | undefined;
|
|
114
|
+
failOnLevels?: ("REQUIRED" | "CONDITIONAL" | "RECOMMENDED" | "OPTIONAL")[] | undefined;
|
|
115
|
+
failOnSeverities?: ("critical" | "high" | "medium" | "low" | "info")[] | undefined;
|
|
116
|
+
maxAllowedRegressions?: number | undefined;
|
|
117
|
+
} | undefined;
|
|
118
|
+
policy?: Record<string, any> | undefined;
|
|
119
|
+
}>;
|
|
3
120
|
export declare class PolicyLoader {
|
|
4
121
|
static resolvePolicy(projectDir?: string, explicitPath?: string): PolicyConfig;
|
|
5
122
|
static parseConfigFile(filePath: string): any;
|
|
6
123
|
static parseYaml(content: string): Record<string, any>;
|
|
7
|
-
private static parseScalarValue;
|
|
8
124
|
static isRegressionBreachingPolicy(diff: InvariantDiffItem, policy: PolicyConfig): boolean;
|
|
125
|
+
static evaluatePolicyGate(diffs: InvariantDiffItem[], policy: PolicyConfig): {
|
|
126
|
+
pass: boolean;
|
|
127
|
+
breachingDiffs: InvariantDiffItem[];
|
|
128
|
+
totalBreaches: number;
|
|
129
|
+
maxAllowed: number;
|
|
130
|
+
verdict: string;
|
|
131
|
+
};
|
|
9
132
|
}
|
package/dist/policy/loader.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
+
import * as YAML from 'yaml';
|
|
4
|
+
import { z } from 'zod';
|
|
3
5
|
import { BUILTIN_PROFILES } from './profiles.js';
|
|
6
|
+
export const InvariantRuleSchema = z.object({
|
|
7
|
+
enabled: z.boolean().optional(),
|
|
8
|
+
requirementLevel: z.enum(['REQUIRED', 'CONDITIONAL', 'RECOMMENDED', 'OPTIONAL']).optional(),
|
|
9
|
+
severity: z.enum(['critical', 'high', 'medium', 'low', 'info']).optional()
|
|
10
|
+
});
|
|
11
|
+
export const RegressionPolicySchema = z.object({
|
|
12
|
+
failOnLevels: z.array(z.enum(['REQUIRED', 'CONDITIONAL', 'RECOMMENDED', 'OPTIONAL'])).optional(),
|
|
13
|
+
failOnSeverities: z.array(z.enum(['critical', 'high', 'medium', 'low', 'info'])).optional(),
|
|
14
|
+
allowExpectedChanges: z.boolean().optional(),
|
|
15
|
+
maxAllowedRegressions: z.number().int().min(0).optional()
|
|
16
|
+
});
|
|
17
|
+
export const PolicyConfigSchema = z.object({
|
|
18
|
+
version: z.union([z.number(), z.string()]).optional(),
|
|
19
|
+
profile: z.enum(['strict', 'balanced', 'startup', 'ecommerce', 'documentation', 'custom']).optional(),
|
|
20
|
+
regression: RegressionPolicySchema.optional(),
|
|
21
|
+
invariants: z.record(InvariantRuleSchema).optional(),
|
|
22
|
+
policy: z.record(z.any()).optional(),
|
|
23
|
+
framework: z.object({
|
|
24
|
+
adapter: z.string().optional(),
|
|
25
|
+
force: z.boolean().optional(),
|
|
26
|
+
entrypoint: z.string().optional()
|
|
27
|
+
}).optional()
|
|
28
|
+
});
|
|
4
29
|
export class PolicyLoader {
|
|
5
30
|
static resolvePolicy(projectDir = '.', explicitPath) {
|
|
6
31
|
let rawConfig = null;
|
|
@@ -27,7 +52,7 @@ export class PolicyLoader {
|
|
|
27
52
|
if (!rawConfig) {
|
|
28
53
|
return baseProfile;
|
|
29
54
|
}
|
|
30
|
-
// Normalize
|
|
55
|
+
// Normalize snake_case regression options to camelCase
|
|
31
56
|
const rawReg = rawConfig.regression || {};
|
|
32
57
|
const normalizedRegression = {
|
|
33
58
|
...baseProfile.regression,
|
|
@@ -36,12 +61,12 @@ export class PolicyLoader {
|
|
|
36
61
|
allowExpectedChanges: rawReg.allowExpectedChanges ?? rawReg.allow_expected_changes ?? baseProfile.regression?.allowExpectedChanges,
|
|
37
62
|
maxAllowedRegressions: rawReg.maxAllowedRegressions ?? rawReg.max_allowed_regressions ?? baseProfile.regression?.maxAllowedRegressions
|
|
38
63
|
};
|
|
39
|
-
// Normalize policy
|
|
64
|
+
// Normalize invariant policy overrides
|
|
40
65
|
const mergedInvariants = {
|
|
41
66
|
...baseProfile.invariants,
|
|
42
67
|
...(rawConfig.invariants || {})
|
|
43
68
|
};
|
|
44
|
-
//
|
|
69
|
+
// Support convenient high-level policy shortcuts (e.g. policy: { canonical: required, sitemap: recommended })
|
|
45
70
|
const rawPolicy = rawConfig.policy || {};
|
|
46
71
|
if (rawPolicy.canonical) {
|
|
47
72
|
const lvl = String(rawPolicy.canonical).toUpperCase();
|
|
@@ -77,86 +102,18 @@ export class PolicyLoader {
|
|
|
77
102
|
}
|
|
78
103
|
}
|
|
79
104
|
static parseYaml(content) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
let currentSubsection = null;
|
|
84
|
-
for (let rawLine of lines) {
|
|
85
|
-
// Strip comments
|
|
86
|
-
const commentIdx = rawLine.indexOf('#');
|
|
87
|
-
if (commentIdx !== -1) {
|
|
88
|
-
rawLine = rawLine.slice(0, commentIdx);
|
|
89
|
-
}
|
|
90
|
-
const line = rawLine.replace(/\r/g, '');
|
|
91
|
-
if (!line.trim())
|
|
92
|
-
continue;
|
|
93
|
-
const indent = line.search(/\S/);
|
|
94
|
-
const trimmed = line.trim();
|
|
95
|
-
if (indent === 0) {
|
|
96
|
-
// Top-level key
|
|
97
|
-
const colonIdx = trimmed.indexOf(':');
|
|
98
|
-
if (colonIdx === -1)
|
|
99
|
-
continue;
|
|
100
|
-
const key = trimmed.slice(0, colonIdx).trim();
|
|
101
|
-
const value = trimmed.slice(colonIdx + 1).trim();
|
|
102
|
-
if (value) {
|
|
103
|
-
root[key] = this.parseScalarValue(value);
|
|
104
|
-
currentSection = null;
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
root[key] = {};
|
|
108
|
-
currentSection = key;
|
|
109
|
-
}
|
|
110
|
-
currentSubsection = null;
|
|
111
|
-
}
|
|
112
|
-
else if (indent === 2 && currentSection) {
|
|
113
|
-
// First-level nested key
|
|
114
|
-
const colonIdx = trimmed.indexOf(':');
|
|
115
|
-
if (colonIdx === -1)
|
|
116
|
-
continue;
|
|
117
|
-
const key = trimmed.slice(0, colonIdx).trim();
|
|
118
|
-
const value = trimmed.slice(colonIdx + 1).trim();
|
|
119
|
-
if (value) {
|
|
120
|
-
root[currentSection][key] = this.parseScalarValue(value);
|
|
121
|
-
currentSubsection = null;
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
root[currentSection][key] = {};
|
|
125
|
-
currentSubsection = key;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
else if (indent >= 4 && currentSection && currentSubsection) {
|
|
129
|
-
// Second-level nested key
|
|
130
|
-
const colonIdx = trimmed.indexOf(':');
|
|
131
|
-
if (colonIdx === -1)
|
|
132
|
-
continue;
|
|
133
|
-
const key = trimmed.slice(0, colonIdx).trim();
|
|
134
|
-
const value = trimmed.slice(colonIdx + 1).trim();
|
|
135
|
-
root[currentSection][currentSubsection][key] = this.parseScalarValue(value);
|
|
136
|
-
}
|
|
105
|
+
try {
|
|
106
|
+
const parsed = YAML.parse(content);
|
|
107
|
+
return (typeof parsed === 'object' && parsed !== null) ? parsed : {};
|
|
137
108
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
static parseScalarValue(val) {
|
|
141
|
-
const clean = val.trim();
|
|
142
|
-
if (clean.startsWith('[') && clean.endsWith(']')) {
|
|
143
|
-
return clean
|
|
144
|
-
.slice(1, -1)
|
|
145
|
-
.split(',')
|
|
146
|
-
.map(s => s.trim().replace(/^['"]|['"]$/g, ''))
|
|
147
|
-
.filter(Boolean);
|
|
109
|
+
catch {
|
|
110
|
+
return {};
|
|
148
111
|
}
|
|
149
|
-
if (clean === 'true')
|
|
150
|
-
return true;
|
|
151
|
-
if (clean === 'false')
|
|
152
|
-
return false;
|
|
153
|
-
if (clean === 'null')
|
|
154
|
-
return null;
|
|
155
|
-
if (!isNaN(Number(clean)) && clean !== '')
|
|
156
|
-
return Number(clean);
|
|
157
|
-
return clean.replace(/^['"]|['"]$/g, '');
|
|
158
112
|
}
|
|
159
113
|
static isRegressionBreachingPolicy(diff, policy) {
|
|
114
|
+
if (diff.status === 'EXPECTED_CHANGE') {
|
|
115
|
+
return policy.regression?.allowExpectedChanges === false;
|
|
116
|
+
}
|
|
160
117
|
if (diff.status !== 'NEW_REGRESSION')
|
|
161
118
|
return false;
|
|
162
119
|
const invOverride = policy.invariants?.[diff.invariantId];
|
|
@@ -171,4 +128,20 @@ export class PolicyLoader {
|
|
|
171
128
|
const severityBreached = failSeverities.includes(severity);
|
|
172
129
|
return levelBreached && severityBreached;
|
|
173
130
|
}
|
|
131
|
+
static evaluatePolicyGate(diffs, policy) {
|
|
132
|
+
const breachingDiffs = diffs.filter(d => this.isRegressionBreachingPolicy(d, policy));
|
|
133
|
+
const totalBreaches = breachingDiffs.length;
|
|
134
|
+
const maxAllowed = policy.regression?.maxAllowedRegressions ?? 0;
|
|
135
|
+
const pass = totalBreaches <= maxAllowed;
|
|
136
|
+
const verdict = pass
|
|
137
|
+
? `✅ PASSED (Policy: ${policy.profile}): ${totalBreaches} breach(es) within allowed threshold (max: ${maxAllowed}).`
|
|
138
|
+
: `🚨 FAILED (Policy: ${policy.profile}): ${totalBreaches} invariant regression(s) breached policy (max allowed: ${maxAllowed}).`;
|
|
139
|
+
return {
|
|
140
|
+
pass,
|
|
141
|
+
breachingDiffs,
|
|
142
|
+
totalBreaches,
|
|
143
|
+
maxAllowed,
|
|
144
|
+
verdict
|
|
145
|
+
};
|
|
146
|
+
}
|
|
174
147
|
}
|
package/dist/test.js
CHANGED
|
@@ -10,6 +10,7 @@ import { defaultCacheManager } from './utils/cacheManager.js';
|
|
|
10
10
|
import { exportFindingsToSarif } from './utils/sarifExporter.js';
|
|
11
11
|
import { defaultInvariantRegistry } from './invariants/registry.js';
|
|
12
12
|
import { PolicyLoader } from './policy/loader.js';
|
|
13
|
+
import { BUILTIN_PROFILES } from './policy/profiles.js';
|
|
13
14
|
import { formatPrCommentMarkdown } from './utils/prCommentFormatter.js';
|
|
14
15
|
import { analyzeSemanticFileChange } from './utils/gitDiffEngine.js';
|
|
15
16
|
import { compareSnapshots } from './utils/snapshotEngine.js';
|
|
@@ -57,7 +58,7 @@ regression:
|
|
|
57
58
|
fail_on_levels: [REQUIRED, CONDITIONAL, RECOMMENDED]
|
|
58
59
|
fail_on_severities: [critical, high, medium]
|
|
59
60
|
allow_expected_changes: false
|
|
60
|
-
max_allowed_regressions:
|
|
61
|
+
max_allowed_regressions: 2
|
|
61
62
|
`;
|
|
62
63
|
const parsedYaml = PolicyLoader.parseYaml(mockYaml);
|
|
63
64
|
assert.equal(parsedYaml.version, 1);
|
|
@@ -77,7 +78,7 @@ regression:
|
|
|
77
78
|
createdAt: new Date().toISOString(),
|
|
78
79
|
projectPath: '.',
|
|
79
80
|
frameworkInfo: { framework: 'nextjs-app-router', name: 'Next.js', version: '14.0.0', confidence: 1.0 },
|
|
80
|
-
discoveredRoutes: [],
|
|
81
|
+
discoveredRoutes: [{ routePath: '/deleted-route', routeType: 'page', sourceFilePath: 'app/deleted/page.tsx', hasHeadComponent: false }],
|
|
81
82
|
scores: { overallHealth: 100, overallConfidence: 'High', totalEvidenceSignals: 10, technical: { score: 100, state: 'healthy' }, content: { score: 100, state: 'healthy' }, discoverability: { score: 100, state: 'healthy' }, authority: { score: 100, state: 'healthy' }, entity: { score: 100, state: 'healthy' }, performance: { score: 100, state: 'healthy' }, aiReadiness: { score: 100, state: 'healthy' } },
|
|
82
83
|
findings: [],
|
|
83
84
|
invariants: [
|
|
@@ -85,19 +86,86 @@ regression:
|
|
|
85
86
|
],
|
|
86
87
|
routeMappings: []
|
|
87
88
|
};
|
|
88
|
-
const
|
|
89
|
+
const fakeCurrentActiveRouteMissingEvidence = {
|
|
89
90
|
...fakeBaseline,
|
|
90
91
|
snapshotId: 'snap_curr',
|
|
91
|
-
|
|
92
|
+
discoveredRoutes: [{ routePath: '/deleted-route', routeType: 'page', sourceFilePath: 'app/deleted/page.tsx', hasHeadComponent: false }],
|
|
93
|
+
invariants: [] // Active route missing invariant evidence
|
|
92
94
|
};
|
|
93
|
-
const
|
|
94
|
-
assert.equal(
|
|
95
|
-
assert.equal(
|
|
96
|
-
|
|
95
|
+
const diffReport1 = compareSnapshots(fakeBaseline, fakeCurrentActiveRouteMissingEvidence);
|
|
96
|
+
assert.equal(diffReport1.invariantDiffs?.length, 1);
|
|
97
|
+
assert.equal(diffReport1.invariantDiffs[0].status, 'NEW_REGRESSION', 'Disappearing satisfied invariant on active route must trigger regression');
|
|
98
|
+
const fakeCurrentRouteDeleted = {
|
|
99
|
+
...fakeBaseline,
|
|
100
|
+
snapshotId: 'snap_curr',
|
|
101
|
+
discoveredRoutes: [], // Route intentionally removed from project
|
|
102
|
+
invariants: []
|
|
103
|
+
};
|
|
104
|
+
const diffReport2 = compareSnapshots(fakeBaseline, fakeCurrentRouteDeleted);
|
|
105
|
+
assert.equal(diffReport2.invariantDiffs?.length, 1);
|
|
106
|
+
assert.equal(diffReport2.invariantDiffs[0].status, 'EXPECTED_CHANGE', 'Disappearing invariant for removed route must be tagged EXPECTED_CHANGE');
|
|
107
|
+
console.log(' ✅ Invariant Union Diffing: Active route missing evidence -> NEW_REGRESSION; Deleted route -> EXPECTED_CHANGE.');
|
|
108
|
+
// -------------------------------------------------------------
|
|
109
|
+
// Test 5: Policy-Matrix Evaluation
|
|
110
|
+
// -------------------------------------------------------------
|
|
111
|
+
console.log('\n5️⃣ Testing Policy Matrix Evaluation Across Profiles & Thresholds...');
|
|
112
|
+
const lowRecDiff = {
|
|
113
|
+
invariantId: 'INV-LLMS-TXT',
|
|
114
|
+
logicalPageId: 'site_root',
|
|
115
|
+
url: '/llms.txt',
|
|
116
|
+
status: 'NEW_REGRESSION',
|
|
117
|
+
baselineSatisfied: true,
|
|
118
|
+
currentSatisfied: false,
|
|
119
|
+
requirementLevel: 'RECOMMENDED',
|
|
120
|
+
severity: 'low',
|
|
121
|
+
message: 'llms.txt missing'
|
|
122
|
+
};
|
|
123
|
+
const criticalReqDiff = {
|
|
124
|
+
invariantId: 'INV-HTTP-200',
|
|
125
|
+
logicalPageId: 'page_home',
|
|
126
|
+
url: '/',
|
|
127
|
+
status: 'NEW_REGRESSION',
|
|
128
|
+
baselineSatisfied: true,
|
|
129
|
+
currentSatisfied: false,
|
|
130
|
+
requirementLevel: 'REQUIRED',
|
|
131
|
+
severity: 'critical',
|
|
132
|
+
message: 'HTTP 500 error'
|
|
133
|
+
};
|
|
134
|
+
// Profile strict fails on RECOMMENDED + low
|
|
135
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(lowRecDiff, BUILTIN_PROFILES.strict), true);
|
|
136
|
+
// Profile balanced ignores RECOMMENDED + low
|
|
137
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(lowRecDiff, BUILTIN_PROFILES.balanced), false);
|
|
138
|
+
// Profile startup ignores RECOMMENDED + low
|
|
139
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(lowRecDiff, BUILTIN_PROFILES.startup), false);
|
|
140
|
+
// All profiles fail on REQUIRED + critical
|
|
141
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(criticalReqDiff, BUILTIN_PROFILES.strict), true);
|
|
142
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(criticalReqDiff, BUILTIN_PROFILES.balanced), true);
|
|
143
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(criticalReqDiff, BUILTIN_PROFILES.startup), true);
|
|
144
|
+
// Invariant override: disabled policy
|
|
145
|
+
const customPolicy = {
|
|
146
|
+
...BUILTIN_PROFILES.strict,
|
|
147
|
+
invariants: {
|
|
148
|
+
'INV-HTTP-200': { enabled: false }
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
assert.equal(PolicyLoader.isRegressionBreachingPolicy(criticalReqDiff, customPolicy), false);
|
|
152
|
+
// Gate evaluation with maxAllowedRegressions
|
|
153
|
+
const gatePolicy = {
|
|
154
|
+
...BUILTIN_PROFILES.strict,
|
|
155
|
+
regression: {
|
|
156
|
+
...BUILTIN_PROFILES.strict.regression,
|
|
157
|
+
maxAllowedRegressions: 1
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const gatePass = PolicyLoader.evaluatePolicyGate([criticalReqDiff], gatePolicy);
|
|
161
|
+
assert.equal(gatePass.pass, true, '1 breach with maxAllowedRegressions=1 must pass');
|
|
162
|
+
const gateFail = PolicyLoader.evaluatePolicyGate([criticalReqDiff, criticalReqDiff], gatePolicy);
|
|
163
|
+
assert.equal(gateFail.pass, false, '2 breaches with maxAllowedRegressions=1 must fail');
|
|
164
|
+
console.log(' ✅ Policy Matrix: Strict, Balanced, Startup, Invariant Overrides, and maxAllowedRegressions verified.');
|
|
97
165
|
// -------------------------------------------------------------
|
|
98
|
-
// Test
|
|
166
|
+
// Test 6: Semantic SEO Diff & Risk Assessment (P1)
|
|
99
167
|
// -------------------------------------------------------------
|
|
100
|
-
console.log('\
|
|
168
|
+
console.log('\n6️⃣ Testing Semantic SEO Git Diff Engine...');
|
|
101
169
|
const nextAppPath = path.join(FIXTURES_DIR, 'nextjs-app');
|
|
102
170
|
const semChange = analyzeSemanticFileChange('app/page.tsx', nextAppPath);
|
|
103
171
|
assert.equal(semChange.affectsMetadata, true);
|
|
@@ -106,9 +174,9 @@ regression:
|
|
|
106
174
|
assert.ok(semChange.likelyAffectedInvariants.includes('INV-TITLE-PRESENT'));
|
|
107
175
|
console.log(` ✅ Semantic Analysis for app/page.tsx: Risk=${semChange.riskLevel}`);
|
|
108
176
|
// -------------------------------------------------------------
|
|
109
|
-
// Test
|
|
177
|
+
// Test 7: Developer-Native PR Comment Formatter (P1)
|
|
110
178
|
// -------------------------------------------------------------
|
|
111
|
-
console.log('\
|
|
179
|
+
console.log('\n7️⃣ Testing Developer-Native GitHub PR Comment Formatter...');
|
|
112
180
|
const snapResult = await createSnapshotTool(nextAppPath);
|
|
113
181
|
const regCheck = await checkRegression(nextAppPath, snapResult.snapshot);
|
|
114
182
|
assert.equal(regCheck.pass, true);
|
|
@@ -117,24 +185,26 @@ regression:
|
|
|
117
185
|
assert.ok(prComment.includes('PASSED'));
|
|
118
186
|
console.log(` ✅ PR Markdown Generated (${prComment.length} characters)`);
|
|
119
187
|
// -------------------------------------------------------------
|
|
120
|
-
// Test
|
|
188
|
+
// Test 8: 17 Framework Adapters + Unknown Adapter Coverage
|
|
121
189
|
// -------------------------------------------------------------
|
|
122
|
-
console.log('\
|
|
190
|
+
console.log('\n8️⃣ Testing Framework Adapters & Unknown Fallback...');
|
|
123
191
|
const adapters = defaultAdapterRegistry.getAllAdapters();
|
|
124
|
-
assert.equal(adapters.length, 17, 'Must have exactly 17 adapters loaded');
|
|
125
|
-
|
|
192
|
+
assert.equal(adapters.length, 17, 'Must have exactly 17 standard adapters loaded');
|
|
193
|
+
const emptyFolderAdapter = defaultAdapterRegistry.getAdapterForProject('non_existent_folder_abc');
|
|
194
|
+
assert.equal(emptyFolderAdapter.id, 'unknown');
|
|
195
|
+
console.log(` ✅ 17 Adapters Loaded + UnknownAdapter fallback verified.`);
|
|
126
196
|
// -------------------------------------------------------------
|
|
127
|
-
// Test
|
|
197
|
+
// Test 9: SARIF v2.1.0 Exporter
|
|
128
198
|
// -------------------------------------------------------------
|
|
129
|
-
console.log('\
|
|
199
|
+
console.log('\n9️⃣ Testing SARIF v2.1.0 Exporter for GitHub Security Tab...');
|
|
130
200
|
const sarif = exportFindingsToSarif(snapResult.snapshot.findings, nextAppPath);
|
|
131
201
|
assert.equal(sarif.version, '2.1.0');
|
|
132
202
|
assert.equal(sarif.runs[0].tool.driver.name, 'SEO Gravity');
|
|
133
203
|
console.log(` ✅ SARIF Report Generated: Driver=${sarif.runs[0].tool.driver.name}, Version=${sarif.version}`);
|
|
134
204
|
// -------------------------------------------------------------
|
|
135
|
-
// Test
|
|
205
|
+
// Test 10: Content-Hash Cache Manager with Provenance & LRU
|
|
136
206
|
// -------------------------------------------------------------
|
|
137
|
-
console.log('\
|
|
207
|
+
console.log('\n🔟 Testing Cache Manager with Provenance & LRU...');
|
|
138
208
|
const cacheKey = defaultCacheManager.computeKey('test_ast', { file: 'page.tsx' });
|
|
139
209
|
defaultCacheManager.set(cacheKey, { parsed: true }, 10000, 'typescript_ast');
|
|
140
210
|
const cachedVal = defaultCacheManager.getWithMetadata(cacheKey);
|
|
@@ -295,15 +295,11 @@ export async function checkRegression(projectPath, baselineSnapshot, baseUrl, po
|
|
|
295
295
|
const currentSnapshot = await createProjectSnapshot(projectPath, { baseUrl });
|
|
296
296
|
const report = await compareSnapshotsTool(baselineSnapshot, currentSnapshot);
|
|
297
297
|
const invariantDiffs = report.invariantDiffs || [];
|
|
298
|
-
const
|
|
299
|
-
const pass = policyBreaches.length === 0;
|
|
300
|
-
const verdict = pass
|
|
301
|
-
? `✅ PASSED (Policy: ${policy.profile}): No policy-breaching SEO invariant regressions detected.`
|
|
302
|
-
: `🚨 FAILED (Policy: ${policy.profile}): ${policyBreaches.length} invariant regression(s) breached policy thresholds.`;
|
|
298
|
+
const gateResult = PolicyLoader.evaluatePolicyGate(invariantDiffs, policy);
|
|
303
299
|
return {
|
|
304
300
|
schemaVersion: 'seo.gravity/v1',
|
|
305
|
-
pass,
|
|
306
|
-
verdict,
|
|
301
|
+
pass: gateResult.pass,
|
|
302
|
+
verdict: gateResult.verdict,
|
|
307
303
|
policyProfile: policy.profile,
|
|
308
304
|
regressionReport: report
|
|
309
305
|
};
|
package/dist/types/evidence.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ export interface AstEvidence extends BaseEvidence {
|
|
|
10
10
|
sourceFile: string;
|
|
11
11
|
symbolName?: string;
|
|
12
12
|
nodeType?: string;
|
|
13
|
-
startLine
|
|
14
|
-
endLine
|
|
13
|
+
startLine?: number;
|
|
14
|
+
endLine?: number;
|
|
15
15
|
startColumn?: number;
|
|
16
16
|
endColumn?: number;
|
|
17
17
|
codeSnippet?: string;
|
package/dist/types/seo.d.ts
CHANGED
|
@@ -256,6 +256,8 @@ export interface ContentBrief {
|
|
|
256
256
|
}
|
|
257
257
|
export interface JsRenderingDiffReport {
|
|
258
258
|
url: string;
|
|
259
|
+
executionMode: 'static_dom_normalization';
|
|
260
|
+
domParser: string;
|
|
259
261
|
serverHtmlLength: number;
|
|
260
262
|
hydratedDomLength: number;
|
|
261
263
|
contentDifferencePercent: number;
|
|
@@ -4,6 +4,8 @@ export interface ASTMetadataInspection {
|
|
|
4
4
|
hasGenerateMetadata: boolean;
|
|
5
5
|
hasCanonicalDeclaration: boolean;
|
|
6
6
|
hasSchemaMarkup: boolean;
|
|
7
|
+
detectionMethod: 'ast_exact' | 'regex_heuristic';
|
|
8
|
+
confidence: number;
|
|
7
9
|
metadataRange?: ASTSourceRange;
|
|
8
10
|
canonicalRange?: ASTSourceRange;
|
|
9
11
|
schemaRange?: ASTSourceRange;
|
package/dist/utils/astLocator.js
CHANGED
|
@@ -7,7 +7,9 @@ export function inspectSourceFileAST(filePath) {
|
|
|
7
7
|
hasMetadataExport: false,
|
|
8
8
|
hasGenerateMetadata: false,
|
|
9
9
|
hasCanonicalDeclaration: false,
|
|
10
|
-
hasSchemaMarkup: false
|
|
10
|
+
hasSchemaMarkup: false,
|
|
11
|
+
detectionMethod: 'ast_exact',
|
|
12
|
+
confidence: 1.0
|
|
11
13
|
};
|
|
12
14
|
}
|
|
13
15
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
@@ -109,24 +111,31 @@ export function inspectSourceFileAST(filePath) {
|
|
|
109
111
|
ts.forEachChild(node, visit);
|
|
110
112
|
};
|
|
111
113
|
visit(sourceFile);
|
|
114
|
+
let regexFallbackUsed = false;
|
|
112
115
|
// Strict fallback regex detection if AST missed non-standard export structures
|
|
113
116
|
if (!hasMetadataExport && /export\s+const\s+metadata\b/i.test(content)) {
|
|
114
117
|
hasMetadataExport = true;
|
|
118
|
+
regexFallbackUsed = true;
|
|
115
119
|
}
|
|
116
120
|
if (!hasGenerateMetadata && /export\s+(async\s+)?function\s+generateMetadata\b/i.test(content)) {
|
|
117
121
|
hasGenerateMetadata = true;
|
|
122
|
+
regexFallbackUsed = true;
|
|
118
123
|
}
|
|
119
124
|
if (!hasCanonicalDeclaration && (/(rel=["']canonical["']|alternates:\s*\{[^}]*canonical:)/i.test(content))) {
|
|
120
125
|
hasCanonicalDeclaration = true;
|
|
126
|
+
regexFallbackUsed = true;
|
|
121
127
|
}
|
|
122
128
|
if (!hasSchemaMarkup && /application\/ld\+json/i.test(content)) {
|
|
123
129
|
hasSchemaMarkup = true;
|
|
130
|
+
regexFallbackUsed = true;
|
|
124
131
|
}
|
|
125
132
|
return {
|
|
126
133
|
hasMetadataExport,
|
|
127
134
|
hasGenerateMetadata,
|
|
128
135
|
hasCanonicalDeclaration,
|
|
129
136
|
hasSchemaMarkup,
|
|
137
|
+
detectionMethod: regexFallbackUsed ? 'regex_heuristic' : 'ast_exact',
|
|
138
|
+
confidence: regexFallbackUsed ? 0.85 : 1.0,
|
|
130
139
|
metadataRange,
|
|
131
140
|
canonicalRange,
|
|
132
141
|
schemaRange,
|
|
@@ -65,6 +65,8 @@ export async function compareServerVsClientDom(url) {
|
|
|
65
65
|
}
|
|
66
66
|
return {
|
|
67
67
|
url,
|
|
68
|
+
executionMode: 'static_dom_normalization',
|
|
69
|
+
domParser: 'jsdom_html_parser',
|
|
68
70
|
serverHtmlLength: serverLength,
|
|
69
71
|
hydratedDomLength: clientLength,
|
|
70
72
|
contentDifferencePercent: percentDiff,
|
|
@@ -2,5 +2,5 @@ import { ProjectFrameworkInfo, DiscoveredRoute, RouteSourceMapping } from '../ty
|
|
|
2
2
|
import { FrameworkAdapter } from '../adapters/types.js';
|
|
3
3
|
export declare function getProjectAdapter(projectDir: string): FrameworkAdapter;
|
|
4
4
|
export declare function detectFramework(projectDir: string): ProjectFrameworkInfo;
|
|
5
|
-
export declare function discoverRoutes(projectDir: string,
|
|
5
|
+
export declare function discoverRoutes(projectDir: string, frameworkInfo?: ProjectFrameworkInfo): DiscoveredRoute[];
|
|
6
6
|
export declare function mapUrlToRouteSource(targetUrl: string, discoveredRoutes: DiscoveredRoute[], projectDir?: string): RouteSourceMapping;
|
|
@@ -9,9 +9,11 @@ export function detectFramework(projectDir) {
|
|
|
9
9
|
const adapter = defaultAdapterRegistry.getAdapterForProject(resolvedDir);
|
|
10
10
|
return adapter.getProjectInfo(resolvedDir);
|
|
11
11
|
}
|
|
12
|
-
export function discoverRoutes(projectDir,
|
|
12
|
+
export function discoverRoutes(projectDir, frameworkInfo) {
|
|
13
13
|
const resolvedDir = path.resolve(projectDir);
|
|
14
|
-
const adapter =
|
|
14
|
+
const adapter = frameworkInfo?.framework
|
|
15
|
+
? (defaultAdapterRegistry.getAdapterById(frameworkInfo.framework) || defaultAdapterRegistry.getAdapterForProject(resolvedDir))
|
|
16
|
+
: defaultAdapterRegistry.getAdapterForProject(resolvedDir);
|
|
15
17
|
return adapter.discoverRoutes(resolvedDir);
|
|
16
18
|
}
|
|
17
19
|
export function mapUrlToRouteSource(targetUrl, discoveredRoutes, projectDir = '.') {
|
|
@@ -5,7 +5,7 @@ export interface CreateSnapshotOptions {
|
|
|
5
5
|
maxPagesToAudit?: number;
|
|
6
6
|
includeCrawlGraph?: boolean;
|
|
7
7
|
}
|
|
8
|
-
export declare function computeLogicalPageId(urlOrPath: string): string;
|
|
8
|
+
export declare function computeLogicalPageId(urlOrPath: string, baseUrl?: string): string;
|
|
9
9
|
export declare function extractGitMetadata(projectDir: string): GitMetadata;
|
|
10
10
|
export declare function createProjectSnapshot(projectPath: string, options?: CreateSnapshotOptions): Promise<ProjectSnapshot>;
|
|
11
11
|
export declare function compareSnapshots(baseline: ProjectSnapshot, current: ProjectSnapshot): RegressionReport;
|
|
@@ -5,20 +5,9 @@ import { inspectSourceFileAST } from './astLocator.js';
|
|
|
5
5
|
import { defaultInvariantRegistry } from '../invariants/registry.js';
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import { execSync } from 'child_process';
|
|
8
|
-
import
|
|
9
|
-
export function computeLogicalPageId(urlOrPath) {
|
|
10
|
-
|
|
11
|
-
let origin = 'default';
|
|
12
|
-
try {
|
|
13
|
-
if (clean.startsWith('http')) {
|
|
14
|
-
const u = new URL(clean);
|
|
15
|
-
origin = u.origin.toLowerCase();
|
|
16
|
-
clean = u.pathname;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
catch { }
|
|
20
|
-
clean = clean.replace(/\/$/, '') || '/';
|
|
21
|
-
return 'page_' + crypto.createHash('sha256').update(`${origin}:${clean}`).digest('hex').substring(0, 12);
|
|
8
|
+
import { computeNormalizedLogicalPageId } from './urlNormalizer.js';
|
|
9
|
+
export function computeLogicalPageId(urlOrPath, baseUrl) {
|
|
10
|
+
return computeNormalizedLogicalPageId(urlOrPath, baseUrl);
|
|
22
11
|
}
|
|
23
12
|
export function extractGitMetadata(projectDir) {
|
|
24
13
|
try {
|
|
@@ -170,13 +159,18 @@ export async function createProjectSnapshot(projectPath, options = {}) {
|
|
|
170
159
|
const pageId = computeLogicalPageId(route.routePath);
|
|
171
160
|
const fullSrcPath = path.join(resolvedPath, route.sourceFilePath);
|
|
172
161
|
const ast = inspectSourceFileAST(fullSrcPath);
|
|
173
|
-
// Metadata invariant
|
|
162
|
+
// Metadata & Title invariant (disaggregated)
|
|
174
163
|
const hasMeta = ast.hasMetadataExport || ast.hasGenerateMetadata || route.hasHeadComponent;
|
|
164
|
+
const hasTitle = Boolean(ast.extractedTitle || route.hasHeadComponent);
|
|
175
165
|
const titleInv = defaultInvariantRegistry.evaluateContext('INV-TITLE-PRESENT', {
|
|
176
166
|
url: route.routePath,
|
|
177
167
|
logicalPageId: pageId,
|
|
168
|
+
sourceFilePath: route.sourceFilePath,
|
|
169
|
+
sourceRange: ast.metadataRange,
|
|
170
|
+
hasTitle,
|
|
178
171
|
hasMetadata: hasMeta,
|
|
179
|
-
extractedTitle: ast.extractedTitle
|
|
172
|
+
extractedTitle: ast.extractedTitle,
|
|
173
|
+
hasDescription: Boolean(ast.extractedDescription)
|
|
180
174
|
}, {
|
|
181
175
|
analyzer: 'astLocator',
|
|
182
176
|
source: 'ast_inspection',
|
|
@@ -190,6 +184,8 @@ export async function createProjectSnapshot(projectPath, options = {}) {
|
|
|
190
184
|
const canonicalInv = defaultInvariantRegistry.evaluateContext('INV-CANONICAL-RESOLVES', {
|
|
191
185
|
url: route.routePath,
|
|
192
186
|
logicalPageId: pageId,
|
|
187
|
+
sourceFilePath: route.sourceFilePath,
|
|
188
|
+
sourceRange: ast.canonicalRange,
|
|
193
189
|
hasCanonical,
|
|
194
190
|
extractedCanonical: ast.extractedCanonical
|
|
195
191
|
}, {
|
|
@@ -211,11 +207,9 @@ export async function createProjectSnapshot(projectPath, options = {}) {
|
|
|
211
207
|
evidenceType: 'observed',
|
|
212
208
|
evidence: `File '${route.sourceFilePath}' has no metadata declaration.`,
|
|
213
209
|
affectedUrl: route.routePath,
|
|
214
|
-
sourceLocation:
|
|
215
|
-
filePath: route.sourceFilePath,
|
|
216
|
-
|
|
217
|
-
endLine: 1
|
|
218
|
-
},
|
|
210
|
+
sourceLocation: ast.metadataRange
|
|
211
|
+
? { filePath: route.sourceFilePath, startLine: ast.metadataRange.startLine, endLine: ast.metadataRange.endLine }
|
|
212
|
+
: { filePath: route.sourceFilePath },
|
|
219
213
|
sourceRange: ast.metadataRange,
|
|
220
214
|
likelyRootCause: 'Page component does not declare SEO metadata export.',
|
|
221
215
|
recommendation: `Add metadata export or title tag in ${route.sourceFilePath}.`,
|
|
@@ -238,7 +232,9 @@ export async function createProjectSnapshot(projectPath, options = {}) {
|
|
|
238
232
|
evidenceType: 'observed',
|
|
239
233
|
evidence: `Route '${route.routePath}' (${route.sourceFilePath}) does not declare a canonical URL.`,
|
|
240
234
|
affectedUrl: route.routePath,
|
|
241
|
-
sourceLocation:
|
|
235
|
+
sourceLocation: ast.canonicalRange
|
|
236
|
+
? { filePath: route.sourceFilePath, startLine: ast.canonicalRange.startLine, endLine: ast.canonicalRange.endLine }
|
|
237
|
+
: { filePath: route.sourceFilePath },
|
|
242
238
|
sourceRange: ast.canonicalRange,
|
|
243
239
|
likelyRootCause: 'Page does not declare its canonical master URL, risking duplicate indexing.',
|
|
244
240
|
recommendation: `Add canonical URL declaration to ${route.sourceFilePath}.`,
|
|
@@ -283,7 +279,15 @@ export async function createProjectSnapshot(projectPath, options = {}) {
|
|
|
283
279
|
}
|
|
284
280
|
}
|
|
285
281
|
}
|
|
286
|
-
catch {
|
|
282
|
+
catch (err) {
|
|
283
|
+
crawlGraphSummary = {
|
|
284
|
+
state: 'FAILED',
|
|
285
|
+
error: err.message || 'Crawl error',
|
|
286
|
+
baseUrl,
|
|
287
|
+
pagesCrawled: 0,
|
|
288
|
+
orphanPages: []
|
|
289
|
+
};
|
|
290
|
+
}
|
|
287
291
|
}
|
|
288
292
|
// 4. Compute Multidimensional Scores
|
|
289
293
|
const scores = calculateMultiDimensionalScores(findings, {
|
|
@@ -390,19 +394,39 @@ export function compareSnapshots(baseline, current) {
|
|
|
390
394
|
}
|
|
391
395
|
else if (bInv && !cInv) {
|
|
392
396
|
// Invariant disappeared in current snapshot!
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
397
|
+
const currentRouteStillExists = (current.discoveredRoutes || []).some(r => r.routePath === bInv.url);
|
|
398
|
+
if (currentRouteStillExists) {
|
|
399
|
+
// Active route lost its invariant evidence -> Genuine regression
|
|
400
|
+
status = bInv.satisfied ? 'NEW_REGRESSION' : 'RESOLVED';
|
|
401
|
+
invariantDiffs.push({
|
|
402
|
+
invariantId: bInv.id,
|
|
403
|
+
logicalPageId: bInv.logicalPageId,
|
|
404
|
+
url: bInv.url,
|
|
405
|
+
status,
|
|
406
|
+
baselineSatisfied: bInv.satisfied,
|
|
407
|
+
currentSatisfied: false,
|
|
408
|
+
requirementLevel: bInv.requirementLevel || 'REQUIRED',
|
|
409
|
+
severity: bInv.severity || 'high',
|
|
410
|
+
evidence: bInv.evidence,
|
|
411
|
+
message: `${bInv.description}: Missing invariant evidence for active route (${status})`
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// Route was intentionally deleted or removed -> Expected change
|
|
416
|
+
status = 'EXPECTED_CHANGE';
|
|
417
|
+
invariantDiffs.push({
|
|
418
|
+
invariantId: bInv.id,
|
|
419
|
+
logicalPageId: bInv.logicalPageId,
|
|
420
|
+
url: bInv.url,
|
|
421
|
+
status,
|
|
422
|
+
baselineSatisfied: bInv.satisfied,
|
|
423
|
+
currentSatisfied: false,
|
|
424
|
+
requirementLevel: bInv.requirementLevel || 'REQUIRED',
|
|
425
|
+
severity: 'low',
|
|
426
|
+
evidence: bInv.evidence,
|
|
427
|
+
message: `${bInv.description}: Route was removed (EXPECTED_CHANGE)`
|
|
428
|
+
});
|
|
429
|
+
}
|
|
406
430
|
}
|
|
407
431
|
}
|
|
408
432
|
// Score deltas
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
export function normalizeUrl(rawUrl, baseUrl) {
|
|
3
|
+
if (!rawUrl)
|
|
4
|
+
return '/';
|
|
5
|
+
try {
|
|
6
|
+
let resolved;
|
|
7
|
+
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
|
|
8
|
+
resolved = new URL(rawUrl);
|
|
9
|
+
}
|
|
10
|
+
else if (baseUrl) {
|
|
11
|
+
resolved = new URL(rawUrl, baseUrl);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
resolved = new URL(rawUrl, 'http://localhost');
|
|
15
|
+
}
|
|
16
|
+
// Lowercase hostname
|
|
17
|
+
resolved.hostname = resolved.hostname.toLowerCase();
|
|
18
|
+
// Strip default ports
|
|
19
|
+
if ((resolved.protocol === 'http:' && resolved.port === '80') ||
|
|
20
|
+
(resolved.protocol === 'https:' && resolved.port === '443')) {
|
|
21
|
+
resolved.port = '';
|
|
22
|
+
}
|
|
23
|
+
// Normalize path (remove duplicate slashes, keep root or trim trailing slash)
|
|
24
|
+
let pathname = resolved.pathname.replace(/\/+/g, '/');
|
|
25
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
26
|
+
pathname = pathname.slice(0, -1);
|
|
27
|
+
}
|
|
28
|
+
resolved.pathname = pathname;
|
|
29
|
+
// Sort query parameters deterministically
|
|
30
|
+
const params = Array.from(resolved.searchParams.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
31
|
+
resolved.search = '';
|
|
32
|
+
for (const [k, v] of params) {
|
|
33
|
+
resolved.searchParams.append(k, v);
|
|
34
|
+
}
|
|
35
|
+
// Remove hash/fragment for canonical logical page identity
|
|
36
|
+
resolved.hash = '';
|
|
37
|
+
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://') || baseUrl) {
|
|
38
|
+
return resolved.toString();
|
|
39
|
+
}
|
|
40
|
+
return resolved.pathname + resolved.search;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return rawUrl.trim().replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function computeNormalizedLogicalPageId(urlOrPath, baseUrl) {
|
|
47
|
+
const norm = normalizeUrl(urlOrPath, baseUrl);
|
|
48
|
+
let origin = 'default';
|
|
49
|
+
let pathOnly = norm;
|
|
50
|
+
try {
|
|
51
|
+
if (norm.startsWith('http')) {
|
|
52
|
+
const u = new URL(norm);
|
|
53
|
+
origin = u.origin;
|
|
54
|
+
pathOnly = u.pathname + u.search;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { }
|
|
58
|
+
const payload = `${origin}:${pathOnly}`;
|
|
59
|
+
return 'page_' + crypto.createHash('sha256').update(payload).digest('hex').substring(0, 12);
|
|
60
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "1.3.
|
|
1
|
+
export declare const VERSION = "1.3.3";
|
|
2
2
|
export declare const PACKAGE_NAME = "seo-gravity-mcp";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = '1.3.
|
|
1
|
+
export const VERSION = '1.3.3';
|
|
2
2
|
export const PACKAGE_NAME = 'seo-gravity-mcp';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "seo-gravity-mcp",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "SEO Engineering Infrastructure Layer for AI Coding Agents and CI/CD Pipelines",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"cheerio": "^1.0.0",
|
|
38
38
|
"fast-xml-parser": "^5.0.8",
|
|
39
39
|
"jsdom": "^26.0.0",
|
|
40
|
+
"yaml": "^2.9.0",
|
|
40
41
|
"zod": "^3.24.2"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|