seo-gravity-mcp 1.3.2 → 1.3.4

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.
@@ -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.4";
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.4';
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.4",
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": {