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.
@@ -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;
@@ -22,153 +47,115 @@ export class PolicyLoader {
22
47
  }
23
48
  }
24
49
  }
25
- const baseProfileName = rawConfig?.profile || 'balanced';
26
- const baseProfile = BUILTIN_PROFILES[baseProfileName] || BUILTIN_PROFILES.balanced;
27
50
  if (!rawConfig) {
28
- return baseProfile;
51
+ return BUILTIN_PROFILES.balanced;
29
52
  }
30
- // Normalize regression config (support snake_case from README and camelCase)
31
- const rawReg = rawConfig.regression || {};
53
+ const parsed = PolicyConfigSchema.safeParse(rawConfig);
54
+ if (!parsed.success) {
55
+ const details = parsed.error.issues
56
+ .map(issue => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
57
+ .join('; ');
58
+ throw new Error(`Invalid SEO Gravity policy configuration: ${details}`);
59
+ }
60
+ const config = parsed.data;
61
+ const baseProfileName = config.profile || 'balanced';
62
+ const baseProfile = (baseProfileName !== 'custom' && BUILTIN_PROFILES[baseProfileName])
63
+ ? BUILTIN_PROFILES[baseProfileName]
64
+ : BUILTIN_PROFILES.balanced;
65
+ const rawReg = config.regression || {};
32
66
  const normalizedRegression = {
33
67
  ...baseProfile.regression,
34
- failOnLevels: rawReg.failOnLevels || rawReg.fail_on_levels || baseProfile.regression?.failOnLevels,
35
- failOnSeverities: rawReg.failOnSeverities || rawReg.fail_on_severities || baseProfile.regression?.failOnSeverities,
36
- allowExpectedChanges: rawReg.allowExpectedChanges ?? rawReg.allow_expected_changes ?? baseProfile.regression?.allowExpectedChanges,
37
- maxAllowedRegressions: rawReg.maxAllowedRegressions ?? rawReg.max_allowed_regressions ?? baseProfile.regression?.maxAllowedRegressions
68
+ failOnLevels: rawReg.failOnLevels || baseProfile.regression?.failOnLevels,
69
+ failOnSeverities: rawReg.failOnSeverities || baseProfile.regression?.failOnSeverities,
70
+ allowExpectedChanges: rawReg.allowExpectedChanges ?? baseProfile.regression?.allowExpectedChanges,
71
+ maxAllowedRegressions: rawReg.maxAllowedRegressions ?? baseProfile.regression?.maxAllowedRegressions
38
72
  };
39
- // Normalize policy / invariants shortcuts
40
73
  const mergedInvariants = {
41
74
  ...baseProfile.invariants,
42
- ...(rawConfig.invariants || {})
75
+ ...(config.invariants || {})
43
76
  };
44
- // Handle high-level policy shortcuts (e.g. policy: { canonical: required, sitemap: recommended })
45
- const rawPolicy = rawConfig.policy || {};
46
- if (rawPolicy.canonical) {
47
- const lvl = String(rawPolicy.canonical).toUpperCase();
77
+ const rawPolicy = config.policy || {};
78
+ if (rawPolicy.canonical !== undefined) {
79
+ const parsedLevel = String(rawPolicy.canonical).toUpperCase();
80
+ if (!['REQUIRED', 'CONDITIONAL', 'RECOMMENDED', 'OPTIONAL'].includes(parsedLevel)) {
81
+ throw new Error(`Invalid policy.canonical requirement level: '${rawPolicy.canonical}'.`);
82
+ }
83
+ const lvl = parsedLevel;
48
84
  mergedInvariants['INV-CANONICAL-RESOLVES'] = { requirementLevel: lvl, severity: 'high', enabled: true };
49
85
  }
50
- if (rawPolicy.sitemap) {
51
- const lvl = String(rawPolicy.sitemap).toUpperCase();
86
+ if (rawPolicy.sitemap !== undefined) {
87
+ const parsedLevel = String(rawPolicy.sitemap).toUpperCase();
88
+ if (!['REQUIRED', 'CONDITIONAL', 'RECOMMENDED', 'OPTIONAL'].includes(parsedLevel)) {
89
+ throw new Error(`Invalid policy.sitemap requirement level: '${rawPolicy.sitemap}'.`);
90
+ }
91
+ const lvl = parsedLevel;
52
92
  mergedInvariants['INV-SITEMAP-PRESENT'] = { requirementLevel: lvl, severity: 'medium', enabled: true };
53
93
  }
54
- if (rawPolicy.llms_txt || rawPolicy.llmsTxt) {
55
- const lvl = String(rawPolicy.llms_txt || rawPolicy.llmsTxt).toUpperCase();
94
+ if (rawPolicy.llms_txt !== undefined || rawPolicy.llmsTxt !== undefined) {
95
+ const rawLevel = rawPolicy.llms_txt ?? rawPolicy.llmsTxt;
96
+ const parsedLevel = String(rawLevel).toUpperCase();
97
+ if (!['REQUIRED', 'CONDITIONAL', 'RECOMMENDED', 'OPTIONAL'].includes(parsedLevel)) {
98
+ throw new Error(`Invalid policy.llms_txt requirement level: '${rawLevel}'.`);
99
+ }
100
+ const lvl = parsedLevel;
56
101
  mergedInvariants['INV-LLMS-TXT'] = { requirementLevel: lvl, severity: 'low', enabled: true };
57
102
  }
58
- const merged = {
59
- version: rawConfig.version || baseProfile.version,
103
+ return {
104
+ version: typeof config.version === 'number' ? config.version : (Number(config.version) || baseProfile.version),
60
105
  profile: baseProfileName,
61
106
  regression: normalizedRegression,
62
107
  invariants: mergedInvariants,
63
- framework: rawConfig.framework
108
+ framework: config.framework
64
109
  };
65
- return merged;
66
110
  }
67
111
  static parseConfigFile(filePath) {
68
- try {
69
- const content = fs.readFileSync(filePath, 'utf-8');
70
- if (filePath.endsWith('.json')) {
112
+ const content = fs.readFileSync(filePath, 'utf-8');
113
+ if (filePath.endsWith('.json')) {
114
+ try {
71
115
  return JSON.parse(content);
72
116
  }
117
+ catch (err) {
118
+ throw new Error(`Invalid JSON policy configuration '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
119
+ }
120
+ }
121
+ try {
73
122
  return this.parseYaml(content);
74
123
  }
75
- catch {
76
- return null;
124
+ catch (err) {
125
+ throw new Error(`Invalid YAML policy configuration '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
77
126
  }
78
127
  }
79
128
  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
- }
129
+ const parsed = YAML.parse(content);
130
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
131
+ throw new Error('Policy YAML root must be an object.');
137
132
  }
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);
148
- }
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, '');
133
+ return parsed;
158
134
  }
159
135
  static isRegressionBreachingPolicy(diff, policy) {
136
+ if (diff.status === 'EXPECTED_CHANGE') {
137
+ return policy.regression?.allowExpectedChanges === false;
138
+ }
160
139
  if (diff.status !== 'NEW_REGRESSION')
161
140
  return false;
162
141
  const invOverride = policy.invariants?.[diff.invariantId];
163
142
  if (invOverride && invOverride.enabled === false) {
164
- return false; // Explicitly disabled by project policy
143
+ return false;
165
144
  }
166
145
  const level = invOverride?.requirementLevel || diff.requirementLevel || 'REQUIRED';
167
146
  const severity = invOverride?.severity || diff.severity || 'high';
168
147
  const failLevels = policy.regression?.failOnLevels || ['REQUIRED', 'CONDITIONAL'];
169
148
  const failSeverities = policy.regression?.failOnSeverities || ['critical', 'high'];
170
- const levelBreached = failLevels.includes(level);
171
- const severityBreached = failSeverities.includes(severity);
172
- return levelBreached && severityBreached;
149
+ return failLevels.includes(level) && failSeverities.includes(severity);
150
+ }
151
+ static evaluatePolicyGate(diffs, policy) {
152
+ const breachingDiffs = diffs.filter(d => this.isRegressionBreachingPolicy(d, policy));
153
+ const totalBreaches = breachingDiffs.length;
154
+ const maxAllowed = policy.regression?.maxAllowedRegressions ?? 0;
155
+ const pass = totalBreaches <= maxAllowed;
156
+ const verdict = pass
157
+ ? `✅ PASSED (Policy: ${policy.profile}): ${totalBreaches} breach(es) within allowed threshold (max: ${maxAllowed}).`
158
+ : `🚨 FAILED (Policy: ${policy.profile}): ${totalBreaches} invariant regression(s) breached policy (max allowed: ${maxAllowed}).`;
159
+ return { pass, breachingDiffs, totalBreaches, maxAllowed, verdict };
173
160
  }
174
161
  }
@@ -1,2 +1,2 @@
1
- import { PolicyConfig, PolicyProfileName } from './types.js';
2
- export declare const BUILTIN_PROFILES: Record<PolicyProfileName, PolicyConfig>;
1
+ import { PolicyConfig, BuiltinProfileName } from './types.js';
2
+ export declare const BUILTIN_PROFILES: Record<BuiltinProfileName, PolicyConfig>;
@@ -1,5 +1,6 @@
1
1
  import { RequirementLevel, InvariantSeverity } from '../types/canonical.js';
2
- export type PolicyProfileName = 'strict' | 'balanced' | 'startup' | 'ecommerce' | 'documentation';
2
+ export type BuiltinProfileName = 'strict' | 'balanced' | 'startup' | 'ecommerce' | 'documentation';
3
+ export type PolicyProfileName = BuiltinProfileName | 'custom';
3
4
  export interface InvariantPolicyOverride {
4
5
  requirementLevel?: RequirementLevel;
5
6
  severity?: InvariantSeverity;
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;
@@ -27,8 +27,8 @@ export interface TemplateEvidence extends BaseEvidence {
27
27
  }
28
28
  export interface RouteConfigEvidence extends BaseEvidence {
29
29
  type: 'route_config';
30
- sourceFile: string;
31
- configFormat: 'laravel_routes' | 'symfony_yaml' | 'next_app_dir' | 'next_pages_dir' | 'react_router' | 'static_file';
30
+ sourceFile?: string;
31
+ configFormat: 'laravel_routes' | 'symfony_yaml' | 'next_app_dir' | 'next_pages_dir' | 'react_router' | 'static_file' | 'implicit_default';
32
32
  declaredPattern: string;
33
33
  }
34
34
  export interface DomEvidence extends BaseEvidence {
@@ -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,