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 CHANGED
@@ -1,4 +1,4 @@
1
- # 🚀 SEO Gravity (v1.3.2)
1
+ # 🚀 SEO Gravity (v1.3.3)
2
2
 
3
3
  <div align="center">
4
4
 
@@ -1,5 +1,6 @@
1
1
  import { FrameworkAdapter } from './types.js';
2
2
  export declare class AdapterRegistry {
3
+ private unknownAdapter;
3
4
  private adapters;
4
5
  getAdapterForProject(projectDir: string): FrameworkAdapter;
5
6
  getAdapterById(id: string): FrameworkAdapter | undefined;
@@ -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
- // Universal Fallback
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.adapters[this.adapters.length - 1]; // fallback static
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(_projectDir: string): boolean;
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(_projectDir) {
7
- return true; // Fallback
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.2',
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: 1,
53
- endLine: 1,
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.hasMetadata || ctx.extractedTitle);
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: 1,
82
- endLine: 1,
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;
@@ -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
  }
@@ -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 regression config (support snake_case from README and camelCase)
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 / invariants shortcuts
64
+ // Normalize invariant policy overrides
40
65
  const mergedInvariants = {
41
66
  ...baseProfile.invariants,
42
67
  ...(rawConfig.invariants || {})
43
68
  };
44
- // Handle high-level policy shortcuts (e.g. policy: { canonical: required, sitemap: recommended })
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
- const root = {};
81
- const lines = content.split('\n');
82
- let currentSection = null;
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
- return root;
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: 0
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 fakeCurrent = {
89
+ const fakeCurrentActiveRouteMissingEvidence = {
89
90
  ...fakeBaseline,
90
91
  snapshotId: 'snap_curr',
91
- invariants: [] // Invariant disappeared!
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 diffReport = compareSnapshots(fakeBaseline, fakeCurrent);
94
- assert.equal(diffReport.invariantDiffs?.length, 1);
95
- assert.equal(diffReport.invariantDiffs[0].status, 'NEW_REGRESSION', 'Disappearing satisfied invariant must trigger regression');
96
- console.log(' ✅ Invariant Union Diffing: Disappearing satisfied invariant correctly flagged as NEW_REGRESSION.');
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 5: Semantic SEO Diff & Risk Assessment (P1)
166
+ // Test 6: Semantic SEO Diff & Risk Assessment (P1)
99
167
  // -------------------------------------------------------------
100
- console.log('\n5️⃣ Testing Semantic SEO Git Diff Engine...');
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 6: Developer-Native PR Comment Formatter (P1)
177
+ // Test 7: Developer-Native PR Comment Formatter (P1)
110
178
  // -------------------------------------------------------------
111
- console.log('\n6️⃣ Testing Developer-Native GitHub PR Comment Formatter...');
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 7: 17 Framework Adapters Coverage
188
+ // Test 8: 17 Framework Adapters + Unknown Adapter Coverage
121
189
  // -------------------------------------------------------------
122
- console.log('\n7️⃣ Testing 17 Framework Adapters Coverage...');
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
- console.log(` ✅ ${adapters.length} Framework Adapters Loaded.`);
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 8: SARIF v2.1.0 Exporter
197
+ // Test 9: SARIF v2.1.0 Exporter
128
198
  // -------------------------------------------------------------
129
- console.log('\n8️⃣ Testing SARIF v2.1.0 Exporter for GitHub Security Tab...');
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 9: Content-Hash Cache Manager with Provenance & LRU
205
+ // Test 10: Content-Hash Cache Manager with Provenance & LRU
136
206
  // -------------------------------------------------------------
137
- console.log('\n9️⃣ Testing Cache Manager with Provenance & LRU...');
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 policyBreaches = invariantDiffs.filter(d => PolicyLoader.isRegressionBreachingPolicy(d, policy));
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
  };
@@ -10,8 +10,8 @@ export interface AstEvidence extends BaseEvidence {
10
10
  sourceFile: string;
11
11
  symbolName?: string;
12
12
  nodeType?: string;
13
- startLine: number;
14
- endLine: number;
13
+ startLine?: number;
14
+ endLine?: number;
15
15
  startColumn?: number;
16
16
  endColumn?: number;
17
17
  codeSnippet?: string;
@@ -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;
@@ -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, _frameworkInfo?: ProjectFrameworkInfo): DiscoveredRoute[];
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, _frameworkInfo) {
12
+ export function discoverRoutes(projectDir, frameworkInfo) {
13
13
  const resolvedDir = path.resolve(projectDir);
14
- const adapter = defaultAdapterRegistry.getAdapterForProject(resolvedDir);
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 * as crypto from 'crypto';
9
- export function computeLogicalPageId(urlOrPath) {
10
- let clean = urlOrPath;
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
- startLine: 1,
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: { filePath: route.sourceFilePath },
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
- status = bInv.satisfied ? 'NEW_REGRESSION' : 'RESOLVED';
394
- invariantDiffs.push({
395
- invariantId: bInv.id,
396
- logicalPageId: bInv.logicalPageId,
397
- url: bInv.url,
398
- status,
399
- baselineSatisfied: bInv.satisfied,
400
- currentSatisfied: false,
401
- requirementLevel: bInv.requirementLevel || 'REQUIRED',
402
- severity: bInv.severity || 'high',
403
- evidence: bInv.evidence,
404
- message: `${bInv.description}: Invariant disappeared (${status})`
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,2 @@
1
+ export declare function normalizeUrl(rawUrl: string, baseUrl?: string): string;
2
+ export declare function computeNormalizedLogicalPageId(urlOrPath: string, baseUrl?: string): string;
@@ -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.2";
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.2';
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.2",
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": {