@nebulacomponents/citable 1.15.1 → 1.17.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 +50 -0
- package/README.md +18 -5
- package/dist/universal/.agents/skills/citable/SKILL.md +51 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/commands/README.md +3 -1
- package/dist/universal/.agents/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.agents/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.agents/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.agents/skills/citable/manifest.json +17 -12
- package/dist/universal/.agents/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.agents/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.agents/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.agents/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.agents/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.agents/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.agents/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +51 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/commands/README.md +3 -1
- package/dist/universal/.claude/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.claude/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.claude/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.claude/skills/citable/manifest.json +17 -12
- package/dist/universal/.claude/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.claude/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.claude/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.claude/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.claude/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.claude/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.claude/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +51 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/commands/README.md +3 -1
- package/dist/universal/.cursor/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.cursor/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.cursor/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.cursor/skills/citable/manifest.json +17 -12
- package/dist/universal/.cursor/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.cursor/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.cursor/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.cursor/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.cursor/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.cursor/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.cursor/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +51 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/commands/README.md +3 -1
- package/dist/universal/.gemini/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.gemini/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.gemini/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.gemini/skills/citable/manifest.json +17 -12
- package/dist/universal/.gemini/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.gemini/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.gemini/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.gemini/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.gemini/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.gemini/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.gemini/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.github/skills/citable/SKILL.md +51 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/commands/README.md +3 -1
- package/dist/universal/.github/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.github/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.github/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.github/skills/citable/manifest.json +17 -12
- package/dist/universal/.github/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.github/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.github/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.github/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.github/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.github/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.github/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +51 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/commands/README.md +3 -1
- package/dist/universal/.kiro/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.kiro/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.kiro/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.kiro/skills/citable/manifest.json +17 -12
- package/dist/universal/.kiro/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.kiro/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.kiro/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.kiro/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.kiro/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.kiro/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.kiro/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +51 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/commands/README.md +3 -1
- package/dist/universal/.opencode/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.opencode/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.opencode/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.opencode/skills/citable/manifest.json +17 -12
- package/dist/universal/.opencode/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.opencode/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.opencode/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.opencode/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.opencode/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.opencode/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.opencode/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +51 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/commands/README.md +3 -1
- package/dist/universal/.pi/agent/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.pi/agent/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.pi/agent/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.pi/agent/skills/citable/manifest.json +17 -12
- package/dist/universal/.pi/agent/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.pi/agent/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +51 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/commands/README.md +3 -1
- package/dist/universal/.qoder/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.qoder/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.qoder/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.qoder/skills/citable/manifest.json +17 -12
- package/dist/universal/.qoder/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.qoder/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.qoder/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.qoder/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.qoder/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.qoder/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.qoder/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +51 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/commands/README.md +3 -1
- package/dist/universal/.rovodev/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.rovodev/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.rovodev/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.rovodev/skills/citable/manifest.json +17 -12
- package/dist/universal/.rovodev/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.rovodev/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.rovodev/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.rovodev/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.rovodev/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.rovodev/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.rovodev/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +51 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae/skills/citable/manifest.json +17 -12
- package/dist/universal/.trae/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +51 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/commands/README.md +3 -1
- package/dist/universal/.trae-cn/skills/citable/commands/governance.md +12 -0
- package/dist/universal/.trae-cn/skills/citable/commands/measurement.md +20 -0
- package/dist/universal/.trae-cn/skills/citable/commands/reporting.md +16 -0
- package/dist/universal/.trae-cn/skills/citable/manifest.json +17 -12
- package/dist/universal/.trae-cn/skills/citable/schemas/cli-output-envelope.schema.json +30 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/experiment.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/finding.schema.json +69 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/fleet.schema.json +93 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/funnel.schema.json +116 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/mcp-transport-envelope.schema.json +244 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/observation.schema.json +1 -1
- package/dist/universal/.trae-cn/skills/citable/schemas/remediation-verification.schema.json +98 -0
- package/dist/universal/manifest.json +208 -148
- package/package.json +1 -1
- package/schemas/cli-output-envelope.schema.json +30 -0
- package/schemas/experiment.schema.json +1 -1
- package/schemas/finding.schema.json +69 -0
- package/schemas/fleet.schema.json +93 -0
- package/schemas/funnel.schema.json +116 -0
- package/schemas/mcp-transport-envelope.schema.json +244 -0
- package/schemas/observation.schema.json +1 -1
- package/schemas/remediation-verification.schema.json +98 -0
- package/skill/SKILL.md +51 -1
- package/skill/commands/README.md +3 -1
- package/skill/commands/governance.md +12 -0
- package/skill/commands/measurement.md +20 -0
- package/skill/commands/reporting.md +16 -0
- package/src/analysis/saliency.js +119 -0
- package/src/cli/index.js +651 -13
- package/src/commands/attributeImpact.js +54 -0
- package/src/commands/audit.js +2 -2
- package/src/commands/ciWorkflow.js +63 -0
- package/src/commands/compatibility.js +205 -0
- package/src/commands/connect.js +3 -0
- package/src/commands/context.js +2 -1
- package/src/commands/edgeRules.js +196 -0
- package/src/commands/edgeSecurity.js +192 -0
- package/src/commands/exceptions.js +241 -0
- package/src/commands/experimentGuardrails.js +213 -0
- package/src/commands/fleet.js +104 -0
- package/src/commands/generateLlmsTxt.js +111 -0
- package/src/commands/goldenCorpus.js +155 -0
- package/src/commands/implementationKit.js +202 -0
- package/src/commands/inspectAeo.js +74 -0
- package/src/commands/inspectCro.js +142 -0
- package/src/commands/inspectGeo.js +52 -0
- package/src/commands/inspectSerp.js +317 -0
- package/src/commands/lintComponents.js +132 -0
- package/src/commands/monitor.js +138 -0
- package/src/commands/observe.js +6 -1
- package/src/commands/planExperiment.js +120 -0
- package/src/commands/previewCro.js +241 -0
- package/src/commands/probe.js +70 -0
- package/src/commands/remediate.js +461 -0
- package/src/commands/reportConsensus.js +48 -0
- package/src/commands/testFunnel.js +107 -0
- package/src/commands/verifyRemediation.js +204 -0
- package/src/commands/visualRegression.js +165 -0
- package/src/components/index.js +556 -0
- package/src/connectors/index.js +6 -1
- package/src/connectors/indexnow.js +431 -0
- package/src/connectors/mcp/allowlist.js +107 -0
- package/src/connectors/mcp/client.js +239 -0
- package/src/connectors/mcp/diagnostics.js +214 -0
- package/src/connectors/mcp/gscServer.js +155 -0
- package/src/connectors/mcp/http.js +130 -0
- package/src/connectors/mcp/index.js +7 -0
- package/src/connectors/mcp/pilot.js +138 -0
- package/src/connectors/mcp/pilotServer.js +80 -0
- package/src/connectors/mcp/sanitizer.js +38 -0
- package/src/connectors/mcp/stdio.js +178 -0
- package/src/crawler/ipRanges.js +161 -0
- package/src/detectors/agent.js +127 -0
- package/src/detectors/ans.js +282 -0
- package/src/detectors/crawl.js +46 -0
- package/src/detectors/cro.js +825 -0
- package/src/detectors/cwv.js +38 -1
- package/src/detectors/evd.js +101 -0
- package/src/detectors/framework.js +32 -2
- package/src/detectors/geoReco.js +408 -0
- package/src/detectors/index.js +4 -2
- package/src/detectors/link.js +112 -1
- package/src/detectors/page.js +101 -0
- package/src/detectors/schemaData.js +529 -0
- package/src/detectors/tech.js +306 -0
- package/src/extractor/page.js +186 -2
- package/src/extractor/site.js +9 -2
- package/src/installer/index.js +45 -2
- package/src/monitoring/alertDelivery.js +101 -7
- package/src/observations/attribution.js +217 -0
- package/src/registries/index.js +14 -0
- package/src/reporting/consensus.js +416 -0
- package/src/reporting/executiveExport.js +244 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { getComponent, listComponents } from '../components/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Production-safe patch review for generated remediations.
|
|
8
|
+
*
|
|
9
|
+
* Premises enforced here (skill/SKILL.md):
|
|
10
|
+
* - Fail closed: a patch that cannot be validated is never written.
|
|
11
|
+
* - Fact != inference: heuristic patchers carry explicit confidence, and
|
|
12
|
+
* semantic (copy) replacements are refused for automated write.
|
|
13
|
+
* - A written patch is not a verified repair until the detector re-run passes
|
|
14
|
+
* (see verifyRemediation in commands/verifyRemediation.js).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const MIN_WRITE_CONFIDENCE = 0.7;
|
|
18
|
+
|
|
19
|
+
const FINDING_TO_COMPONENT = {
|
|
20
|
+
'CRO-007': { component: 'frictionless-form', rationale: 'Form inputs lack W3C autocomplete tokens; swap with NebulaFrictionlessForm or add autocomplete tokens.' },
|
|
21
|
+
'CRO-008': { component: 'frictionless-form', rationale: 'Disappearing placeholder-only labels; swap with NebulaFrictionlessForm for persistent floating labels.' },
|
|
22
|
+
'CRO-009': { component: 'frictionless-form', rationale: 'Mobile virtual keyboard type mismatch; use inputmode and type mappings.' },
|
|
23
|
+
'CRO-010': { component: 'hero-cta', rationale: 'Hero section suffers from choice overload; replace with NebulaHeroCTA single-dominant hierarchy.' },
|
|
24
|
+
'CRO-011': { component: 'sticky-dock', rationale: 'Primary CTA buried on long mobile page; attach NebulaStickyMobileDock at screen bottom.' },
|
|
25
|
+
'CRO-012': { component: 'hero-cta', rationale: 'Generic low-intent microcopy; replace with action-oriented benefit copy.' },
|
|
26
|
+
'CRO-015': { component: 'touch-target', rationale: 'Interactive touch target under 44px; wrap with NebulaTouchTarget (48x48px).' },
|
|
27
|
+
'CRO-016': { component: 'hero-cta', rationale: 'CTA font size below 12px; use NebulaHeroCTA typography scale.' },
|
|
28
|
+
'CRO-019': { component: 'scent-beacon', rationale: 'AI Search referral lacks claim corroboration; dock NebulaScentBeacon with cited claim.' },
|
|
29
|
+
'COMP-001': { component: 'touch-target', rationale: 'Touch target dimension violation; wrap with NebulaTouchTarget.' },
|
|
30
|
+
'COMP-003': { component: 'hero-cta', rationale: 'Generic button text; use descriptive outcome-oriented copy.' },
|
|
31
|
+
'COMP-005': { component: 'frictionless-form', rationale: 'Missing input label or autocomplete attribute; swap with NebulaFrictionlessForm.' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Patchers that only add mechanical attributes/classes (safe to automate). */
|
|
35
|
+
const MECHANICAL_PATCHERS = new Set(['CRO-007', 'COMP-005', 'CRO-015', 'COMP-001']);
|
|
36
|
+
/** Patchers that rewrite human-facing copy (semantic decision, never auto-written). */
|
|
37
|
+
const SEMANTIC_PATCHERS = new Set(['CRO-012', 'COMP-003']);
|
|
38
|
+
|
|
39
|
+
export function remediateFinding(findingId, { format = 'react' } = {}) {
|
|
40
|
+
const normId = (findingId || '').toUpperCase().trim();
|
|
41
|
+
const mapping = FINDING_TO_COMPONENT[normId];
|
|
42
|
+
if (!mapping) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: `No direct component remediation registered for finding "${findingId}". Available findings: ${Object.keys(FINDING_TO_COMPONENT).join(', ')}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const comp = getComponent(mapping.component, format);
|
|
49
|
+
const scaffoldCmd = `npx nebulacomponents add ${mapping.component} --format ${format}`;
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
finding_id: normId,
|
|
53
|
+
recommended_component: comp.name,
|
|
54
|
+
component_id: comp.id,
|
|
55
|
+
rationale: mapping.rationale,
|
|
56
|
+
scaffold_command: scaffoldCmd,
|
|
57
|
+
format,
|
|
58
|
+
code: comp.code,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect the source framework from file extension and content signals.
|
|
64
|
+
* Returns { framework, confidence, signals } where framework is one of
|
|
65
|
+
* 'jsx' | 'vue' | 'svelte' | 'html' | 'unknown'. Never guesses silently:
|
|
66
|
+
* an unknown framework fails closed downstream.
|
|
67
|
+
*/
|
|
68
|
+
export function detectFramework(filePath, source = '') {
|
|
69
|
+
const ext = path.extname(filePath || '').toLowerCase();
|
|
70
|
+
const signals = [];
|
|
71
|
+
let framework = 'unknown';
|
|
72
|
+
let confidence = 0;
|
|
73
|
+
|
|
74
|
+
if (ext === '.vue') { framework = 'vue'; confidence = 0.6; signals.push(`extension:${ext}`); }
|
|
75
|
+
else if (ext === '.svelte') { framework = 'svelte'; confidence = 0.6; signals.push(`extension:${ext}`); }
|
|
76
|
+
else if (['.html', '.htm'].includes(ext)) { framework = 'html'; confidence = 0.6; signals.push(`extension:${ext}`); }
|
|
77
|
+
else if (['.jsx', '.tsx', '.js', '.ts', '.mjs'].includes(ext)) {
|
|
78
|
+
framework = 'jsx'; confidence = 0.4; signals.push(`extension:${ext}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (/<template[\s>][\s\S]*<\/template>/i.test(source) && /<script[\s>]/i.test(source)) {
|
|
82
|
+
framework = 'vue'; confidence = Math.min(0.95, confidence + 0.35); signals.push('content:vue-sfc');
|
|
83
|
+
} else if (/^\s*<svelte:|<\/script>\s*\n\s*<style/i.test(source) && ext === '.svelte') {
|
|
84
|
+
framework = 'svelte'; confidence = Math.min(0.95, confidence + 0.3); signals.push('content:svelte');
|
|
85
|
+
} else if (/className\s*=|<\/[A-Za-z][^>]*>\s*[,);}]|React\.|from ["']react["']/.test(source)) {
|
|
86
|
+
if (framework === 'jsx' || framework === 'html') {
|
|
87
|
+
const next = /className\s*=/.test(source) ? 'jsx' : framework;
|
|
88
|
+
framework = next;
|
|
89
|
+
confidence = Math.min(0.95, confidence + 0.3);
|
|
90
|
+
signals.push('content:jsx-className');
|
|
91
|
+
}
|
|
92
|
+
} else if (framework === 'jsx' && /<[a-z]+[^>]*>[\s\S]*<\/[a-z]+>/i.test(source) && !/className\s*=/.test(source)) {
|
|
93
|
+
framework = 'html'; confidence = 0.5; signals.push('content:html-tags-no-jsx');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (confidence < 0.4) { framework = 'unknown'; }
|
|
97
|
+
return { framework, confidence: Number(confidence.toFixed(2)), signals };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Apply one finding's patch and report what happened. Idempotent by design:
|
|
102
|
+
* running the patcher on its own output is a fixed point (changed=false).
|
|
103
|
+
* Returns { patched, changed, idempotent, matchCount, patcherClass }.
|
|
104
|
+
*/
|
|
105
|
+
export function applyPatchDetailed(sourceCode, findingId) {
|
|
106
|
+
const normId = (findingId || '').toUpperCase().trim();
|
|
107
|
+
const patched = applyAstPatch(sourceCode, normId);
|
|
108
|
+
const changed = patched !== sourceCode;
|
|
109
|
+
const secondPass = changed ? applyAstPatch(patched, normId) : patched;
|
|
110
|
+
const idempotent = secondPass === patched;
|
|
111
|
+
const matchCount = changed ? countPatchMatches(sourceCode, normId) : 0;
|
|
112
|
+
const patcherClass = MECHANICAL_PATCHERS.has(normId) ? 'mechanical-attribute'
|
|
113
|
+
: SEMANTIC_PATCHERS.has(normId) ? 'semantic-copy'
|
|
114
|
+
: 'structural';
|
|
115
|
+
return { patched, changed, idempotent, matchCount, patcherClass };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function countPatchMatches(source, findingId) {
|
|
119
|
+
const normId = (findingId || '').toUpperCase().trim();
|
|
120
|
+
if (normId === 'CRO-007' || normId === 'COMP-005') {
|
|
121
|
+
let count = 0;
|
|
122
|
+
for (const tag of source.match(/<input[^>]*>/gi) || []) {
|
|
123
|
+
if (!/autocomplete\s*=/i.test(tag)) count++;
|
|
124
|
+
}
|
|
125
|
+
return count;
|
|
126
|
+
}
|
|
127
|
+
if (normId === 'CRO-015' || normId === 'COMP-001') {
|
|
128
|
+
return (source.match(/<button[^>]*className=["'](?![^"']*min-h-)[^"']*["'][^>]*>/gi) || []).length;
|
|
129
|
+
}
|
|
130
|
+
if (normId === 'CRO-012' || normId === 'COMP-003') {
|
|
131
|
+
return (source.match(/>\s*(Submit|Click Here|Learn More|Read More)\s*</gi) || []).length;
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Back-compat string API. Prefer applyPatchDetailed for safety metadata. */
|
|
137
|
+
export function applyAstPatch(sourceCode, findingId) {
|
|
138
|
+
const normId = (findingId || '').toUpperCase().trim();
|
|
139
|
+
let patched = sourceCode;
|
|
140
|
+
|
|
141
|
+
if (normId === 'CRO-007' || normId === 'COMP-005') {
|
|
142
|
+
patched = patched.replace(/<input([^>]*?)(\/>|>)/gi, (match, attrs, close) => {
|
|
143
|
+
if (/autocomplete\s*=/i.test(match)) return match;
|
|
144
|
+
let type = 'text';
|
|
145
|
+
const typeMatch = match.match(/type=["']([^"']+)["']/i);
|
|
146
|
+
if (typeMatch) type = typeMatch[1].toLowerCase();
|
|
147
|
+
let auto = 'on';
|
|
148
|
+
if (type === 'email') auto = 'email';
|
|
149
|
+
else if (type === 'tel') auto = 'tel';
|
|
150
|
+
else if (/name/i.test(match)) auto = 'name';
|
|
151
|
+
const attrsClean = attrs.replace(/\s+$/, '');
|
|
152
|
+
const normClose = close === '/>' ? ' />' : '>';
|
|
153
|
+
return `<input${attrsClean} autoComplete="${auto}"${normClose}`;
|
|
154
|
+
});
|
|
155
|
+
} else if (normId === 'CRO-015' || normId === 'COMP-001') {
|
|
156
|
+
patched = patched.replace(/<button([^>]*?)className=["']([^"']*)["']([^>]*)>/gi, (match, pre, cls, post) => {
|
|
157
|
+
if (!cls.includes('min-h-') && !cls.includes('h-')) {
|
|
158
|
+
return `<button${pre}className="${cls} min-h-[48px] min-w-[48px]"${post}>`;
|
|
159
|
+
}
|
|
160
|
+
return match;
|
|
161
|
+
});
|
|
162
|
+
} else if (normId === 'CRO-012' || normId === 'COMP-003') {
|
|
163
|
+
patched = patched.replace(/>\s*(Submit|Click Here|Learn More|Read More)\s*</gi, '>Get Started Free<');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return patched;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
170
|
+
|
|
171
|
+
const TAG_RX = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^"'>])*?)(\/?)>/g;
|
|
172
|
+
|
|
173
|
+
function tagBalanceReport(src) {
|
|
174
|
+
const stack = [];
|
|
175
|
+
let error = null;
|
|
176
|
+
let previous;
|
|
177
|
+
let cleaned = src;
|
|
178
|
+
do {
|
|
179
|
+
previous = cleaned;
|
|
180
|
+
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '');
|
|
181
|
+
} while (cleaned !== previous);
|
|
182
|
+
let m;
|
|
183
|
+
TAG_RX.lastIndex = 0;
|
|
184
|
+
while ((m = TAG_RX.exec(cleaned)) !== null) {
|
|
185
|
+
const closing = Boolean(m[1]);
|
|
186
|
+
const tagName = m[2];
|
|
187
|
+
const selfClose = Boolean(m[4]);
|
|
188
|
+
const lower = tagName.toLowerCase();
|
|
189
|
+
if (closing) {
|
|
190
|
+
if (stack[stack.length - 1] === lower) stack.pop();
|
|
191
|
+
else {
|
|
192
|
+
error = stack.includes(lower)
|
|
193
|
+
? `closing </${tagName}> crosses unclosed <${stack[stack.length - 1]}>`
|
|
194
|
+
: `closing </${tagName}> has no matching open tag`;
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
} else if (!selfClose && !VOID_TAGS.has(lower)) {
|
|
198
|
+
stack.push(lower);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!error && stack.length > 0) error = `unclosed tag(s): ${stack.join(', ')}`;
|
|
202
|
+
return { error };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function balanceCounts(src, open, close) {
|
|
206
|
+
return {
|
|
207
|
+
open: (src.match(new RegExp(`\\${open}`, 'g')) || []).length,
|
|
208
|
+
close: (src.match(new RegExp(`\\${close}`, 'g')) || []).length,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Heuristic static syntax review of a patch. The safety property validated
|
|
214
|
+
* here is precise: the patch must not disturb the source's structural
|
|
215
|
+
* balance (tags, braces, parens, brackets). Pre-existing quirks in the
|
|
216
|
+
* original are reported, not punished — Citable never rewrites code it did
|
|
217
|
+
* not touch. Unknown frameworks fail closed because no check can run.
|
|
218
|
+
* This is NOT a compiler: a passing result never replaces the project's own
|
|
219
|
+
* build; a failed result blocks any write.
|
|
220
|
+
*/
|
|
221
|
+
export function validatePatchedSource(patched, framework, original = null) {
|
|
222
|
+
const checks = [];
|
|
223
|
+
const push = (check_id, passed, detail) => checks.push({ check_id, passed, detail });
|
|
224
|
+
|
|
225
|
+
if (framework === 'unknown') {
|
|
226
|
+
push('framework-known', false, 'framework could not be detected; no syntax check can run, refusing to write (fail closed)');
|
|
227
|
+
return { ok: false, checks, reason: 'unknown framework; refusing to write' };
|
|
228
|
+
}
|
|
229
|
+
push('framework-known', true, `framework: ${framework}`);
|
|
230
|
+
|
|
231
|
+
if (!patched || !patched.trim()) {
|
|
232
|
+
push('non-empty-output', false, 'patched output is empty');
|
|
233
|
+
return { ok: false, checks, reason: 'empty patched output' };
|
|
234
|
+
}
|
|
235
|
+
push('non-empty-output', true, `${patched.length} chars`);
|
|
236
|
+
|
|
237
|
+
for (const [name, open, close] of [['brace', '{', '}'], ['paren', '(', ')'], ['bracket', '[', ']']]) {
|
|
238
|
+
const after = balanceCounts(patched, open, close);
|
|
239
|
+
const before = original !== null ? balanceCounts(original, open, close) : after;
|
|
240
|
+
const disturbed = original !== null && (before.open !== after.open || before.close !== after.close);
|
|
241
|
+
push(`balanced-${name}s`, !disturbed,
|
|
242
|
+
original !== null
|
|
243
|
+
? disturbed ? `patch changed ${name} balance (${before.open}/${before.close} → ${after.open}/${after.close})` : `balance unchanged (${after.open}/${after.close})`
|
|
244
|
+
: `open: ${after.open}, close: ${after.close}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const patchedTags = tagBalanceReport(patched);
|
|
248
|
+
if (original !== null) {
|
|
249
|
+
const originalTags = tagBalanceReport(original);
|
|
250
|
+
if (patchedTags.error && !originalTags.error) {
|
|
251
|
+
push('tag-structure-preserved', false, `patch introduced tag structure error: ${patchedTags.error}`);
|
|
252
|
+
} else if (patchedTags.error && patchedTags.error === originalTags.error) {
|
|
253
|
+
push('tag-structure-preserved', true, `pre-existing tag structure quirk unchanged by patch: ${patchedTags.error}`);
|
|
254
|
+
} else if (patchedTags.error) {
|
|
255
|
+
push('tag-structure-preserved', false, `patch altered tag structure: ${patchedTags.error} (original: ${originalTags.error || 'balanced'})`);
|
|
256
|
+
} else {
|
|
257
|
+
push('tag-structure-preserved', true, originalTags.error ? `structure balanced; original quirk resolved: ${originalTags.error}` : 'tag structure preserved');
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
push('balanced-tags', !patchedTags.error, patchedTags.error || 'all tags balanced');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ok = checks.every((c) => c.passed);
|
|
264
|
+
return {
|
|
265
|
+
ok,
|
|
266
|
+
checks,
|
|
267
|
+
reason: ok ? null : (checks.find((c) => !c.passed)?.detail || 'validation failed'),
|
|
268
|
+
methodology: 'heuristic static review (structural balance of the patch only); does not replace the project compiler — run the repository build before shipping',
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Unified diff (LCS line diff, 3 lines of context). */
|
|
273
|
+
export function unifiedDiff(original, patched, filename = 'target') {
|
|
274
|
+
const a = original.split('\n');
|
|
275
|
+
const b = patched.split('\n');
|
|
276
|
+
const n = a.length, m2 = b.length;
|
|
277
|
+
const lcs = Array.from({ length: n + 1 }, () => new Array(m2 + 1).fill(0));
|
|
278
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
279
|
+
for (let j = m2 - 1; j >= 0; j--) {
|
|
280
|
+
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const ops = [];
|
|
284
|
+
let i = 0, j = 0;
|
|
285
|
+
while (i < n && j < m2) {
|
|
286
|
+
if (a[i] === b[j]) { ops.push(['ctx', a[i]]); i++; j++; }
|
|
287
|
+
else if (lcs[i + 1][j] >= lcs[i][j + 1]) { ops.push(['del', a[i]]); i++; }
|
|
288
|
+
else { ops.push(['add', b[j]]); j++; }
|
|
289
|
+
}
|
|
290
|
+
while (i < n) { ops.push(['del', a[i]]); i++; }
|
|
291
|
+
while (j < m2) { ops.push(['add', b[j]]); j++; }
|
|
292
|
+
if (!ops.some(([t]) => t !== 'ctx')) return null;
|
|
293
|
+
|
|
294
|
+
const lines = [`--- a/${filename}`, `+++ b/${filename}`];
|
|
295
|
+
const CONTEXT = 3;
|
|
296
|
+
let idx = 0;
|
|
297
|
+
while (idx < ops.length) {
|
|
298
|
+
if (ops[idx][0] === 'ctx') { idx++; continue; }
|
|
299
|
+
let start = idx;
|
|
300
|
+
let end = idx;
|
|
301
|
+
while (end + 1 < ops.length) {
|
|
302
|
+
let k = end + 1;
|
|
303
|
+
while (k < ops.length && ops[k][0] === 'ctx') k++;
|
|
304
|
+
if (k < ops.length && k - (end + 1) <= CONTEXT * 2) { end = k; } else { break; }
|
|
305
|
+
}
|
|
306
|
+
const hunkStart = Math.max(0, start - CONTEXT);
|
|
307
|
+
const hunkEnd = Math.min(ops.length, end + 1 + CONTEXT);
|
|
308
|
+
let aStart = 1, bStart = 1;
|
|
309
|
+
for (let k = 0; k < hunkStart; k++) {
|
|
310
|
+
if (ops[k][0] !== 'add') aStart++;
|
|
311
|
+
if (ops[k][0] !== 'del') bStart++;
|
|
312
|
+
}
|
|
313
|
+
let aCount = 0, bCount = 0;
|
|
314
|
+
const body = [];
|
|
315
|
+
for (let k = hunkStart; k < hunkEnd; k++) {
|
|
316
|
+
const [t, line] = ops[k];
|
|
317
|
+
if (t === 'ctx') { body.push(' ' + line); aCount++; bCount++; }
|
|
318
|
+
else if (t === 'del') { body.push('-' + line); aCount++; }
|
|
319
|
+
else { body.push('+' + line); bCount++; }
|
|
320
|
+
}
|
|
321
|
+
lines.push(`@@ -${aStart},${aCount} +${bStart},${bCount} @@`, ...body);
|
|
322
|
+
idx = hunkEnd + 1;
|
|
323
|
+
}
|
|
324
|
+
return lines.join('\n') + '\n';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Explicit, fact-based confidence score for an automated write. Every factor
|
|
329
|
+
* is recorded; nothing is hidden behind a single number.
|
|
330
|
+
*/
|
|
331
|
+
export function computeConfidence({ frameworkInfo, patch, validation }) {
|
|
332
|
+
const factors = [];
|
|
333
|
+
const add = (factor, weight, detail) => factors.push({ factor, weight, detail });
|
|
334
|
+
|
|
335
|
+
const frameworkKnown = frameworkInfo.framework !== 'unknown';
|
|
336
|
+
add('framework-identified', frameworkKnown ? 0.35 : 0, `${frameworkInfo.framework} (${frameworkInfo.signals.join(', ') || 'no signals'})`);
|
|
337
|
+
add('patcher-matched-target', patch.matchCount > 0 ? 0.3 : 0, `${patch.matchCount} match(es)`);
|
|
338
|
+
add('static-validation-passed', validation.ok ? 0.25 : 0, validation.ok ? 'all balance checks passed' : validation.reason);
|
|
339
|
+
add('idempotency-verified', patch.idempotent ? 0.1 : 0, patch.idempotent ? 'second application is a no-op' : 'repeated application would mutate output again');
|
|
340
|
+
|
|
341
|
+
let score = factors.reduce((s, f) => s + f.weight, 0);
|
|
342
|
+
let cap = null;
|
|
343
|
+
let capReason = null;
|
|
344
|
+
if (patch.patcherClass === 'semantic-copy') {
|
|
345
|
+
cap = 0.6;
|
|
346
|
+
capReason = 'semantic microcopy replacement requires a human editorial decision and is refused for automated write (fact != inference)';
|
|
347
|
+
}
|
|
348
|
+
if (cap !== null && score > cap) score = cap;
|
|
349
|
+
return { score: Number(score.toFixed(2)), factors, cap, cap_reason: capReason };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function sha256(content) {
|
|
353
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function createRollbackSnapshot(root, targetPath, originalContent, meta) {
|
|
357
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
358
|
+
const snapDir = path.join(root, '.citable', 'remediation', 'snapshots');
|
|
359
|
+
fs.mkdirSync(snapDir, { recursive: true });
|
|
360
|
+
const base = path.basename(targetPath);
|
|
361
|
+
const snapFile = path.join(snapDir, `${stamp}-${base}`);
|
|
362
|
+
fs.writeFileSync(snapFile, originalContent, 'utf8');
|
|
363
|
+
const manifest = {
|
|
364
|
+
created_at: new Date().toISOString(),
|
|
365
|
+
target: path.relative(root, targetPath),
|
|
366
|
+
snapshot_file: path.relative(root, snapFile),
|
|
367
|
+
original_sha256: sha256(originalContent),
|
|
368
|
+
...meta,
|
|
369
|
+
};
|
|
370
|
+
const manifestFile = `${snapFile}.manifest.json`;
|
|
371
|
+
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
372
|
+
return { snapshot_file: path.relative(root, snapFile), manifest_file: path.relative(root, manifestFile), restore_hint: `cp "${path.relative(root, snapFile)}" "${path.relative(root, targetPath)}"` };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Remediation command. Dry run by default: returns framework detection,
|
|
377
|
+
* unified diff, static validation, and confidence without touching disk.
|
|
378
|
+
* `write: true` is gated on validation success and confidence threshold and
|
|
379
|
+
* always creates a rollback snapshot first. Low confidence or failed
|
|
380
|
+
* validation refuses the write (fail closed).
|
|
381
|
+
*/
|
|
382
|
+
export async function remediateCommand(root, options = {}) {
|
|
383
|
+
const { finding, component, target, format = 'react', write = false } = options;
|
|
384
|
+
|
|
385
|
+
if (finding) {
|
|
386
|
+
const res = remediateFinding(finding, { format });
|
|
387
|
+
if (!res.ok) throw new Error(res.error);
|
|
388
|
+
|
|
389
|
+
if (target) {
|
|
390
|
+
const fullPath = path.resolve(root, target);
|
|
391
|
+
if (!fs.existsSync(fullPath)) {
|
|
392
|
+
return { ...res, target, error: `target file not found: ${target}`, written: false, write_refused: true, refusal_reason: 'target file not found' };
|
|
393
|
+
}
|
|
394
|
+
const original = fs.readFileSync(fullPath, 'utf8');
|
|
395
|
+
const patch = applyPatchDetailed(original, finding);
|
|
396
|
+
const frameworkInfo = detectFramework(fullPath, original);
|
|
397
|
+
const validation = validatePatchedSource(patch.patched, frameworkInfo.framework, original);
|
|
398
|
+
const confidence = computeConfidence({ frameworkInfo, patch, validation });
|
|
399
|
+
const diff = patch.changed ? unifiedDiff(original, patch.patched, target) : null;
|
|
400
|
+
|
|
401
|
+
const refusal = { write_refused: true, refusal_reason: null };
|
|
402
|
+
if (!patch.changed) refusal.refusal_reason = 'no patch match; file appears already remediated or finding does not apply to this file';
|
|
403
|
+
else if (!patch.idempotent) refusal.refusal_reason = 'patcher is not idempotent on this source; refusing automated write';
|
|
404
|
+
else if (!validation.ok) refusal.refusal_reason = `static validation failed: ${validation.reason}`;
|
|
405
|
+
else if (confidence.score < MIN_WRITE_CONFIDENCE) {
|
|
406
|
+
refusal.refusal_reason = `confidence ${confidence.score} below write threshold ${MIN_WRITE_CONFIDENCE}`
|
|
407
|
+
+ (confidence.cap_reason ? ` (${confidence.cap_reason})` : '');
|
|
408
|
+
} else {
|
|
409
|
+
refusal.write_refused = false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const result = {
|
|
413
|
+
...res,
|
|
414
|
+
target,
|
|
415
|
+
written: false,
|
|
416
|
+
changed: patch.changed,
|
|
417
|
+
patcher_class: patch.patcherClass,
|
|
418
|
+
idempotent: patch.idempotent,
|
|
419
|
+
match_count: patch.matchCount,
|
|
420
|
+
framework: frameworkInfo,
|
|
421
|
+
validation,
|
|
422
|
+
confidence,
|
|
423
|
+
diff,
|
|
424
|
+
write_policy: {
|
|
425
|
+
min_confidence: MIN_WRITE_CONFIDENCE,
|
|
426
|
+
note: 'a written patch is a candidate change, not a verified repair; run citable verify remediation <run-id> to re-run the detector',
|
|
427
|
+
},
|
|
428
|
+
...refusal,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
if (write && !refusal.write_refused) {
|
|
432
|
+
const snapshot = createRollbackSnapshot(root, fullPath, original, {
|
|
433
|
+
finding_id: res.finding_id,
|
|
434
|
+
patched_sha256: sha256(patch.patched),
|
|
435
|
+
confidence: confidence.score,
|
|
436
|
+
});
|
|
437
|
+
fs.writeFileSync(fullPath, patch.patched, 'utf8');
|
|
438
|
+
return { ...result, written: true, rollback: snapshot };
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
return res;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (component) {
|
|
446
|
+
const comp = getComponent(component, format);
|
|
447
|
+
if (!comp) throw new Error(`Unknown component "${component}". Available: ${listComponents().map((c) => c.id).join(', ')}`);
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
component: comp.name,
|
|
451
|
+
scaffold_command: `npx nebulacomponents add ${component} --format ${format}`,
|
|
452
|
+
code: comp.code,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
ok: true,
|
|
458
|
+
available_components: listComponents(),
|
|
459
|
+
supported_findings: Object.keys(FINDING_TO_COMPONENT),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
buildConsensusMatrix,
|
|
5
|
+
loadConsensusRuns,
|
|
6
|
+
renderConsensusHtml,
|
|
7
|
+
renderConsensusMarkdown,
|
|
8
|
+
} from '../reporting/consensus.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `citable report consensus` — compile recorded consensus observations into an
|
|
12
|
+
* auditable Discovery Consensus Matrix covering HTML canonical, Open Graph URL,
|
|
13
|
+
* sitemap presence, and search engine observations.
|
|
14
|
+
*/
|
|
15
|
+
export function reportConsensus(root, { runId, since, last } = {}) {
|
|
16
|
+
if (last != null && (!Number.isInteger(last) || last < 1)) {
|
|
17
|
+
throw new Error('--last must be a positive integer');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { included, skipped } = loadConsensusRuns(root, { runId, since, last });
|
|
21
|
+
if (included.length === 0) {
|
|
22
|
+
throw new Error('No consensus observation runs found in .citable/runs. Run "citable observe consensus --target <dir|url>" first.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const matrix = buildConsensusMatrix(included);
|
|
26
|
+
const dir = path.join(root, '.citable', 'reports');
|
|
27
|
+
const pathMd = path.join(dir, 'consensus.md');
|
|
28
|
+
const pathHtml = path.join(dir, 'consensus.html');
|
|
29
|
+
|
|
30
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
+
fs.writeFileSync(pathMd, renderConsensusMarkdown(matrix, { runId: matrix.latest_run_id, skipped }));
|
|
32
|
+
fs.writeFileSync(pathHtml, renderConsensusHtml(matrix, { runId: matrix.latest_run_id, skipped }));
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
dir,
|
|
36
|
+
runs_included: included.length,
|
|
37
|
+
skipped: skipped.length,
|
|
38
|
+
latest_run_id: matrix.latest_run_id,
|
|
39
|
+
urls_evaluated: matrix.total_urls,
|
|
40
|
+
canonical_consensus_count: matrix.full_canonical_consensus,
|
|
41
|
+
conflicts_count: matrix.canonical_conflicts,
|
|
42
|
+
engine_discrepancies_count: matrix.engine_discrepancies,
|
|
43
|
+
freshness_aligned_count: matrix.freshness_aligned,
|
|
44
|
+
path_md: pathMd,
|
|
45
|
+
path_html: pathHtml,
|
|
46
|
+
matrix,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { buildContext } from './context.js';
|
|
2
|
+
import { selectDetectors } from '../detectors/index.js';
|
|
3
|
+
import { runDetectors } from '../detectors/framework.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `citable test funnel <funnel_id>` — synthetically test multi-step conversion funnel continuity and parameter preservation.
|
|
7
|
+
*/
|
|
8
|
+
export async function testFunnel(root, funnelId, { target, baseUrl, refDate } = {}) {
|
|
9
|
+
const ctx = await buildContext(root, { target, baseUrl, refDate });
|
|
10
|
+
if (!ctx.site) throw new Error('test funnel requires a site target (built output directory or URL)');
|
|
11
|
+
|
|
12
|
+
const funnels = ctx.registries?.funnels?.entries || [];
|
|
13
|
+
if (funnels.length === 0) {
|
|
14
|
+
throw new Error('no funnels declared in registries (expected .citable/funnels.yaml)');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const targetFunnel = funnelId
|
|
18
|
+
? funnels.find((f) => f.funnel_id === funnelId || f.name.toLowerCase().includes(funnelId.toLowerCase()))
|
|
19
|
+
: funnels[0];
|
|
20
|
+
|
|
21
|
+
if (!targetFunnel) {
|
|
22
|
+
throw new Error(`funnel "${funnelId}" not found in registries. Available: ${funnels.map((f) => f.funnel_id).join(', ')}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stepResults = [];
|
|
26
|
+
const issues = [];
|
|
27
|
+
|
|
28
|
+
const steps = targetFunnel.steps || [];
|
|
29
|
+
for (let i = 0; i < steps.length; i++) {
|
|
30
|
+
const step = steps[i];
|
|
31
|
+
const page = ctx.site.pages.find((p) => p.url.includes(step.url_pattern) || (step.page_id && p.url === step.url_pattern));
|
|
32
|
+
|
|
33
|
+
if (!page) {
|
|
34
|
+
issues.push(`Step ${i + 1} (${step.step_id}): Page matching "${step.url_pattern}" not found in audited site`);
|
|
35
|
+
stepResults.push({
|
|
36
|
+
step_id: step.step_id,
|
|
37
|
+
name: step.name || step.step_id,
|
|
38
|
+
url_pattern: step.url_pattern,
|
|
39
|
+
role: step.role,
|
|
40
|
+
found: false,
|
|
41
|
+
status: null,
|
|
42
|
+
next_step_linked: false,
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (page.status >= 400) {
|
|
48
|
+
issues.push(`Step ${i + 1} (${step.step_id}): Returns HTTP error status ${page.status}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let nextStepLinked = true;
|
|
52
|
+
let nextStepTarget = null;
|
|
53
|
+
if (i < steps.length - 1) {
|
|
54
|
+
const nextStep = steps[i + 1];
|
|
55
|
+
const link = (page.ctas || []).find((c) => c.target && c.target.includes(nextStep.url_pattern));
|
|
56
|
+
if (!link) {
|
|
57
|
+
nextStepLinked = false;
|
|
58
|
+
issues.push(`Step ${i + 1} (${step.step_id}): Lacks interactive CTA pointing to next step "${nextStep.url_pattern}"`);
|
|
59
|
+
} else {
|
|
60
|
+
nextStepTarget = link.target;
|
|
61
|
+
if (step.preserve_params && step.preserve_params.length > 0) {
|
|
62
|
+
if (!link.target.includes('?') && !link.target.includes('utm_')) {
|
|
63
|
+
issues.push(`Step ${i + 1} (${step.step_id}): CTA to next step does not forward parameters (${step.preserve_params.join(', ')})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
stepResults.push({
|
|
70
|
+
step_id: step.step_id,
|
|
71
|
+
name: step.name || step.step_id,
|
|
72
|
+
url: page.url,
|
|
73
|
+
role: step.role,
|
|
74
|
+
found: true,
|
|
75
|
+
status: page.status,
|
|
76
|
+
cta_count: (page.ctas || []).length,
|
|
77
|
+
next_step_linked: nextStepLinked,
|
|
78
|
+
next_step_target: nextStepTarget,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Run CRO detectors across the funnel pages
|
|
83
|
+
const { findings } = runDetectors(selectDetectors({ namespaces: ['CRO'] }), ctx);
|
|
84
|
+
const funnelFindings = findings.filter((f) =>
|
|
85
|
+
f.detector_id.startsWith('CRO-') &&
|
|
86
|
+
(f.observation.summary.includes(targetFunnel.funnel_id) ||
|
|
87
|
+
stepResults.some((s) => s.url && (f.subject.identifier === s.url || f.subject.url === s.url)))
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const status = issues.length === 0 ? 'PASS' : 'FAIL';
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
funnel_id: targetFunnel.funnel_id,
|
|
94
|
+
name: targetFunnel.name,
|
|
95
|
+
primary_goal: targetFunnel.primary_goal,
|
|
96
|
+
status,
|
|
97
|
+
total_steps: steps.length,
|
|
98
|
+
steps_verified: stepResults.filter((s) => s.found && s.status === 200 && s.next_step_linked).length,
|
|
99
|
+
steps: stepResults,
|
|
100
|
+
issues,
|
|
101
|
+
findings: funnelFindings.map((f) => ({
|
|
102
|
+
detector_id: f.detector_id,
|
|
103
|
+
severity: f.classification.severity,
|
|
104
|
+
summary: f.observation.summary,
|
|
105
|
+
})),
|
|
106
|
+
};
|
|
107
|
+
}
|