@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.
Files changed (142) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +16 -3
  3. package/dist/universal/.agents/skills/citable/SKILL.md +66 -1
  4. package/dist/universal/.agents/skills/citable/VERSION +1 -1
  5. package/dist/universal/.agents/skills/citable/manifest.json +9 -6
  6. package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  7. package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
  8. package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
  9. package/dist/universal/.agents/skills/citable/schemas/sow.schema.json +92 -0
  10. package/dist/universal/.claude/agents/citable/manifest.json +2 -2
  11. package/dist/universal/.claude/skills/citable/SKILL.md +66 -1
  12. package/dist/universal/.claude/skills/citable/VERSION +1 -1
  13. package/dist/universal/.claude/skills/citable/manifest.json +9 -6
  14. package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  15. package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
  16. package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
  17. package/dist/universal/.claude/skills/citable/schemas/sow.schema.json +92 -0
  18. package/dist/universal/.cursor/skills/citable/SKILL.md +66 -1
  19. package/dist/universal/.cursor/skills/citable/VERSION +1 -1
  20. package/dist/universal/.cursor/skills/citable/manifest.json +9 -6
  21. package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  22. package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
  23. package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
  24. package/dist/universal/.cursor/skills/citable/schemas/sow.schema.json +92 -0
  25. package/dist/universal/.gemini/skills/citable/SKILL.md +66 -1
  26. package/dist/universal/.gemini/skills/citable/VERSION +1 -1
  27. package/dist/universal/.gemini/skills/citable/manifest.json +9 -6
  28. package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  29. package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
  30. package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
  31. package/dist/universal/.gemini/skills/citable/schemas/sow.schema.json +92 -0
  32. package/dist/universal/.github/skills/citable/SKILL.md +66 -1
  33. package/dist/universal/.github/skills/citable/VERSION +1 -1
  34. package/dist/universal/.github/skills/citable/manifest.json +9 -6
  35. package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  36. package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
  37. package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
  38. package/dist/universal/.github/skills/citable/schemas/sow.schema.json +92 -0
  39. package/dist/universal/.kiro/skills/citable/SKILL.md +66 -1
  40. package/dist/universal/.kiro/skills/citable/VERSION +1 -1
  41. package/dist/universal/.kiro/skills/citable/manifest.json +9 -6
  42. package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  43. package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
  44. package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
  45. package/dist/universal/.kiro/skills/citable/schemas/sow.schema.json +92 -0
  46. package/dist/universal/.opencode/skills/citable/SKILL.md +66 -1
  47. package/dist/universal/.opencode/skills/citable/VERSION +1 -1
  48. package/dist/universal/.opencode/skills/citable/manifest.json +9 -6
  49. package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  50. package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
  51. package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
  52. package/dist/universal/.opencode/skills/citable/schemas/sow.schema.json +92 -0
  53. package/dist/universal/.pi/agent/skills/citable/SKILL.md +66 -1
  54. package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
  55. package/dist/universal/.pi/agent/skills/citable/manifest.json +9 -6
  56. package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  57. package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
  58. package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
  59. package/dist/universal/.pi/agent/skills/citable/schemas/sow.schema.json +92 -0
  60. package/dist/universal/.qoder/skills/citable/SKILL.md +66 -1
  61. package/dist/universal/.qoder/skills/citable/VERSION +1 -1
  62. package/dist/universal/.qoder/skills/citable/manifest.json +9 -6
  63. package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  64. package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
  65. package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
  66. package/dist/universal/.qoder/skills/citable/schemas/sow.schema.json +92 -0
  67. package/dist/universal/.rovodev/skills/citable/SKILL.md +66 -1
  68. package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
  69. package/dist/universal/.rovodev/skills/citable/manifest.json +9 -6
  70. package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  71. package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
  72. package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
  73. package/dist/universal/.rovodev/skills/citable/schemas/sow.schema.json +92 -0
  74. package/dist/universal/.trae/skills/citable/SKILL.md +66 -1
  75. package/dist/universal/.trae/skills/citable/VERSION +1 -1
  76. package/dist/universal/.trae/skills/citable/manifest.json +9 -6
  77. package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  78. package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
  79. package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
  80. package/dist/universal/.trae/skills/citable/schemas/sow.schema.json +92 -0
  81. package/dist/universal/.trae-cn/skills/citable/SKILL.md +66 -1
  82. package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
  83. package/dist/universal/.trae-cn/skills/citable/manifest.json +9 -6
  84. package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
  85. package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
  86. package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
  87. package/dist/universal/.trae-cn/skills/citable/schemas/sow.schema.json +92 -0
  88. package/dist/universal/manifest.json +112 -76
  89. package/package.json +1 -1
  90. package/schemas/cli-output-envelope.schema.json +30 -0
  91. package/schemas/finding.schema.json +69 -0
  92. package/schemas/remediation-verification.schema.json +98 -0
  93. package/schemas/sow.schema.json +92 -0
  94. package/skill/SKILL.md +66 -1
  95. package/src/analysis/behavioral.js +126 -0
  96. package/src/analysis/croAudit.js +302 -0
  97. package/src/analysis/croRoadmap.js +121 -0
  98. package/src/analysis/eeat.js +300 -0
  99. package/src/analysis/funnelAnalysis.js +171 -0
  100. package/src/analysis/iceMatrix.js +205 -0
  101. package/src/analysis/offpage.js +228 -0
  102. package/src/analysis/readiness.js +253 -0
  103. package/src/analysis/saliency.js +119 -0
  104. package/src/analysis/schemaRecommendations.js +173 -0
  105. package/src/analysis/strategicRoadmap.js +148 -0
  106. package/src/cli/index.js +418 -9
  107. package/src/commands/answerEngineReadiness.js +48 -0
  108. package/src/commands/audit.js +2 -2
  109. package/src/commands/auditBacklinks.js +79 -0
  110. package/src/commands/ciWorkflow.js +63 -0
  111. package/src/commands/compatibility.js +205 -0
  112. package/src/commands/context.js +2 -1
  113. package/src/commands/croBacklog.js +182 -0
  114. package/src/commands/croSuite.js +156 -0
  115. package/src/commands/edgeRules.js +80 -4
  116. package/src/commands/edgeSecurity.js +192 -0
  117. package/src/commands/executive.js +6 -0
  118. package/src/commands/executiveCro.js +30 -0
  119. package/src/commands/executiveSearch.js +28 -0
  120. package/src/commands/experimentGuardrails.js +213 -0
  121. package/src/commands/goldenCorpus.js +155 -0
  122. package/src/commands/implementationKit.js +202 -0
  123. package/src/commands/inspectCro.js +67 -0
  124. package/src/commands/inspectEeat.js +53 -0
  125. package/src/commands/planExperiment.js +52 -0
  126. package/src/commands/previewCro.js +241 -0
  127. package/src/commands/prioritize.js +42 -6
  128. package/src/commands/remediate.js +461 -0
  129. package/src/commands/roadmapCmd.js +69 -0
  130. package/src/commands/schemaCmd.js +10 -2
  131. package/src/commands/sowCmd.js +67 -0
  132. package/src/commands/sweep.js +357 -0
  133. package/src/commands/verifyRemediation.js +204 -0
  134. package/src/commands/visualRegression.js +165 -0
  135. package/src/components/index.js +556 -0
  136. package/src/detectors/cro.js +48 -0
  137. package/src/detectors/framework.js +31 -1
  138. package/src/reporting/executiveCroReport.js +731 -0
  139. package/src/reporting/executiveExport.js +175 -1
  140. package/src/reporting/executiveSearchReport.js +704 -0
  141. package/src/sow/admissibilityGate.js +194 -0
  142. package/src/sow/generateSow.js +762 -0
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse as parseCsv } from 'csv-parse/sync';
4
+ import { auditBacklinkProfile } from '../analysis/offpage.js';
5
+
6
+ /**
7
+ * `citable audit backlinks` — audit off-page authority and identify toxic domains.
8
+ */
9
+ export async function auditBacklinks(root, { input, target, minSeverity } = {}) {
10
+ let backlinks = [];
11
+
12
+ if (input) {
13
+ const filePath = path.resolve(root, input);
14
+ if (!fs.existsSync(filePath)) throw new Error(`input file not found: ${input}`);
15
+ const raw = fs.readFileSync(filePath, 'utf8');
16
+
17
+ if (filePath.endsWith('.csv')) {
18
+ const records = parseCsv(raw, { columns: true, skip_empty_lines: true });
19
+ backlinks = records.map((r) => ({
20
+ source_url: r.source_url || r.Source || r.url || r.URL,
21
+ target_url: r.target_url || r.Target || r.target,
22
+ anchor_text: r.anchor_text || r.Anchor || r.anchor || '',
23
+ rel: r.rel || r.Rel || r.type || '',
24
+ source_ip: r.source_ip || r.IP || r.ip || null,
25
+ }));
26
+ } else {
27
+ const parsed = JSON.parse(raw);
28
+ backlinks = Array.isArray(parsed) ? parsed : (parsed.backlinks || []);
29
+ }
30
+ } else {
31
+ // Check if there is a competitor or backlink import in .citable/
32
+ const defaultFile = path.join(root, '.citable', 'backlinks.json');
33
+ if (fs.existsSync(defaultFile)) {
34
+ const parsed = JSON.parse(fs.readFileSync(defaultFile, 'utf8'));
35
+ backlinks = Array.isArray(parsed) ? parsed : (parsed.backlinks || []);
36
+ } else {
37
+ throw new Error('audit backlinks requires --input <backlinks.json|csv> or .citable/backlinks.json');
38
+ }
39
+ }
40
+
41
+ const result = auditBacklinkProfile(backlinks, { targetDomain: target });
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Format terminal output for `citable audit backlinks`
47
+ */
48
+ export function formatBacklinksOutput(r) {
49
+ const lines = [
50
+ `Off-Page Authority & Toxic Domain Audit`,
51
+ `=======================================`,
52
+ `Profile Health: ${r.profile_health.toUpperCase()}`,
53
+ `Total Backlinks: ${r.summary.total_backlinks} across ${r.summary.total_referring_domains} referring domain(s)`,
54
+ `Dofollow: ${r.summary.dofollow_count} | Nofollow: ${r.summary.nofollow_count} | UGC: ${r.summary.ugc_count} | Sponsored: ${r.summary.sponsored_count}`,
55
+ `Deep Link Ratio: ${r.summary.deep_link_ratio_pct}%`,
56
+ ``,
57
+ `ANCHOR TEXT PROFILE:`,
58
+ ` Branded Anchors: ${r.anchor_profile.branded_pct}%`,
59
+ ` Commercial Exact Match: ${r.anchor_profile.commercial_exact_match_pct}% (Risk: ${r.anchor_profile.over_optimization_risk.toUpperCase()})`,
60
+ ``,
61
+ `TOXIC DOMAIN IDENTIFICATION:`,
62
+ ` Flagged Toxic Domains: ${r.summary.toxic_domains_count} (Critical: ${r.summary.critical_risk_domains}, High: ${r.summary.high_risk_domains})`,
63
+ ];
64
+
65
+ if (r.toxic_domains.length) {
66
+ lines.push(``, `Flagged Risk Candidates:`);
67
+ for (const d of r.toxic_domains.slice(0, 15)) {
68
+ lines.push(` [${d.risk_tier.toUpperCase()}] ${d.domain}`);
69
+ for (const reason of d.reasons) lines.push(` - ${reason}`);
70
+ if (d.anchors.length) lines.push(` - Sample anchors: "${d.anchors.join('", "')}"`);
71
+ }
72
+ lines.push(``, `Google Search Console Disavow Syntax Preview:`);
73
+ lines.push(r.disavow_export.split('\n').slice(0, 10).join('\n'));
74
+ } else {
75
+ lines.push(` No toxic domain risk patterns detected in profile.`);
76
+ }
77
+
78
+ return lines.join('\n');
79
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function generateCiWorkflow() {
5
+ return `name: Citable CRO Funnel Sentinel
6
+ on:
7
+ pull_request:
8
+ branches: [main, master]
9
+ push:
10
+ branches: [main, master]
11
+
12
+ jobs:
13
+ cro-audit:
14
+ name: CRO Friction & Funnel Drift Sentinel
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - name: Checkout Code
18
+ uses: actions/checkout@v4
19
+
20
+ - name: Setup Node.js
21
+ uses: actions/setup-node@v4
22
+ with:
23
+ node-version: 24
24
+
25
+ - name: Install Dependencies
26
+ run: npm ci
27
+
28
+ - name: Run Component Static Linter
29
+ run: npx @nebulacomponents/citable lint components src/
30
+
31
+ - name: Run Strict Shift-Left CRO Audit Gate
32
+ run: npx @nebulacomponents/citable audit cro --strict
33
+ `;
34
+ }
35
+
36
+ export function formatPrReviewComment(findings = []) {
37
+ if (findings.length === 0) {
38
+ return '### ✅ Citable CRO Sentinel: All conversion funnels and components verified clean with zero detected friction.';
39
+ }
40
+
41
+ const lines = [
42
+ '### ⚠️ Citable CRO Sentinel: Friction Detected in Pull Request',
43
+ '',
44
+ `Found **${findings.length}** conversion action defect(s) violating UX or funnel continuity criteria:`,
45
+ '',
46
+ ];
47
+
48
+ for (const f of findings) {
49
+ lines.push(`- **[${f.detector_id || 'CRO'}]** (${f.severity || 'medium'}): ${f.summary || f.observation?.summary}`);
50
+ if (f.remediation) {
51
+ lines.push(` - *Remediation*: ${f.remediation}`);
52
+ }
53
+ }
54
+
55
+ lines.push('');
56
+ lines.push('#### Recommended 1-Click Remediation');
57
+ lines.push('Run the following locally to patch components automatically with accessible Nebula Components:');
58
+ lines.push('```bash');
59
+ lines.push('npx @nebulacomponents/citable remediate --finding CRO-007 --write');
60
+ lines.push('```');
61
+
62
+ return lines.join('\n');
63
+ }
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { readJson, nowIso } from '../shared/io.js';
6
+ import { buildContext } from './context.js';
7
+ import { runDetectors } from '../detectors/framework.js';
8
+ import { selectDetectors, ALL_DETECTORS } from '../detectors/index.js';
9
+
10
+ const PKG = readJson(new URL('../../package.json', import.meta.url));
11
+ const SCHEMA_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schemas');
12
+
13
+ function checkModule(name) {
14
+ try {
15
+ execFileSync(process.execPath, ['--input-type=module', '-e', `await import('${name}')`], {
16
+ stdio: ['pipe', 'pipe', 'pipe'],
17
+ timeout: 15000,
18
+ env: { ...process.env, NODE_OPTIONS: '' },
19
+ });
20
+ return { present: true, importable: true };
21
+ } catch (err) {
22
+ const notFound = /Cannot find (package|module)/.test(String(err.stderr || err.message));
23
+ return { present: !notFound, importable: false, note: notFound ? 'not installed (optional peer dependency)' : 'installed but failed to import — adapter mismatch' };
24
+ }
25
+ }
26
+
27
+ function engineSatisfied(range, actual) {
28
+ const m = range.match(/>=\s*(\d+)/);
29
+ if (!m) return { satisfied: true, note: `unrecognized range "${range}"` };
30
+ const major = Number.parseInt(actual.split('.')[0], 10);
31
+ return { satisfied: major >= Number(m[1]), note: `requires Node >= ${m[1]}, found ${actual}` };
32
+ }
33
+
34
+ /**
35
+ * `citable compatibility` — pre-flight diagnosis of environment, optional
36
+ * adapters, project framework, registry freshness, and generated-code
37
+ * constraints. Every check states its evidence; absence of an optional
38
+ * adapter is reported, never assumed.
39
+ */
40
+ export async function compatibilityCommand(root, options = {}) {
41
+ const checks = [];
42
+
43
+ const engine = engineSatisfied(PKG.engines?.node || '>=0', process.versions.node);
44
+ checks.push({
45
+ check_id: 'NODE-ENGINE',
46
+ passed: engine.satisfied,
47
+ severity: engine.satisfied ? 'pass' : 'blocker',
48
+ detail: engine.note,
49
+ });
50
+
51
+ const peers = ['playwright', 'lighthouse', 'chrome-launcher', 'tesseract.js'];
52
+ const adapterResults = {};
53
+ for (const name of peers) {
54
+ const declared = Boolean(PKG.peerDependencies?.[name]);
55
+ const res = checkModule(name);
56
+ adapterResults[name] = { declared_optional_peer: declared, ...res };
57
+ checks.push({
58
+ check_id: `ADAPTER-${name.toUpperCase().replace('.', '-')}`,
59
+ passed: true, // absence of an optional adapter is a report, not a failure
60
+ severity: res.importable ? 'pass' : 'advisory',
61
+ detail: res.importable ? `${name} importable` : `${name}: ${res.note}`,
62
+ });
63
+ }
64
+
65
+ // Browser dependency: presence of a Chrome executable is only verified when
66
+ // chrome-launcher is importable; otherwise reported as unknown (fail open in
67
+ // reporting, fail closed in any feature that needs it).
68
+ if (adapterResults['chrome-launcher']?.importable) {
69
+ let browser = 'unknown';
70
+ try {
71
+ const mod = await import('chrome-launcher');
72
+ browser = mod.getChromePath?.() ? 'found' : 'not found';
73
+ } catch { browser = 'lookup failed'; }
74
+ checks.push({
75
+ check_id: 'BROWSER-CHROME',
76
+ passed: browser === 'found',
77
+ severity: browser === 'found' ? 'pass' : 'advisory',
78
+ detail: `Chrome executable: ${browser} (required for observe render / preview evidence; presence does not prove a successful launch)`,
79
+ });
80
+ }
81
+
82
+ // Project framework detection (for remediation format matching)
83
+ let frameworks = [];
84
+ const pkgFile = path.join(root, 'package.json');
85
+ if (fs.existsSync(pkgFile)) {
86
+ try {
87
+ const deps = { ...readJson(pkgFile).dependencies, ...readJson(pkgFile).devDependencies };
88
+ if (deps.next) frameworks.push('next');
89
+ if (deps.react) frameworks.push('react');
90
+ if (deps.vue || deps.nuxt) frameworks.push('vue');
91
+ if (deps.svelte || deps['@sveltejs/kit']) frameworks.push('svelte');
92
+ } catch { /* unreadable package.json reported below */ }
93
+ }
94
+ checks.push({
95
+ check_id: 'PROJECT-FRAMEWORK',
96
+ passed: true,
97
+ severity: 'pass',
98
+ detail: frameworks.length ? `detected: ${frameworks.join(', ')}; remediation templates available: react, vue, html` : 'no framework dependencies detected; component templates remain available as standalone HTML',
99
+ });
100
+
101
+ // Registry freshness
102
+ const citableDir = path.join(root, '.citable');
103
+ if (!fs.existsSync(citableDir)) {
104
+ checks.push({ check_id: 'REGISTRIES-PRESENT', passed: false, severity: 'advisory', detail: '.citable/ not found — run citable init' });
105
+ } else {
106
+ const yamlFiles = fs.readdirSync(citableDir).filter((f) => f.endsWith('.yaml'));
107
+ checks.push({ check_id: 'REGISTRIES-PRESENT', passed: true, severity: 'pass', detail: `${yamlFiles.length} registry/config YAML file(s) found; run citable validate registries for full referential validation` });
108
+ const registrySchemas = fs.readdirSync(SCHEMA_DIR).filter((f) => f.endsWith('.schema.json'));
109
+ checks.push({
110
+ check_id: 'SCHEMA-CONTRACTS',
111
+ passed: true,
112
+ severity: 'pass',
113
+ detail: `${registrySchemas.length} schema contracts shipped by citable ${PKG.version}; registries validated against them by citable validate registries`,
114
+ });
115
+ }
116
+
117
+ // Generated-code constraints (advisory, documented limits)
118
+ const edgeDir = path.join(root, '.citable', 'edge');
119
+ if (fs.existsSync(edgeDir)) {
120
+ for (const f of fs.readdirSync(edgeDir)) {
121
+ const size = fs.statSync(path.join(edgeDir, f)).size;
122
+ const limit = 1024 * 1024;
123
+ checks.push({
124
+ check_id: `EDGE-SIZE-${f}`,
125
+ passed: size <= limit,
126
+ severity: size <= limit ? 'pass' : 'blocker',
127
+ detail: `${f}: ${(size / 1024).toFixed(1)} KiB — Cloudflare Workers free plan limits the worker to ~1 MiB before compression (verify against your plan; this is a documented platform limit, not a measurement)`,
128
+ });
129
+ }
130
+ }
131
+
132
+ const blockers = checks.filter((c) => c.severity === 'blocker' && !c.passed);
133
+ return {
134
+ ok: blockers.length === 0,
135
+ timestamp: nowIso(),
136
+ tool_version: PKG.version,
137
+ node: process.versions.node,
138
+ engines: PKG.engines?.node || null,
139
+ framework: frameworks,
140
+ adapters: adapterResults,
141
+ checks,
142
+ blocker_count: blockers.length,
143
+ note: 'compatibility reports current environment facts; it cannot prove that a browser launch, API authorization, or collection will succeed',
144
+ };
145
+ }
146
+
147
+ /**
148
+ * `citable verify page <page>` — run the full deterministic detector set
149
+ * scoped to one page subject and return a posture verdict.
150
+ * pass = no detector currently reports this page; never an outcome guarantee.
151
+ */
152
+ export async function verifyPage(root, pageRef, { target, baseUrl, refDate, viewport = null } = {}) {
153
+ const ctx = await buildContext(root, { target, baseUrl, refDate, viewport });
154
+ if (!ctx.site) throw new Error('verify page requires a site target (built output directory or URL)');
155
+ const norm = (s) => String(s || '').replace(/\/+$/, '');
156
+ const candidates = [pageRef, pageRef.replace(/\/index\.html?$/i, '/'), pageRef.replace(/^\/+/, '')];
157
+ const wanted = pageRef.startsWith('/') ? `${norm(ctx.site.baseUrl)}${pageRef}` : pageRef;
158
+ const page = ctx.site.pages.find((p) => {
159
+ if (norm(p.url) === norm(wanted) || p.sourceFile === pageRef) return true;
160
+ if (candidates.some((c) => norm(p.url) === norm(pageRef.startsWith('/') ? `${norm(ctx.site.baseUrl)}${c}` : c))) return true;
161
+ if (norm(p.url).endsWith(norm(wanted))) return true;
162
+ return false;
163
+ });
164
+ if (!page) {
165
+ throw new Error(`page not found in audited output: ${pageRef}`);
166
+ }
167
+
168
+ const { findings, detectorsRun, detectorsSkipped, errors } = runDetectors(
169
+ selectDetectors({ namespaces: ALL_DETECTORS.map((d) => d.namespace).filter((v, i, a) => a.indexOf(v) === i) }),
170
+ { ...ctx, site: { ...ctx.site, pages: [page] } }
171
+ );
172
+ const pageFindings = findings.filter((f) => (f.subject?.url === page.url) || (f.subject?.identifier === page.url) || (f.subject?.source_file && f.subject.source_file === page.sourceFile));
173
+
174
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, informational: 0 };
175
+ for (const f of pageFindings) {
176
+ const s = f.classification?.severity;
177
+ if (s in bySeverity) bySeverity[s]++;
178
+ }
179
+ const status = errors.length ? 'blocked' : (bySeverity.critical > 0 || bySeverity.high > 0) ? 'attention' : pageFindings.length ? 'attention' : 'pass';
180
+
181
+ return {
182
+ page: page.url,
183
+ source_file: page.sourceFile,
184
+ status,
185
+ finding_count: pageFindings.length,
186
+ by_severity: bySeverity,
187
+ findings: pageFindings.map((f) => ({
188
+ detector_id: f.detector_id,
189
+ severity: f.classification.severity,
190
+ confidence: f.classification.confidence,
191
+ summary: f.observation.summary,
192
+ provenance: f.provenance,
193
+ })),
194
+ provenance: {
195
+ command: 'verify page',
196
+ timestamp: nowIso(),
197
+ tool_version: PKG.version,
198
+ viewport: ctx.viewport ?? null,
199
+ detectors_run: detectorsRun,
200
+ detectors_skipped: detectorsSkipped,
201
+ errors,
202
+ definition: 'pass means no detector currently reports this page; it is not a guarantee of crawling, indexing, ranking, citation, or conversion',
203
+ },
204
+ };
205
+ }
@@ -5,7 +5,7 @@ import { loadRegistries } from '../registries/index.js';
5
5
  import { buildSiteFromDir, buildSiteFromUrl } from '../extractor/site.js';
6
6
 
7
7
  /** Assemble the shared execution context used by audit-family commands. */
8
- export async function buildContext(root, { target, baseUrl, refDate } = {}) {
8
+ export async function buildContext(root, { target, baseUrl, refDate, viewport = null } = {}) {
9
9
  const warnings = [];
10
10
  const configFile = path.join(root, '.citable', 'config.yaml');
11
11
  const config = fs.existsSync(configFile) ? readYaml(configFile) : { version: 1 };
@@ -71,6 +71,7 @@ export async function buildContext(root, { target, baseUrl, refDate } = {}) {
71
71
  observations: observations.length ? observations : null,
72
72
  snapshots,
73
73
  refDate: parseRefDate(refDate),
74
+ viewport,
74
75
  hashPage: (p) => sha256(p.text),
75
76
  warnings,
76
77
  };
@@ -0,0 +1,182 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJson } from '../shared/io.js';
4
+
5
+ /**
6
+ * Generate a prioritized A/B experiment backlog from CRO audit findings.
7
+ */
8
+ export function generateExperimentBacklog(findings = [], { dailyVisitors = 500, baselineRate = 0.02, defaultMde = 0.12 } = {}) {
9
+ const croFindings = findings.filter((f) => (f.detector_id || '').startsWith('CRO-') || f.discipline?.includes('cro'));
10
+
11
+ const backlog = [];
12
+
13
+ for (let i = 0; i < croFindings.length; i++) {
14
+ const f = croFindings[i];
15
+ const ns = f.detector_id;
16
+ const pageUrl = f.subject?.identifier || f.subject?.url || '/';
17
+
18
+ let title = '';
19
+ let change = '';
20
+ let rationale = f.observation?.summary || 'Friction detected on conversion pathway';
21
+ let expectedMde = defaultMde;
22
+ let primaryMetric = 'conversion_rate';
23
+ let secondaryMetrics = ['bounce_rate', 'form_completion_rate'];
24
+
25
+ switch (ns) {
26
+ case 'CRO-001':
27
+ title = 'Prominent Interactive Hero CTA Deployment';
28
+ change = 'Deploy prominent, high-contrast primary CTA button matching declared conversion intent';
29
+ expectedMde = 0.15;
30
+ break;
31
+ case 'CRO-002':
32
+ title = 'Form Friction Reduction & Progressive Profiling';
33
+ change = 'Condense lead capture form from >6 fields to 3 essential fields with progressive disclosure';
34
+ expectedMde = 0.20;
35
+ primaryMetric = 'lead_submission_rate';
36
+ break;
37
+ case 'CRO-005':
38
+ title = 'Ad-to-Landing Scent Match Alignment';
39
+ change = 'Align above-the-fold H1 headline and value proposition directly with title and ad intent';
40
+ expectedMde = 0.12;
41
+ break;
42
+ case 'CRO-006':
43
+ title = 'Proximate Trust Proof & Security Badges';
44
+ change = 'Position verified customer logo bar and security certification badges immediately below primary CTA';
45
+ expectedMde = 0.10;
46
+ break;
47
+ case 'CRO-007':
48
+ title = 'HTML5 Autocomplete Form Optimization';
49
+ change = 'Inject standard HTML5 autocomplete attributes on all identity, email, and telephone fields';
50
+ expectedMde = 0.08;
51
+ primaryMetric = 'form_completion_speed';
52
+ break;
53
+ case 'CRO-010':
54
+ title = 'Hero Choice Overload Elimination';
55
+ change = 'Consolidate competing hero buttons into exactly ONE dominant primary action and one text link';
56
+ expectedMde = 0.14;
57
+ break;
58
+ case 'CRO-012':
59
+ title = 'High-Intent Value CTA Microcopy';
60
+ change = 'Replace generic "Submit" or "Learn More" with action-oriented value copy ("Get My Free Analysis")';
61
+ expectedMde = 0.10;
62
+ break;
63
+ case 'CRO-013':
64
+ title = 'Enclosed Distraction-Free Checkout Funnel';
65
+ change = 'Remove global header navigation and external outbound links on checkout/pricing steps';
66
+ expectedMde = 0.15;
67
+ break;
68
+ case 'CRO-021':
69
+ title = 'Express Payment Wallet Integration';
70
+ change = 'Implement 1-click Apple Pay, Google Pay, and PayPal express wallet buttons';
71
+ expectedMde = 0.18;
72
+ primaryMetric = 'checkout_conversion_rate';
73
+ break;
74
+ default:
75
+ title = `Remediation for ${f.name || ns}`;
76
+ change = f.remediation?.preferred || 'Implement recommended conversion design system component';
77
+ expectedMde = defaultMde;
78
+ }
79
+
80
+ // Falsifiable hypothesis formulation
81
+ const hypothesis = `If we ${change} on ${pageUrl}, then ${primaryMetric} will increase by at least ${Math.round(expectedMde * 100)}%, because ${rationale.toLowerCase()}.`;
82
+
83
+ // Sample size calculation (two-tailed alpha=0.05, power=0.80)
84
+ const p1 = baselineRate;
85
+ const p2 = p1 * (1 + expectedMde);
86
+ const pBar = (p1 + p2) / 2;
87
+ const zAlpha = 1.96;
88
+ const zBeta = 0.8416;
89
+ const num = Math.pow(zAlpha * Math.sqrt(2 * pBar * (1 - pBar)) + zBeta * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2)), 2);
90
+ const den = Math.pow(p2 - p1, 2);
91
+ const nPerVariant = Math.ceil(num / den);
92
+ const totalSample = nPerVariant * 2;
93
+ const estDays = Math.max(7, Math.ceil(totalSample / dailyVisitors));
94
+
95
+ // Impact, Effort, Confidence (ICE)
96
+ const impact = Math.min(10, Math.max(4, Math.round(expectedMde * 50)));
97
+ const effort = ['CRO-007', 'CRO-012', 'CRO-005'].includes(ns) ? 2 : ['CRO-001', 'CRO-006', 'CRO-010'].includes(ns) ? 4 : 6;
98
+ const confidence = f.classification?.deterministic ? 9 : 7;
99
+ const iceScore = Math.round(((impact * confidence) / effort) * 10) / 10;
100
+
101
+ backlog.push({
102
+ experiment_id: `EXP-CRO-${String(i + 1).padStart(3, '0')}`,
103
+ title,
104
+ detector_id: ns,
105
+ target_page: pageUrl,
106
+ hypothesis,
107
+ discipline: ['cro'],
108
+ primary_metric: primaryMetric,
109
+ secondary_metrics: secondaryMetrics,
110
+ statistical_setup: {
111
+ baseline_rate_pct: Math.round(baselineRate * 1000) / 10,
112
+ expected_mde_pct: Math.round(expectedMde * 100),
113
+ confidence_level_pct: 95,
114
+ power_pct: 80,
115
+ sample_size_per_variant: nPerVariant,
116
+ total_sample_required: totalSample,
117
+ estimated_duration_days: estDays,
118
+ daily_visitors: dailyVisitors,
119
+ },
120
+ guardrails: [
121
+ 'SEO Retention: Variant must preserve all canonical tags, JSON-LD structured data, and meta robots tags',
122
+ 'Performance Guardrail: Mobile LCP must not degrade by > 250ms; CLS must remain <= 0.10',
123
+ 'Secondary Safety Metric: Bounce rate must not increase by > 5% relative to control',
124
+ ],
125
+ stopping_criteria: [
126
+ 'Minimum Duration Lock: Run for at least 7 full days to account for day-of-week seasonality, regardless of early p-values',
127
+ 'Sample Ratio Mismatch (SRM): Terminate immediately if traffic allocation deviates significantly (chi-square p < 0.001)',
128
+ 'Maximum Duration Cap: Stop and conclude as inconclusive if required sample size is not reached within 45 days',
129
+ ],
130
+ ice_prioritization: {
131
+ impact,
132
+ effort,
133
+ confidence,
134
+ ice_score: iceScore,
135
+ quadrant: impact >= 6 && effort <= 4 ? 'quick_wins' : impact >= 6 ? 'strategic_bets' : 'low_hanging_fruit',
136
+ },
137
+ status: 'draft',
138
+ });
139
+ }
140
+
141
+ // Sort by ICE score descending
142
+ backlog.sort((a, b) => b.ice_prioritization.ice_score - a.ice_prioritization.ice_score);
143
+
144
+ return {
145
+ fact_status: 'experiment_backlog_projection',
146
+ total_experiments: backlog.length,
147
+ summary: {
148
+ quick_wins: backlog.filter((e) => e.ice_prioritization.quadrant === 'quick_wins').length,
149
+ strategic_bets: backlog.filter((e) => e.ice_prioritization.quadrant === 'strategic_bets').length,
150
+ low_hanging_fruit: backlog.filter((e) => e.ice_prioritization.quadrant === 'low_hanging_fruit').length,
151
+ },
152
+ experiments: backlog,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Format Experiment Backlog as Markdown
158
+ */
159
+ export function formatBacklogMarkdown(backlog) {
160
+ const lines = [
161
+ `# Conversion Experimentation Backlog & Hypothesis Register`,
162
+ `=========================================================`,
163
+ `Total Hypotheses: ${backlog.total_experiments} | Quick Wins: ${backlog.summary.quick_wins} | Strategic Bets: ${backlog.summary.strategic_bets}`,
164
+ ``,
165
+ `Methodology: Every experiment requires a falsifiable hypothesis, statistical power calculations, SEO guardrails, and stopping criteria.`,
166
+ ``,
167
+ ];
168
+
169
+ for (const exp of backlog.experiments) {
170
+ lines.push(`## [${exp.ice_prioritization.quadrant.toUpperCase()}] ${exp.experiment_id}: ${exp.title}`);
171
+ lines.push(`- **Target Surface**: \`${exp.target_page}\` (Trigger: \`${exp.detector_id}\`)`);
172
+ lines.push(`- **Falsifiable Hypothesis**: "${exp.hypothesis}"`);
173
+ lines.push(`- **Primary Metric**: \`${exp.primary_metric}\` | **Expected MDE**: +${exp.statistical_setup.expected_mde_pct}%`);
174
+ lines.push(`- **Statistical Blueprint**: ${exp.statistical_setup.sample_size_per_variant.toLocaleString()} visitors/variant (~${exp.statistical_setup.estimated_duration_days} days at ${exp.statistical_setup.daily_visitors} visitors/day, 80% power @ 95% conf)`);
175
+ lines.push(`- **ICE Score**: **${exp.ice_prioritization.ice_score}** (Impact: ${exp.ice_prioritization.impact}/10, Effort: ${exp.ice_prioritization.effort}/10, Conf: ${exp.ice_prioritization.confidence}/10)`);
176
+ lines.push(`- **Guardrails**: ${exp.guardrails.join('; ')}`);
177
+ lines.push(`- **Stopping Criteria**: ${exp.stopping_criteria.join('; ')}`);
178
+ lines.push(``);
179
+ }
180
+
181
+ return lines.join('\n');
182
+ }