@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,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,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { contextDir, loadRegistryFile } from '../registries/index.js';
4
+ import { readJson, writeJson } from '../shared/io.js';
5
+ import { buildStrategicRoadmap, formatRoadmapMarkdown } from '../analysis/strategicRoadmap.js';
6
+
7
+ /**
8
+ * `citable roadmap [generate|show]` — generate or view the 30/90/180-day strategic roadmap.
9
+ */
10
+ export async function roadmapCommand(root, { runId, target, write = true } = {}) {
11
+ let findings = [];
12
+ let initiatives = [];
13
+
14
+ // 1. Load findings from specified or latest run
15
+ const runsDir = path.join(root, '.citable', 'runs');
16
+ let sourceRun = runId;
17
+ if (!sourceRun && fs.existsSync(runsDir)) {
18
+ const runs = fs.readdirSync(runsDir).filter((r) => fs.existsSync(path.join(runsDir, r, 'findings.json'))).sort();
19
+ if (runs.length) sourceRun = runs.at(-1);
20
+ }
21
+
22
+ if (sourceRun) {
23
+ const findingsFile = path.join(runsDir, sourceRun, 'findings.json');
24
+ if (fs.existsSync(findingsFile)) {
25
+ findings = JSON.parse(fs.readFileSync(findingsFile, 'utf8'));
26
+ }
27
+ }
28
+
29
+ // 2. Load initiatives if present
30
+ const initFile = path.join(contextDir(root), 'initiatives.yaml');
31
+ if (fs.existsSync(initFile)) {
32
+ try {
33
+ const data = loadRegistryFile(initFile, 'initiatives');
34
+ initiatives = data.entries || [];
35
+ } catch {
36
+ // Ignore non-fatal registry load problems
37
+ }
38
+ }
39
+
40
+ // Target domain resolution
41
+ let domain = target || 'nebulacomponents.com';
42
+ if (sourceRun) {
43
+ const manifestFile = path.join(runsDir, sourceRun, 'manifest.json');
44
+ if (fs.existsSync(manifestFile)) {
45
+ try {
46
+ const manifest = readJson(manifestFile);
47
+ if (manifest.target?.location) domain = manifest.target.location;
48
+ } catch {}
49
+ }
50
+ }
51
+
52
+ const roadmap = buildStrategicRoadmap({ findings, initiatives, targetDomain: domain });
53
+ const markdown = formatRoadmapMarkdown(roadmap);
54
+
55
+ let artifactPath = null;
56
+ if (write) {
57
+ const outDir = path.join(root, '.citable', 'roadmap');
58
+ fs.mkdirSync(outDir, { recursive: true });
59
+ artifactPath = path.join(outDir, 'strategic-roadmap.md');
60
+ fs.writeFileSync(artifactPath, markdown, 'utf8');
61
+ writeJson(path.join(outDir, 'strategic-roadmap.json'), roadmap);
62
+ }
63
+
64
+ return {
65
+ ...roadmap,
66
+ markdown,
67
+ artifact_path: artifactPath,
68
+ };
69
+ }
@@ -2,9 +2,11 @@ import { buildContext } from './context.js';
2
2
  import { selectDetectors } from '../detectors/index.js';
3
3
  import { runDetectors, sitePageFor } from '../detectors/framework.js';
4
4
  import { checklistItem, toStringArray } from '../shared/checklist.js';
5
+ import { recommendSchemaForPages } from '../analysis/schemaRecommendations.js';
5
6
 
6
7
  /**
7
- * `citable schema` — derive JSON-LD proposals from registry data and validate deployed schema.
8
+ * `citable schema` — derive JSON-LD proposals from registry data, recommend FAQPage, Speakable,
9
+ * and HowTo markup from visible page content, and validate deployed schema.
8
10
  *
9
11
  * Generation is registry-driven: schema is only proposed for entities that exist in the
10
12
  * entity registry with a canonical URL. Nothing is fabricated; missing fields are listed
@@ -71,5 +73,11 @@ export async function schemaCommand(root, { target, baseUrl, refDate } = {}) {
71
73
  }
72
74
  }
73
75
 
74
- return { findings, proposals, blocked, missingDeployment };
76
+ // 4. Recommend structural schema markup (FAQPage, Speakable, HowTo) from visible content
77
+ let recommendations = { faq: [], speakable: [], howto: [] };
78
+ if (ctx.site?.pages) {
79
+ recommendations = recommendSchemaForPages(ctx.site.pages);
80
+ }
81
+
82
+ return { findings, proposals, blocked, missingDeployment, recommendations };
75
83
  }
@@ -0,0 +1,67 @@
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
+ client: args.client || 'Enterprise Customer',
36
+ clientContact: args.clientContact || 'procurement@customer.test',
37
+ supplier: args.supplier || 'Nebula Components & Citable Advisory Practice',
38
+ supplierContact: args.supplierContact || 'advisory@nebulacomponents.test',
39
+ budget: args.budget ? Number(args.budget) : 45000,
40
+ termDays: args.term ? Number(args.term) : 90,
41
+ format: args.format || 'markdown',
42
+ output: args.output,
43
+ });
44
+
45
+ return result;
46
+ }
47
+
48
+ function parseSowArgs(argv) {
49
+ const args = { _: [] };
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const a = argv[i];
52
+ if (a === '--target') args.target = argv[++i];
53
+ else if (a === '--base-url') args.baseUrl = argv[++i];
54
+ else if (a === '--run') args.runId = argv[++i];
55
+ else if (a === '--client') args.client = argv[++i];
56
+ else if (a === '--client-contact') args.clientContact = argv[++i];
57
+ else if (a === '--supplier') args.supplier = argv[++i];
58
+ else if (a === '--supplier-contact') args.supplierContact = argv[++i];
59
+ else if (a === '--budget') args.budget = argv[++i];
60
+ else if (a === '--term') args.term = argv[++i];
61
+ else if (a === '--format') args.format = argv[++i];
62
+ else if (a === '--output') args.output = argv[++i];
63
+ else if (a === '--json') args.format = 'json';
64
+ else args._.push(a);
65
+ }
66
+ return args;
67
+ }