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.
- 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 +26 -17
- package/dist/invariants/types.d.ts +9 -1
- package/dist/policy/loader.d.ts +126 -3
- package/dist/policy/loader.js +96 -109
- package/dist/policy/profiles.d.ts +2 -2
- package/dist/policy/types.d.ts +2 -1
- package/dist/test.js +90 -20
- package/dist/tools/orchestration.js +3 -7
- package/dist/types/evidence.d.ts +4 -4
- 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/crawlGraph.js +41 -51
- package/dist/utils/gitDiffEngine.js +58 -24
- package/dist/utils/jsdomRenderer.d.ts +2 -1
- package/dist/utils/jsdomRenderer.js +15 -17
- package/dist/utils/projectScanner.d.ts +1 -1
- package/dist/utils/projectScanner.js +4 -2
- package/dist/utils/scraper.d.ts +6 -7
- package/dist/utils/scraper.js +13 -25
- 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/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;
|
|
@@ -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
|
|
51
|
+
return BUILTIN_PROFILES.balanced;
|
|
29
52
|
}
|
|
30
|
-
|
|
31
|
-
|
|
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 ||
|
|
35
|
-
failOnSeverities: rawReg.failOnSeverities ||
|
|
36
|
-
allowExpectedChanges: rawReg.allowExpectedChanges ??
|
|
37
|
-
maxAllowedRegressions: rawReg.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
|
-
...(
|
|
75
|
+
...(config.invariants || {})
|
|
43
76
|
};
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
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:
|
|
108
|
+
framework: config.framework
|
|
64
109
|
};
|
|
65
|
-
return merged;
|
|
66
110
|
}
|
|
67
111
|
static parseConfigFile(filePath) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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;
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
2
|
-
export declare const BUILTIN_PROFILES: Record<
|
|
1
|
+
import { PolicyConfig, BuiltinProfileName } from './types.js';
|
|
2
|
+
export declare const BUILTIN_PROFILES: Record<BuiltinProfileName, PolicyConfig>;
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { RequirementLevel, InvariantSeverity } from '../types/canonical.js';
|
|
2
|
-
export type
|
|
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:
|
|
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;
|
|
@@ -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
|
|
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 {
|
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,
|