@nebulacomponents/citable 1.16.0 → 1.18.0
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/CHANGELOG.md +47 -0
- package/README.md +16 -3
- package/dist/universal/.agents/skills/citable/SKILL.md +66 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/manifest.json +9 -6
- package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +66 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/manifest.json +9 -6
- package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +66 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/manifest.json +9 -6
- package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +66 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/manifest.json +9 -6
- package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.github/skills/citable/SKILL.md +66 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/manifest.json +9 -6
- package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.github/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +66 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/manifest.json +9 -6
- package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +66 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/manifest.json +9 -6
- package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +66 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/manifest.json +9 -6
- package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +66 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/manifest.json +9 -6
- package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +66 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/manifest.json +9 -6
- package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +66 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/manifest.json +9 -6
- package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +66 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/manifest.json +9 -6
- package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +92 -0
- package/dist/universal/manifest.json +112 -76
- package/package.json +1 -1
- package/schemas/cli-output-envelope.schema.json +30 -0
- package/schemas/finding.schema.json +69 -0
- package/schemas/remediation-verification.schema.json +98 -0
- package/schemas/sow.schema.json +92 -0
- package/skill/SKILL.md +66 -1
- package/src/analysis/behavioral.js +126 -0
- package/src/analysis/croAudit.js +302 -0
- package/src/analysis/croRoadmap.js +121 -0
- package/src/analysis/eeat.js +300 -0
- package/src/analysis/funnelAnalysis.js +171 -0
- package/src/analysis/iceMatrix.js +205 -0
- package/src/analysis/offpage.js +228 -0
- package/src/analysis/readiness.js +253 -0
- package/src/analysis/saliency.js +119 -0
- package/src/analysis/schemaRecommendations.js +173 -0
- package/src/analysis/strategicRoadmap.js +148 -0
- package/src/cli/index.js +418 -9
- package/src/commands/answerEngineReadiness.js +48 -0
- package/src/commands/audit.js +2 -2
- package/src/commands/auditBacklinks.js +79 -0
- package/src/commands/ciWorkflow.js +63 -0
- package/src/commands/compatibility.js +205 -0
- package/src/commands/context.js +2 -1
- package/src/commands/croBacklog.js +182 -0
- package/src/commands/croSuite.js +156 -0
- package/src/commands/edgeRules.js +80 -4
- package/src/commands/edgeSecurity.js +192 -0
- package/src/commands/executive.js +6 -0
- package/src/commands/executiveCro.js +30 -0
- package/src/commands/executiveSearch.js +28 -0
- package/src/commands/experimentGuardrails.js +213 -0
- package/src/commands/goldenCorpus.js +155 -0
- package/src/commands/implementationKit.js +202 -0
- package/src/commands/inspectCro.js +67 -0
- package/src/commands/inspectEeat.js +53 -0
- package/src/commands/planExperiment.js +52 -0
- package/src/commands/previewCro.js +241 -0
- package/src/commands/prioritize.js +42 -6
- package/src/commands/remediate.js +461 -0
- package/src/commands/roadmapCmd.js +69 -0
- package/src/commands/schemaCmd.js +10 -2
- package/src/commands/sowCmd.js +67 -0
- package/src/commands/sweep.js +357 -0
- package/src/commands/verifyRemediation.js +204 -0
- package/src/commands/visualRegression.js +165 -0
- package/src/components/index.js +556 -0
- package/src/detectors/cro.js +48 -0
- package/src/detectors/framework.js +31 -1
- package/src/reporting/executiveCroReport.js +731 -0
- package/src/reporting/executiveExport.js +175 -1
- package/src/reporting/executiveSearchReport.js +704 -0
- package/src/sow/admissibilityGate.js +194 -0
- package/src/sow/generateSow.js +762 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { parse as parseHtml } from 'node-html-parser';
|
|
4
|
+
import { buildContext } from './context.js';
|
|
5
|
+
import { selectDetectors } from '../detectors/index.js';
|
|
6
|
+
import { runDetectors, indexTargets, pageSubject, safePath } from '../detectors/framework.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* CWV standard thresholds per Google / web.dev guidance
|
|
10
|
+
*/
|
|
11
|
+
export const CWV_THRESHOLDS = {
|
|
12
|
+
lcp: { unit: 's', good: 2.5, needs_improvement: 4.0, description: 'Largest Contentful Paint' },
|
|
13
|
+
inp: { unit: 'ms', good: 200, needs_improvement: 500, description: 'Interaction to Next Paint' },
|
|
14
|
+
cls: { unit: 'score', good: 0.1, needs_improvement: 0.25, description: 'Cumulative Layout Shift' },
|
|
15
|
+
fcp: { unit: 's', good: 1.8, needs_improvement: 3.0, description: 'First Contentful Paint' },
|
|
16
|
+
ttfb: { unit: 'ms', good: 800, needs_improvement: 1800, description: 'Time to First Byte' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Evaluate static DOM heuristics for Core Web Vitals readiness
|
|
21
|
+
*/
|
|
22
|
+
export function evaluateStaticCwv(page) {
|
|
23
|
+
const issues = [];
|
|
24
|
+
const heuristics = {
|
|
25
|
+
lcp_blockers: 0,
|
|
26
|
+
inp_risks: 0,
|
|
27
|
+
cls_risks: 0,
|
|
28
|
+
unsized_images: 0,
|
|
29
|
+
total_images: 0,
|
|
30
|
+
render_blocking_scripts: 0,
|
|
31
|
+
head_stylesheets: 0,
|
|
32
|
+
lazy_above_fold: 0,
|
|
33
|
+
missing_preconnect: false,
|
|
34
|
+
dom_nodes: page.domNodeCount || 0,
|
|
35
|
+
max_dom_depth: page.maxDomDepth || 0,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const html = page.html || '';
|
|
39
|
+
const root = parseHtml(html);
|
|
40
|
+
const head = root.querySelector('head');
|
|
41
|
+
|
|
42
|
+
// 1. LCP checks
|
|
43
|
+
if (head) {
|
|
44
|
+
const scripts = head.querySelectorAll('script:not([async]):not([defer])');
|
|
45
|
+
for (const s of scripts) {
|
|
46
|
+
const src = s.getAttribute('src');
|
|
47
|
+
if (src && !src.includes('analytics') && !src.includes('tracking')) {
|
|
48
|
+
heuristics.render_blocking_scripts++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const stylesheets = head.querySelectorAll('link[rel="stylesheet"]');
|
|
52
|
+
heuristics.head_stylesheets = stylesheets.length;
|
|
53
|
+
|
|
54
|
+
const preconnects = head.querySelectorAll('link[rel="preconnect"]');
|
|
55
|
+
const fontLinks = head.querySelectorAll('link[href*="fonts.googleapis.com"], link[href*="fonts.gstatic.com"], link[href*="typekit.net"]');
|
|
56
|
+
if (fontLinks.length > 0 && preconnects.length === 0) {
|
|
57
|
+
heuristics.missing_preconnect = true;
|
|
58
|
+
issues.push({
|
|
59
|
+
vitals: 'FCP/LCP',
|
|
60
|
+
severity: 'low',
|
|
61
|
+
issue: 'External font origin without preconnect link hint',
|
|
62
|
+
remediation: 'Add <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (heuristics.render_blocking_scripts > 0) {
|
|
68
|
+
heuristics.lcp_blockers += heuristics.render_blocking_scripts;
|
|
69
|
+
issues.push({
|
|
70
|
+
vitals: 'LCP',
|
|
71
|
+
severity: 'high',
|
|
72
|
+
issue: `${heuristics.render_blocking_scripts} render-blocking script(s) in <head>`,
|
|
73
|
+
remediation: 'Add async or defer attributes, or move scripts to end of <body>',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (heuristics.head_stylesheets > 5) {
|
|
78
|
+
heuristics.lcp_blockers++;
|
|
79
|
+
issues.push({
|
|
80
|
+
vitals: 'LCP',
|
|
81
|
+
severity: 'medium',
|
|
82
|
+
issue: `${heuristics.head_stylesheets} stylesheets in <head> may delay first paint`,
|
|
83
|
+
remediation: 'Inline critical CSS and defer non-critical stylesheets',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Hero image / lazy loading
|
|
88
|
+
const imgs = root.querySelectorAll('img');
|
|
89
|
+
heuristics.total_images = imgs.length;
|
|
90
|
+
|
|
91
|
+
for (const img of imgs) {
|
|
92
|
+
const src = img.getAttribute('src') || '';
|
|
93
|
+
const width = img.getAttribute('width');
|
|
94
|
+
const height = img.getAttribute('height');
|
|
95
|
+
const loading = (img.getAttribute('loading') || '').toLowerCase();
|
|
96
|
+
|
|
97
|
+
if (!width || !height) {
|
|
98
|
+
heuristics.unsized_images++;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const numWidth = parseInt(width || '0', 10);
|
|
102
|
+
if (numWidth > 400 && loading === 'lazy') {
|
|
103
|
+
heuristics.lazy_above_fold++;
|
|
104
|
+
heuristics.lcp_blockers++;
|
|
105
|
+
issues.push({
|
|
106
|
+
vitals: 'LCP',
|
|
107
|
+
severity: 'high',
|
|
108
|
+
issue: `Large hero image (${src || '<img>'}) uses loading="lazy", delaying LCP`,
|
|
109
|
+
remediation: 'Remove loading="lazy" and add fetchpriority="high" to above-fold hero images',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. CLS checks
|
|
115
|
+
if (heuristics.unsized_images > 0) {
|
|
116
|
+
heuristics.cls_risks += heuristics.unsized_images;
|
|
117
|
+
issues.push({
|
|
118
|
+
vitals: 'CLS',
|
|
119
|
+
severity: 'medium',
|
|
120
|
+
issue: `${heuristics.unsized_images} of ${heuristics.total_images} image(s) lack explicit width/height dimensions`,
|
|
121
|
+
remediation: 'Set explicit width and height attributes or CSS aspect-ratio on all image elements',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 3. INP checks
|
|
126
|
+
if (heuristics.dom_nodes > 1500) {
|
|
127
|
+
heuristics.inp_risks++;
|
|
128
|
+
issues.push({
|
|
129
|
+
vitals: 'INP',
|
|
130
|
+
severity: heuristics.dom_nodes > 3000 ? 'high' : 'medium',
|
|
131
|
+
issue: `Excessive DOM size: ${heuristics.dom_nodes} nodes (Google recommends <= 1,500)`,
|
|
132
|
+
remediation: 'Simplify DOM hierarchy, virtualize large lists, and defer non-critical components',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (heuristics.max_dom_depth > 32) {
|
|
137
|
+
heuristics.inp_risks++;
|
|
138
|
+
issues.push({
|
|
139
|
+
vitals: 'INP',
|
|
140
|
+
severity: 'medium',
|
|
141
|
+
issue: `Deep DOM nesting: maximum depth ${heuristics.max_dom_depth} (Google recommends <= 32)`,
|
|
142
|
+
remediation: 'Flatten nested layout wrapper containers',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Determine readiness status per vital
|
|
147
|
+
const lcpStatus = heuristics.lcp_blockers === 0 ? 'good' : heuristics.lcp_blockers <= 2 ? 'needs_improvement' : 'poor';
|
|
148
|
+
const inpStatus = heuristics.inp_risks === 0 ? 'good' : heuristics.inp_risks <= 1 ? 'needs_improvement' : 'poor';
|
|
149
|
+
const clsStatus = heuristics.cls_risks === 0 ? 'good' : heuristics.cls_risks <= 2 ? 'needs_improvement' : 'poor';
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
fact_status: 'deterministic_readiness_audit',
|
|
153
|
+
status: {
|
|
154
|
+
lcp: lcpStatus,
|
|
155
|
+
inp: inpStatus,
|
|
156
|
+
cls: clsStatus,
|
|
157
|
+
fcp: heuristics.missing_preconnect || heuristics.render_blocking_scripts > 0 ? 'needs_improvement' : 'good',
|
|
158
|
+
ttfb: 'unassessed_without_server_telemetry',
|
|
159
|
+
},
|
|
160
|
+
heuristics,
|
|
161
|
+
issues,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Search recent audit runs for observed performance telemetry
|
|
167
|
+
*/
|
|
168
|
+
function findObservedPerformance(root, targetUrl) {
|
|
169
|
+
const runsDir = path.join(root, '.citable', 'runs');
|
|
170
|
+
if (!fs.existsSync(runsDir)) return null;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const runs = fs.readdirSync(runsDir).sort().reverse();
|
|
174
|
+
for (const run of runs) {
|
|
175
|
+
const obsDir = path.join(runsDir, run, 'observations');
|
|
176
|
+
if (!fs.existsSync(obsDir)) continue;
|
|
177
|
+
const obsFiles = fs.readdirSync(obsDir).filter((f) => f.includes('performance') && f.endsWith('.json'));
|
|
178
|
+
for (const file of obsFiles) {
|
|
179
|
+
const obs = JSON.parse(fs.readFileSync(path.join(obsDir, file), 'utf8'));
|
|
180
|
+
if (obs.kind === 'performance' && obs.state === 'observed') {
|
|
181
|
+
return {
|
|
182
|
+
source_run: run,
|
|
183
|
+
provider: obs.data?.provider || 'Lighthouse',
|
|
184
|
+
evidence_type: obs.data?.evidence_type || 'lab',
|
|
185
|
+
metrics: {
|
|
186
|
+
lcp_ms: obs.data?.largest_contentful_paint_ms ?? null,
|
|
187
|
+
fcp_ms: obs.data?.first_contentful_paint_ms ?? null,
|
|
188
|
+
cls: obs.data?.cumulative_layout_shift ?? null,
|
|
189
|
+
tbt_ms: obs.data?.total_blocking_time_ms ?? null,
|
|
190
|
+
score: obs.data?.performance_score ?? null,
|
|
191
|
+
},
|
|
192
|
+
collected_at: obs.collected_at,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Ignore non-blocking read errors
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `citable sweep technical` — execute a technical SEO sweep with Core Web Vitals metrics.
|
|
205
|
+
*/
|
|
206
|
+
export async function sweepTechnical(root, { target, baseUrl, refDate, page: pageFilter } = {}) {
|
|
207
|
+
const ctx = await buildContext(root, { target, baseUrl, refDate });
|
|
208
|
+
if (!ctx.site) {
|
|
209
|
+
throw new Error('technical sweep requires a site target (built output directory or URL)');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 1. Run technical detectors: TECH, CRAWL, CWV, ARCH, STATUS, LINK
|
|
213
|
+
const technicalDetectors = selectDetectors({
|
|
214
|
+
namespaces: ['TECH', 'CRAWL', 'CWV', 'ARCH', 'STATUS', 'LINK'],
|
|
215
|
+
});
|
|
216
|
+
const { findings } = runDetectors(technicalDetectors, ctx);
|
|
217
|
+
|
|
218
|
+
// 2. Select target pages
|
|
219
|
+
let pages = indexTargets(ctx);
|
|
220
|
+
if (pageFilter) {
|
|
221
|
+
const wanted = safePath(pageFilter, ctx.site.baseUrl);
|
|
222
|
+
pages = pages.filter((p) => safePath(p.url) === wanted || p.sourceFile === pageFilter);
|
|
223
|
+
if (!pages.length) throw new Error(`page not found in audited output: ${pageFilter}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 3. Aggregate Core Web Vitals across audited pages
|
|
227
|
+
const pageVitals = [];
|
|
228
|
+
let totalLcpBlockers = 0;
|
|
229
|
+
let totalInpRisks = 0;
|
|
230
|
+
let totalClsRisks = 0;
|
|
231
|
+
let totalUnsizedImages = 0;
|
|
232
|
+
let totalImagesCount = 0;
|
|
233
|
+
|
|
234
|
+
for (const page of pages) {
|
|
235
|
+
const staticCwv = evaluateStaticCwv(page);
|
|
236
|
+
totalLcpBlockers += staticCwv.heuristics.lcp_blockers;
|
|
237
|
+
totalInpRisks += staticCwv.heuristics.inp_risks;
|
|
238
|
+
totalClsRisks += staticCwv.heuristics.cls_risks;
|
|
239
|
+
totalUnsizedImages += staticCwv.heuristics.unsized_images;
|
|
240
|
+
totalImagesCount += staticCwv.heuristics.total_images;
|
|
241
|
+
|
|
242
|
+
const observed = findObservedPerformance(root, page.url);
|
|
243
|
+
|
|
244
|
+
pageVitals.push({
|
|
245
|
+
url: page.url,
|
|
246
|
+
status: page.status,
|
|
247
|
+
cwv_readiness: staticCwv.status,
|
|
248
|
+
heuristics: staticCwv.heuristics,
|
|
249
|
+
issues: staticCwv.issues,
|
|
250
|
+
observed_telemetry: observed,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 4. Crawl and indexability health summary
|
|
255
|
+
const non200Pages = pages.filter((p) => p.status !== 200);
|
|
256
|
+
const noindexPages = pages.filter((p) => p.noindex);
|
|
257
|
+
const canonicalMismatches = pages.filter((p) => p.canonicals.length === 0 || p.canonicals.length > 1);
|
|
258
|
+
|
|
259
|
+
// 5. Findings categorization
|
|
260
|
+
const criticalFindings = findings.filter((f) => f.classification.severity === 'critical');
|
|
261
|
+
const highFindings = findings.filter((f) => f.classification.severity === 'high');
|
|
262
|
+
const mediumFindings = findings.filter((f) => f.classification.severity === 'medium');
|
|
263
|
+
const lowFindings = findings.filter((f) => f.classification.severity === 'low' || f.classification.severity === 'informational');
|
|
264
|
+
|
|
265
|
+
// Overall posture
|
|
266
|
+
let overallHealth = 'optimal';
|
|
267
|
+
if (criticalFindings.length > 0 || non200Pages.length > 0) {
|
|
268
|
+
overallHealth = 'critical_blockers';
|
|
269
|
+
} else if (highFindings.length > 0 || totalLcpBlockers > 3 || totalClsRisks > 5) {
|
|
270
|
+
overallHealth = 'needs_attention';
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const result = {
|
|
274
|
+
target: ctx.site.baseUrl || target,
|
|
275
|
+
total_pages_audited: pages.length,
|
|
276
|
+
overall_health: overallHealth,
|
|
277
|
+
crawl_and_indexability: {
|
|
278
|
+
status_200_count: pages.length - non200Pages.length,
|
|
279
|
+
non_200_count: non200Pages.length,
|
|
280
|
+
noindex_count: noindexPages.length,
|
|
281
|
+
canonical_issues_count: canonicalMismatches.length,
|
|
282
|
+
details: non200Pages.map((p) => ({ url: p.url, status: p.status })),
|
|
283
|
+
},
|
|
284
|
+
core_web_vitals: {
|
|
285
|
+
thresholds: CWV_THRESHOLDS,
|
|
286
|
+
summary: {
|
|
287
|
+
lcp_readiness: totalLcpBlockers === 0 ? 'good' : totalLcpBlockers <= 2 ? 'needs_improvement' : 'poor',
|
|
288
|
+
inp_readiness: totalInpRisks === 0 ? 'good' : 'needs_improvement',
|
|
289
|
+
cls_readiness: totalClsRisks === 0 ? 'good' : 'needs_improvement',
|
|
290
|
+
total_lcp_blockers: totalLcpBlockers,
|
|
291
|
+
total_inp_risks: totalInpRisks,
|
|
292
|
+
unsized_images: totalUnsizedImages,
|
|
293
|
+
total_images: totalImagesCount,
|
|
294
|
+
image_dimension_coverage_pct: totalImagesCount > 0 ? Math.round(((totalImagesCount - totalUnsizedImages) / totalImagesCount) * 100) : 100,
|
|
295
|
+
},
|
|
296
|
+
pages: pageVitals,
|
|
297
|
+
},
|
|
298
|
+
findings_summary: {
|
|
299
|
+
total: findings.length,
|
|
300
|
+
critical: criticalFindings.length,
|
|
301
|
+
high: highFindings.length,
|
|
302
|
+
medium: mediumFindings.length,
|
|
303
|
+
low: lowFindings.length,
|
|
304
|
+
},
|
|
305
|
+
findings: findings.map((f) => ({
|
|
306
|
+
detector_id: f.detector_id,
|
|
307
|
+
name: f.name,
|
|
308
|
+
severity: f.classification.severity,
|
|
309
|
+
subject: f.subject.identifier || f.subject.url,
|
|
310
|
+
summary: f.observation.summary,
|
|
311
|
+
remediation: f.remediation?.preferred,
|
|
312
|
+
})),
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Format terminal output for `citable sweep technical`
|
|
320
|
+
*/
|
|
321
|
+
export function formatSweepOutput(r) {
|
|
322
|
+
const lines = [
|
|
323
|
+
`Technical SEO Sweep & Core Web Vitals Report`,
|
|
324
|
+
`===========================================`,
|
|
325
|
+
`Target: ${r.target}`,
|
|
326
|
+
`Pages Audited: ${r.total_pages_audited}`,
|
|
327
|
+
`Overall Health: ${r.overall_health.toUpperCase()}`,
|
|
328
|
+
``,
|
|
329
|
+
`CRAWL & INDEXABILITY:`,
|
|
330
|
+
` HTTP 200 OK: ${r.crawl_and_indexability.status_200_count}/${r.total_pages_audited}`,
|
|
331
|
+
` Non-200 Statuses: ${r.crawl_and_indexability.non_200_count}`,
|
|
332
|
+
` Noindex Pages: ${r.crawl_and_indexability.noindex_count}`,
|
|
333
|
+
` Canonical Issues: ${r.crawl_and_indexability.canonical_issues_count}`,
|
|
334
|
+
``,
|
|
335
|
+
`CORE WEB VITALS READINESS (Deterministic Static Audit):`,
|
|
336
|
+
` LCP Readiness: ${r.core_web_vitals.summary.lcp_readiness.toUpperCase()} (${r.core_web_vitals.summary.total_lcp_blockers} potential render blocker(s))`,
|
|
337
|
+
` INP Readiness: ${r.core_web_vitals.summary.inp_readiness.toUpperCase()} (${r.core_web_vitals.summary.total_inp_risks} DOM complexity risk(s))`,
|
|
338
|
+
` CLS Readiness: ${r.core_web_vitals.summary.cls_readiness.toUpperCase()} (${r.core_web_vitals.summary.unsized_images}/${r.core_web_vitals.summary.total_images} images lack dimensions; ${r.core_web_vitals.summary.image_dimension_coverage_pct}% coverage)`,
|
|
339
|
+
``,
|
|
340
|
+
`CWV Threshold Standards:`,
|
|
341
|
+
` - LCP: <= ${CWV_THRESHOLDS.lcp.good}s (Good), <= ${CWV_THRESHOLDS.lcp.needs_improvement}s (Needs Improvement)`,
|
|
342
|
+
` - INP: <= ${CWV_THRESHOLDS.inp.good}ms (Good), <= ${CWV_THRESHOLDS.inp.needs_improvement}ms (Needs Improvement)`,
|
|
343
|
+
` - CLS: <= ${CWV_THRESHOLDS.cls.good} (Good), <= ${CWV_THRESHOLDS.cls.needs_improvement} (Needs Improvement)`,
|
|
344
|
+
``,
|
|
345
|
+
`FINDINGS: ${r.findings_summary.total} total (critical:${r.findings_summary.critical} high:${r.findings_summary.high} medium:${r.findings_summary.medium} low:${r.findings_summary.low})`,
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
if (r.findings.length) {
|
|
349
|
+
lines.push(``, `Key Remediation Priorities:`);
|
|
350
|
+
for (const f of r.findings.slice(0, 10)) {
|
|
351
|
+
lines.push(` [${f.detector_id}] (${f.severity}) ${f.summary}`);
|
|
352
|
+
if (f.remediation) lines.push(` Fix: ${f.remediation}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return lines.join('\n');
|
|
357
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { readJson, writeJson, nowIso } from '../shared/io.js';
|
|
5
|
+
import { validateAgainst } from '../shared/schemaValidator.js';
|
|
6
|
+
import { buildContext } from './context.js';
|
|
7
|
+
import { runDetectors } from '../detectors/framework.js';
|
|
8
|
+
import { selectDetectors } from '../detectors/index.js';
|
|
9
|
+
import { remediateCommand } from './remediate.js';
|
|
10
|
+
|
|
11
|
+
const PKG_VERSION = JSON.parse(
|
|
12
|
+
fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
|
13
|
+
).version;
|
|
14
|
+
|
|
15
|
+
const RESOLUTION_DEFINITION =
|
|
16
|
+
'Resolved means the named detector no longer reports a finding for the same subject in the re-run. '
|
|
17
|
+
+ 'Detector absence is not a guarantee of conversion, ranking, or outcome; it is a mechanical re-check only.';
|
|
18
|
+
|
|
19
|
+
function sha256(content) {
|
|
20
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function subjectKey(subject) {
|
|
24
|
+
return subject?.identifier || subject?.url || JSON.stringify(subject) || 'unknown';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function newVerificationId() {
|
|
28
|
+
return `VR-${nowIso().replace(/\D/g, '')}-${Math.random().toString(36).slice(2, 6)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `citable verify remediation` — closed loop: source-run finding -> patch ->
|
|
33
|
+
* detector re-run -> before/after evidence bundle.
|
|
34
|
+
*
|
|
35
|
+
* Fail-closed: a missing source run, an unloadable re-check target, a refused
|
|
36
|
+
* patch, or a failed detector re-run yields status `blocked`/`patch_refused`,
|
|
37
|
+
* never a silent `verified`.
|
|
38
|
+
*/
|
|
39
|
+
export async function verifyRemediation(root, options = {}) {
|
|
40
|
+
const { run: runId, finding, target, apply = false, subject = null } = options;
|
|
41
|
+
const runDir = path.join(root, '.citable', 'runs', runId || '');
|
|
42
|
+
const limitations = [
|
|
43
|
+
RESOLUTION_DEFINITION,
|
|
44
|
+
'The re-check observes the target as it exists now; unrelated concurrent changes are not attributable to the patch.',
|
|
45
|
+
'Rendering-dependent conditions on URL targets are re-observed only if the original collection method is available.',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const result = {
|
|
49
|
+
verification_id: newVerificationId(),
|
|
50
|
+
timestamp: nowIso(),
|
|
51
|
+
status: 'blocked',
|
|
52
|
+
tool_version: PKG_VERSION,
|
|
53
|
+
repository_commit: null,
|
|
54
|
+
source_run_id: runId || null,
|
|
55
|
+
recheck_run_id: null,
|
|
56
|
+
detector_id: finding ? String(finding).toUpperCase() : null,
|
|
57
|
+
subject: null,
|
|
58
|
+
verdict: { resolved: false, before_finding_ids: [], after_finding_ids: [], new_regressions: [], resolution_definition: RESOLUTION_DEFINITION },
|
|
59
|
+
patch: null,
|
|
60
|
+
provenance: { command: 'verify remediation', recheck_target: target || null, detectors_run: [], detectors_skipped: [], recheck_errors: [], recheck_warnings: [] },
|
|
61
|
+
limitations,
|
|
62
|
+
bundle_dir: null,
|
|
63
|
+
bundle_checksums: {},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!runId || !fs.existsSync(path.join(runDir, 'findings.json'))) {
|
|
67
|
+
result.provenance.recheck_errors.push(`source run not found: .citable/runs/${runId || '(none)'}`);
|
|
68
|
+
result.limitations.push('required_input: run-id of the immutable source audit containing the finding.');
|
|
69
|
+
return finalizeResult(result);
|
|
70
|
+
}
|
|
71
|
+
if (!finding) {
|
|
72
|
+
result.provenance.recheck_errors.push('missing required input: --finding <detector-id>');
|
|
73
|
+
return finalizeResult(result);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const beforeFindings = readJson(path.join(runDir, 'findings.json'));
|
|
77
|
+
const manifest = fs.existsSync(path.join(runDir, 'manifest.json'))
|
|
78
|
+
? readJson(path.join(runDir, 'manifest.json'))
|
|
79
|
+
: null;
|
|
80
|
+
result.tool_version = manifest?.tool_version || PKG_VERSION;
|
|
81
|
+
result.repository_commit = manifest?.repository_commit ?? null;
|
|
82
|
+
|
|
83
|
+
const matches = beforeFindings.filter((f) =>
|
|
84
|
+
(f.detector_id || '').toUpperCase() === result.detector_id
|
|
85
|
+
&& (!subject || subjectKey(f.subject) === subject));
|
|
86
|
+
if (matches.length === 0) {
|
|
87
|
+
result.provenance.recheck_errors.push(`finding ${result.detector_id} not present in source run ${runId}`);
|
|
88
|
+
result.status = 'blocked';
|
|
89
|
+
return finalizeResult(result);
|
|
90
|
+
}
|
|
91
|
+
result.verdict.before_finding_ids = matches.map((f) => f.finding_id);
|
|
92
|
+
result.subject = { type: matches[0].subject?.type || 'unknown', identifier: subject || subjectKey(matches[0].subject) };
|
|
93
|
+
|
|
94
|
+
// Optional gated patch application (production-safe pipeline from remediate.js)
|
|
95
|
+
if (target && apply) {
|
|
96
|
+
const targetPath = path.resolve(root, target);
|
|
97
|
+
const beforeSha = fs.existsSync(targetPath) ? sha256(fs.readFileSync(targetPath, 'utf8')) : null;
|
|
98
|
+
const patchRes = await remediateCommand(root, { finding: result.detector_id, target, write: true });
|
|
99
|
+
result.patch = {
|
|
100
|
+
target_file: target,
|
|
101
|
+
framework: patchRes.framework?.framework ?? null,
|
|
102
|
+
patcher_class: patchRes.patcher_class ?? null,
|
|
103
|
+
confidence: patchRes.confidence?.score ?? null,
|
|
104
|
+
validation_passed: Boolean(patchRes.validation?.ok),
|
|
105
|
+
applied: Boolean(patchRes.written),
|
|
106
|
+
refusal_reason: patchRes.refusal_reason ?? null,
|
|
107
|
+
rollback_snapshot: patchRes.rollback?.snapshot_file ?? null,
|
|
108
|
+
before_sha256: beforeSha,
|
|
109
|
+
after_sha256: patchRes.written ? sha256(fs.readFileSync(targetPath, 'utf8')) : null,
|
|
110
|
+
};
|
|
111
|
+
if (!patchRes.written) {
|
|
112
|
+
result.status = 'patch_refused';
|
|
113
|
+
result.limitations.push('Patch was refused by the safety gate; no re-check verdict is possible until the patch is applied or refused intentionally.');
|
|
114
|
+
return finalizeResult(result);
|
|
115
|
+
}
|
|
116
|
+
} else if (target) {
|
|
117
|
+
const p = path.resolve(root, target);
|
|
118
|
+
if (fs.existsSync(p)) {
|
|
119
|
+
result.patch = { target_file: target, applied: true, before_sha256: null, after_sha256: sha256(fs.readFileSync(p, 'utf8')) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Re-run the detector namespace against the original audit target (site dir
|
|
124
|
+
// or URL) — deliberately independent from the patch file target.
|
|
125
|
+
const recheckTarget = options.recheckTarget || manifest?.target?.location || target || null;
|
|
126
|
+
if (!recheckTarget) {
|
|
127
|
+
result.provenance.recheck_errors.push('no re-check target available (original run had no location and no --target supplied)');
|
|
128
|
+
result.limitations.push('required_input: --target <dir|url> matching the source run target.');
|
|
129
|
+
return finalizeResult(result);
|
|
130
|
+
}
|
|
131
|
+
result.provenance.recheck_target = recheckTarget;
|
|
132
|
+
|
|
133
|
+
const namespace = result.detector_id.split('-')[0];
|
|
134
|
+
let ctx;
|
|
135
|
+
try {
|
|
136
|
+
ctx = await buildContext(root, { target: recheckTarget, baseUrl: options.baseUrl, refDate: options.refDate });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
result.provenance.recheck_errors.push(`re-check context failed: ${err.message}`);
|
|
139
|
+
result.limitations.push('required_input: a readable target directory or URL for the detector re-run.');
|
|
140
|
+
return finalizeResult(result);
|
|
141
|
+
}
|
|
142
|
+
if (!ctx.site) {
|
|
143
|
+
result.provenance.recheck_errors.push('re-check target produced no site context; detector re-run impossible (fail closed)');
|
|
144
|
+
return finalizeResult(result);
|
|
145
|
+
}
|
|
146
|
+
result.provenance.recheck_warnings.push(...(ctx.warnings || []));
|
|
147
|
+
|
|
148
|
+
const { findings: afterFindings, detectorsRun, detectorsSkipped, errors } = runDetectors(
|
|
149
|
+
selectDetectors({ namespaces: [namespace] }),
|
|
150
|
+
ctx
|
|
151
|
+
);
|
|
152
|
+
result.provenance.detectors_run = detectorsRun;
|
|
153
|
+
result.provenance.detectors_skipped = detectorsSkipped;
|
|
154
|
+
result.provenance.recheck_errors.push(...errors);
|
|
155
|
+
|
|
156
|
+
const afterMatches = afterFindings.filter((f) =>
|
|
157
|
+
(f.detector_id || '').toUpperCase() === result.detector_id
|
|
158
|
+
&& subjectKey(f.subject) === subjectKey(matches[0].subject));
|
|
159
|
+
result.verdict.after_finding_ids = afterMatches.map((f) => f.finding_id);
|
|
160
|
+
|
|
161
|
+
const beforeKeys = new Set(beforeFindings.map((f) => `${f.detector_id}|${subjectKey(f.subject)}|${f.observation?.summary}`));
|
|
162
|
+
const afterKeys = new Set(afterFindings.map((f) => `${f.detector_id}|${subjectKey(f.subject)}|${f.observation?.summary}`));
|
|
163
|
+
result.verdict.new_regressions = afterFindings
|
|
164
|
+
.filter((f) => ['critical', 'high'].includes(f.classification?.severity))
|
|
165
|
+
.filter((f) => !beforeKeys.has(`${f.detector_id}|${subjectKey(f.subject)}|${f.observation?.summary}`))
|
|
166
|
+
.map((f) => ({ finding_id: f.finding_id, detector_id: f.detector_id, severity: f.classification.severity, summary: f.observation?.summary }));
|
|
167
|
+
|
|
168
|
+
const errorsDuringRecheck = result.provenance.recheck_errors.length > 0;
|
|
169
|
+
if (errorsDuringRecheck) {
|
|
170
|
+
result.status = 'blocked';
|
|
171
|
+
result.verdict.resolved = false;
|
|
172
|
+
result.limitations.push('Detector errors occurred during the re-check; resolution cannot be established from a failed observation.');
|
|
173
|
+
} else if (afterMatches.length === 0) {
|
|
174
|
+
result.status = 'verified';
|
|
175
|
+
result.verdict.resolved = true;
|
|
176
|
+
} else {
|
|
177
|
+
result.status = 'not_resolved';
|
|
178
|
+
result.verdict.resolved = false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return finalizeResult(result, root);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function finalizeResult(result, root = null) {
|
|
185
|
+
const { valid, errors } = validateAgainst('remediation-verification.schema.json', result);
|
|
186
|
+
if (!valid) throw new Error(`verification record invalid: ${errors.join('; ')}`);
|
|
187
|
+
|
|
188
|
+
if (root) {
|
|
189
|
+
const bundleDir = path.join(root, '.citable', 'verification', result.verification_id);
|
|
190
|
+
fs.mkdirSync(bundleDir, { recursive: true });
|
|
191
|
+
writeJson(path.join(bundleDir, 'verification.json'), result);
|
|
192
|
+
writeJson(path.join(bundleDir, 'before-findings.json'), { source_run_id: result.source_run_id, findings: result.verdict.before_finding_ids });
|
|
193
|
+
const checksums = {};
|
|
194
|
+
for (const name of fs.readdirSync(bundleDir)) {
|
|
195
|
+
const p = path.join(bundleDir, name);
|
|
196
|
+
if (fs.statSync(p).isFile()) checksums[name] = sha256(fs.readFileSync(p, 'utf8'));
|
|
197
|
+
}
|
|
198
|
+
result.bundle_dir = path.relative(root, bundleDir);
|
|
199
|
+
result.bundle_checksums = checksums;
|
|
200
|
+
writeJson(path.join(bundleDir, 'verification.json'), result);
|
|
201
|
+
writeJson(path.join(bundleDir, 'checksums.json'), checksums);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|