@nebulacomponents/citable 1.17.0 → 1.18.1
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 +62 -0
- package/dist/universal/.agents/skills/citable/SKILL.md +41 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.agents/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.agents/skills/citable/commands/README.md +13 -0
- package/dist/universal/.agents/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.agents/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.agents/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.agents/skills/citable/manifest.json +12 -7
- package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.claude/agents/citable/citable-remediator.md +43 -0
- package/dist/universal/.claude/agents/citable/citable-sow-architect.md +43 -0
- package/dist/universal/.claude/agents/citable/manifest.json +6 -4
- package/dist/universal/.claude/skills/citable/SKILL.md +41 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.claude/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.claude/skills/citable/commands/README.md +13 -0
- package/dist/universal/.claude/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.claude/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.claude/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.claude/skills/citable/manifest.json +16 -9
- package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +41 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.cursor/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.cursor/skills/citable/commands/README.md +13 -0
- package/dist/universal/.cursor/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.cursor/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +12 -7
- package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +41 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.gemini/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.gemini/skills/citable/commands/README.md +13 -0
- package/dist/universal/.gemini/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.gemini/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +12 -7
- package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.github/skills/citable/SKILL.md +41 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.github/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.github/skills/citable/commands/README.md +13 -0
- package/dist/universal/.github/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.github/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.github/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.github/skills/citable/manifest.json +12 -7
- package/dist/universal/.github/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +41 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.kiro/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.kiro/skills/citable/commands/README.md +13 -0
- package/dist/universal/.kiro/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.kiro/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +12 -7
- package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +41 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.opencode/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.opencode/skills/citable/commands/README.md +13 -0
- package/dist/universal/.opencode/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.opencode/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +12 -7
- package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +41 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.pi/agent/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +13 -0
- package/dist/universal/.pi/agent/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.pi/agent/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +12 -7
- package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +41 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.qoder/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.qoder/skills/citable/commands/README.md +13 -0
- package/dist/universal/.qoder/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.qoder/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +12 -7
- package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +41 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.rovodev/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.rovodev/skills/citable/commands/README.md +13 -0
- package/dist/universal/.rovodev/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.rovodev/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +12 -7
- package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +41 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.trae/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.trae/skills/citable/commands/README.md +13 -0
- package/dist/universal/.trae/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.trae/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.trae/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.trae/skills/citable/manifest.json +12 -7
- package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +41 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/agents/citable-remediator.md +43 -0
- package/dist/universal/.trae-cn/skills/citable/agents/citable-sow-architect.md +43 -0
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +13 -0
- package/dist/universal/.trae-cn/skills/citable/commands/remediation.md +55 -0
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +28 -0
- package/dist/universal/.trae-cn/skills/citable/commands/sow.md +95 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +12 -7
- package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +409 -0
- package/dist/universal/manifest.json +150 -88
- package/package.json +1 -1
- package/schemas/sow.schema.json +409 -0
- package/skill/SKILL.md +41 -1
- package/skill/agents/citable-remediator.md +43 -0
- package/skill/agents/citable-sow-architect.md +43 -0
- package/skill/commands/README.md +13 -0
- package/skill/commands/remediation.md +55 -0
- package/skill/commands/reporting.md +28 -0
- package/skill/commands/sow.md +95 -0
- 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/schemaRecommendations.js +173 -0
- package/src/analysis/strategicRoadmap.js +148 -0
- package/src/cli/index.js +149 -3
- package/src/commands/answerEngineReadiness.js +48 -0
- package/src/commands/auditBacklinks.js +79 -0
- package/src/commands/croBacklog.js +182 -0
- package/src/commands/croSuite.js +156 -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/inspectCro.js +7 -0
- package/src/commands/inspectEeat.js +53 -0
- package/src/commands/prioritize.js +42 -6
- package/src/commands/roadmapCmd.js +69 -0
- package/src/commands/schemaCmd.js +10 -2
- package/src/commands/sowCmd.js +84 -0
- package/src/commands/sweep.js +357 -0
- package/src/reporting/executiveCroReport.js +731 -0
- package/src/reporting/executiveExport.js +62 -0
- package/src/reporting/executiveSearchReport.js +704 -0
- package/src/sow/admissibilityGate.js +364 -0
- package/src/sow/generateSow.js +1178 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { generateSow, exportSow, renderSowMarkdown } from '../sow/generateSow.js';
|
|
4
|
+
import { validateAgainst } from '../shared/schemaValidator.js';
|
|
5
|
+
import { readJson } from '../shared/io.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `citable sow` command handler
|
|
9
|
+
*/
|
|
10
|
+
export async function sowCommand(argv = [], root = process.cwd()) {
|
|
11
|
+
const sub = argv[0];
|
|
12
|
+
|
|
13
|
+
if (sub === 'validate') {
|
|
14
|
+
const file = argv[1];
|
|
15
|
+
if (!file) throw new Error('usage: citable sow validate <file.json>');
|
|
16
|
+
const filePath = path.resolve(root, file);
|
|
17
|
+
if (!fs.existsSync(filePath)) throw new Error(`file not found: ${filePath}`);
|
|
18
|
+
const data = readJson(filePath);
|
|
19
|
+
const { valid, errors } = validateAgainst('sow.schema.json', data);
|
|
20
|
+
return {
|
|
21
|
+
command: 'sow validate',
|
|
22
|
+
file,
|
|
23
|
+
valid,
|
|
24
|
+
errors: errors || [],
|
|
25
|
+
message: valid ? 'SOW conforms strictly to schemas/sow.schema.json contract' : `SOW schema validation failed: ${errors?.join(', ')}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Default: generate SOW
|
|
30
|
+
const args = parseSowArgs(argv);
|
|
31
|
+
const result = await exportSow(root, {
|
|
32
|
+
target: args.target,
|
|
33
|
+
baseUrl: args.baseUrl,
|
|
34
|
+
runId: args.runId,
|
|
35
|
+
live: args.live,
|
|
36
|
+
sample: args.sample,
|
|
37
|
+
demo: args.demo,
|
|
38
|
+
draft: args.draft,
|
|
39
|
+
client: args.client || 'Enterprise Customer',
|
|
40
|
+
clientContact: args.clientContact || 'procurement@customer.test',
|
|
41
|
+
supplier: args.supplier || 'Nebula Components & Citable Advisory Practice',
|
|
42
|
+
supplierContact: args.supplierContact || 'advisory@nebulacomponents.test',
|
|
43
|
+
budget: args.budget ? Number(args.budget) : 45000,
|
|
44
|
+
budgetMinor: args.budgetMinor ? Number(args.budgetMinor) : null,
|
|
45
|
+
termDays: args.term ? Number(args.term) : 90,
|
|
46
|
+
format: args.format || 'markdown',
|
|
47
|
+
output: args.output,
|
|
48
|
+
inScopeProperties: args.inScope ? args.inScope.split(',').map((s) => s.trim()) : [],
|
|
49
|
+
scopeMode: args.scopeMode || 'HOST_AND_SUBDOMAINS',
|
|
50
|
+
minIceScore: args.minIce ? Number(args.minIce) : 8.0,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseSowArgs(argv) {
|
|
57
|
+
const args = { _: [] };
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === '--target') args.target = argv[++i];
|
|
61
|
+
else if (a === '--base-url') args.baseUrl = argv[++i];
|
|
62
|
+
else if (a === '--run') args.runId = argv[++i];
|
|
63
|
+
else if (a === '--live') args.live = true;
|
|
64
|
+
else if (a === '--sample') args.sample = true;
|
|
65
|
+
else if (a === '--demo') args.demo = true;
|
|
66
|
+
else if (a === '--draft') args.draft = true;
|
|
67
|
+
else if (a === '--client') args.client = argv[++i];
|
|
68
|
+
else if (a === '--client-contact') args.clientContact = argv[++i];
|
|
69
|
+
else if (a === '--supplier') args.supplier = argv[++i];
|
|
70
|
+
else if (a === '--supplier-contact') args.supplierContact = argv[++i];
|
|
71
|
+
else if (a === '--budget') args.budget = argv[++i];
|
|
72
|
+
else if (a === '--budget-minor') args.budgetMinor = argv[++i];
|
|
73
|
+
else if (a === '--term') args.term = argv[++i];
|
|
74
|
+
else if (a === '--format') args.format = argv[++i];
|
|
75
|
+
else if (a === '--output') args.output = argv[++i];
|
|
76
|
+
else if (a === '--json') args.format = 'json';
|
|
77
|
+
else if (a === '--scope-mode' || a === '--scope') args.scopeMode = argv[++i];
|
|
78
|
+
else if (a === '--in-scope') args.inScope = argv[++i];
|
|
79
|
+
else if (a === '--min-ice') args.minIce = argv[++i];
|
|
80
|
+
else args._.push(a);
|
|
81
|
+
}
|
|
82
|
+
return args;
|
|
83
|
+
}
|
|
84
|
+
|
|
@@ -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
|
+
}
|