@deneb-ui/cli 2.0.50 → 2.0.52

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.
@@ -8,6 +8,9 @@
8
8
  * locally rather than at upload time.
9
9
  */
10
10
 
11
+ const recast = require('recast');
12
+ const { parseSource, getJsxName, hasJsxAttribute, collectJsxText } = require('./ast.cjs');
13
+
11
14
  const MARKER_ATTRIBUTE_TO_KIND = {
12
15
  'data-preview-field-path': 'field',
13
16
  'data-preview-list-path': 'list',
@@ -214,6 +217,17 @@ function auditMarkerPlacement(code, filePath) {
214
217
  const staticMatch = attrs.match(/\bdata-preview-static\s*=\s*(?:"([^"]*)"|'([^']*)')/);
215
218
  const hasStatic = /\bdata-preview-static\b/.test(attrs);
216
219
 
220
+ const isHidden =
221
+ /\bhidden(?:[\s=]|\/?>)/i.test(attrs) ||
222
+ /\baria-hidden\s*=\s*(?:"true"|'true'|\{\s*true\s*\})/i.test(attrs) ||
223
+ /\bstyle\s*=\s*\{\s*\{[\s\S]*?\b(?:display\s*:\s*['"]none['"]|visibility\s*:\s*['"]hidden['"])[\s\S]*?\}\s*\}/i.test(attrs) ||
224
+ /\bclassName\s*=\s*(?:"[^"]*\bhidden\b[^"]*"|'[^']*\bhidden\b[^']*'|\{\s*`[^`]*\bhidden\b[^`]*`\s*\})/i.test(attrs);
225
+
226
+ if ((hasField || hasList || hasItem) && isHidden) {
227
+ errors.push(
228
+ `${filePath}:${line} data-preview field/list/item marker is hidden. Strict visual-edit targets must remain visible and clickable in the exported page.`
229
+ );
230
+ }
217
231
  if (hasStatic && !(staticMatch?.[1] ?? staticMatch?.[2] ?? '').trim()) {
218
232
  errors.push(`${filePath}:${line} data-preview-static requires a reason.`);
219
233
  }
@@ -234,6 +248,33 @@ function auditMarkerPlacement(code, filePath) {
234
248
  }
235
249
  }
236
250
 
251
+ errors.push(...auditCoupledListMarkers(code, filePath));
252
+ return errors;
253
+ }
254
+
255
+ /**
256
+ * Detects coupling parallel arrays by index: rendering a field from list B inside an item belonging to list A.
257
+ */
258
+ function auditCoupledListMarkers(code, filePath) {
259
+ const errors = [];
260
+ const itemBlockPattern = /<\s*([a-zA-Z][a-zA-Z0-9.:-]*)((?:[^<>{}]|\{[^{}]*\})*?)\bdata-preview-item-path\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`]*)`\s*\})([\s\S]*?)<\/\1>/g;
261
+ for (const match of String(code).matchAll(itemBlockPattern)) {
262
+ const itemPath = match[3] ?? match[4] ?? match[5] ?? '';
263
+ const inner = match[6] || '';
264
+ if (!itemPath) continue;
265
+ const baseList = itemPath.replace(/\[[^\]]*\]$/, '');
266
+
267
+ for (const fieldMatch of inner.matchAll(/\bdata-preview-field-path\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*`([^`]*)`\s*\})/g)) {
268
+ const fieldPath = fieldMatch[1] ?? fieldMatch[2] ?? fieldMatch[3] ?? '';
269
+ if (!fieldPath || !/\[\d+\]/.test(fieldPath)) continue;
270
+ const fieldList = fieldPath.replace(/\[\d+\][\s\S]*$/, '');
271
+ if (fieldList && baseList && fieldList !== baseList) {
272
+ errors.push(
273
+ `${filePath}:${lineNumberAt(code, match.index ?? 0)} repeated field "${fieldPath}" is rendered inside item "${itemPath}" but belongs to a different list. Model one visual card as one object-list item instead of coupling parallel arrays by index.`
274
+ );
275
+ }
276
+ }
277
+ }
237
278
  return errors;
238
279
  }
239
280
 
@@ -475,8 +516,71 @@ function auditPreviewRuntime(sources) {
475
516
  /**
476
517
  * Finds meaningful visible JSX text that is neither bound to a field nor marked
477
518
  * static. Strict mode treats these as errors in the exported HTML.
519
+ * Prefers an AST walk so TypeScript generics and nested tags are not misread.
478
520
  */
479
521
  function findUncoveredVisibleText(code, filePath) {
522
+ let ast;
523
+ try {
524
+ ast = parseSource(code, filePath);
525
+ } catch {
526
+ return findUncoveredVisibleTextRegex(code, filePath);
527
+ }
528
+
529
+ const findings = [];
530
+ recast.types.visit(ast, {
531
+ visitJSXElement(pathNode) {
532
+ const node = pathNode.node;
533
+ const tag = getJsxName(node);
534
+ const lower = String(tag || '').toLowerCase();
535
+ if (!tag || lower === 'option' || lower === 'script' || lower === 'style') {
536
+ this.traverse(pathNode);
537
+ return;
538
+ }
539
+ if (
540
+ hasJsxAttribute(node, 'data-preview-field-path') ||
541
+ hasJsxAttribute(node, 'data-preview-static')
542
+ ) {
543
+ this.traverse(pathNode);
544
+ return;
545
+ }
546
+ if (ancestorHasStaticMarker(pathNode)) {
547
+ this.traverse(pathNode);
548
+ return;
549
+ }
550
+ const text = collectJsxText(node);
551
+ if (isMeaningfulUncoveredText(text)) {
552
+ findings.push({
553
+ tag,
554
+ text,
555
+ filePath,
556
+ line: node.loc?.start?.line || 1,
557
+ });
558
+ }
559
+ this.traverse(pathNode);
560
+ },
561
+ });
562
+ return findings;
563
+ }
564
+
565
+ function ancestorHasStaticMarker(pathNode) {
566
+ let current = pathNode.parent;
567
+ while (current) {
568
+ const node = current.node || current.value;
569
+ if (node && node.type === 'JSXElement' && hasJsxAttribute(node, 'data-preview-static')) return true;
570
+ current = current.parentPath || current.parent;
571
+ }
572
+ return false;
573
+ }
574
+
575
+ function isMeaningfulUncoveredText(text) {
576
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
577
+ if (!value || value.length <= 2 || !/\p{L}/u.test(value)) return false;
578
+ if (!/^[\p{L}\p{N}"'(¡¿#$€£]/u.test(value)) return false;
579
+ if (/^(?:true|false|null|undefined)$/i.test(value)) return false;
580
+ return true;
581
+ }
582
+
583
+ function findUncoveredVisibleTextRegex(code, filePath) {
480
584
  const findings = [];
481
585
  const source = String(code);
482
586
  const elementPattern = /<\s*([a-zA-Z][a-zA-Z0-9.:-]*)((?:[^<>{}]|\{[^{}]*\})*?)>([^<>{}]*)</g;
@@ -487,13 +591,9 @@ function findUncoveredVisibleText(code, filePath) {
487
591
  const text = (match[3] || '').replace(/\s+/g, ' ').trim();
488
592
  const offset = match.index ?? 0;
489
593
 
490
- // A `<` preceded by an identifier character is a TypeScript generic
491
- // (forwardRef<HTMLButtonElement, Props>), never a JSX element.
492
594
  if (/[\w$)\]]/.test(source[offset - 1] || '')) continue;
493
595
  if (tag.toLowerCase() === 'option') continue;
494
- if (!text || text.length <= 2 || !/\p{L}/u.test(text)) continue;
495
- if (!/^[\p{L}\p{N}"'(¡¿#$€£]/u.test(text)) continue;
496
- if (/^(?:true|false|null|undefined)$/i.test(text)) continue;
596
+ if (!isMeaningfulUncoveredText(text)) continue;
497
597
  if (/\bdata-preview-(?:field-path|static)\b/.test(attrs)) continue;
498
598
 
499
599
  findings.push({ tag, text, filePath, line: lineNumberAt(source, offset) });
@@ -502,6 +602,161 @@ function findUncoveredVisibleText(code, filePath) {
502
602
  return findings;
503
603
  }
504
604
 
605
+ function auditEmptyStateSource(code, filePath) {
606
+ const errors = [];
607
+ const source = String(code);
608
+ const andPattern =
609
+ /\{([^{}]{0,120}?)\s*&&\s*\(?\s*<\s*([a-zA-Z][\w.:-]*)([^>]*data-preview-(?:field-path|list-path|item-path)[^>]*)>/g;
610
+ for (const match of source.matchAll(andPattern)) {
611
+ errors.push(
612
+ `${filePath}:${lineNumberAt(source, match.index ?? 0)} editable marker is gated behind "${String(match[1]).trim()} &&". Keep the target mounted when its value is empty, false, or zero.`
613
+ );
614
+ }
615
+ const ternaryPattern =
616
+ /\{([^{}]{0,80}?)\s*\?\s*<\s*([a-zA-Z][\w.:-]*)([^>]*data-preview-(?:field-path|list-path|item-path)[^>]*)>[\s\S]{0,200}?\?\s*(?:null|false|undefined)/g;
617
+ for (const match of source.matchAll(ternaryPattern)) {
618
+ errors.push(
619
+ `${filePath}:${lineNumberAt(source, match.index ?? 0)} editable marker unmounts on a falsy ternary. Keep the target mounted when its value is empty.`
620
+ );
621
+ }
622
+ return errors;
623
+ }
624
+
625
+ function auditSelectOptions(editorSchema, pages = []) {
626
+ const errors = [];
627
+ const declaredPageIds = new Set((pages || []).map((page) => page?.id).filter(Boolean));
628
+
629
+ function checkSelect(path, options) {
630
+ if (!Array.isArray(options) || !options.some((option) => typeof option === 'string' && option.trim())) {
631
+ errors.push(
632
+ `editorSchema select field "${path}" must declare at least one non-empty option for strict visual editing.`
633
+ );
634
+ return;
635
+ }
636
+ if (!declaredPageIds.size) return;
637
+ if (!/(?:destination|action|targetpage|buttonaction|pagekey)/i.test(path)) return;
638
+ for (const option of options) {
639
+ const opt = typeof option === 'string' ? option.trim() : '';
640
+ if (opt && opt !== 'none' && opt !== 'external' && !declaredPageIds.has(opt)) {
641
+ errors.push(
642
+ `editorSchema select field "${path}" declares destination option "${opt}" which is not in fivora-template.json pages[].`
643
+ );
644
+ }
645
+ }
646
+ }
647
+
648
+ function walk(path, node) {
649
+ if (!node) return;
650
+ if (node.type === 'select') {
651
+ checkSelect(path, node.options);
652
+ return;
653
+ }
654
+ if (node.type === 'object') {
655
+ for (const field of node.fields || []) walk(appendPath(path, field.key), field);
656
+ return;
657
+ }
658
+ if (node.type !== 'list') return;
659
+ const itemPath = `${wildcardPath(path)}[*]`;
660
+ if (node.itemField?.type === 'select') checkSelect(itemPath, node.itemField.options);
661
+ for (const field of node.fields || []) walk(appendPath(itemPath, field.key), field);
662
+ }
663
+
664
+ for (const section of editorSchema?.sections || []) walk(section.path, section);
665
+ return errors;
666
+ }
667
+
668
+ function auditListBounds(editorSchema, content) {
669
+ const errors = [];
670
+
671
+ function validBound(value) {
672
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
673
+ }
674
+
675
+ function readValue(path) {
676
+ let value = content || {};
677
+ for (const part of String(path).split('.')) {
678
+ if (!part || !isPlainObject(value)) return undefined;
679
+ value = value[part];
680
+ }
681
+ return value;
682
+ }
683
+
684
+ function walkSchema(path, node) {
685
+ if (!node) return;
686
+ if (node.type === 'object') {
687
+ for (const field of node.fields || []) walkSchema(appendPath(path, field.key), field);
688
+ return;
689
+ }
690
+ if (node.type !== 'list') return;
691
+ const minItems = validBound(node.minItems);
692
+ const maxItems = validBound(node.maxItems);
693
+ if (node.minItems !== undefined && minItems === null) {
694
+ errors.push(`editorSchema list "${path}" minItems must be a non-negative safe integer.`);
695
+ }
696
+ if (node.maxItems !== undefined && maxItems === null) {
697
+ errors.push(`editorSchema list "${path}" maxItems must be a non-negative safe integer.`);
698
+ }
699
+ const effectiveMinimum = minItems ?? (node.required ? 1 : 0);
700
+ if (maxItems !== null && effectiveMinimum > maxItems) {
701
+ errors.push(
702
+ `editorSchema list "${path}" requires at least ${effectiveMinimum} item(s) but maxItems is ${maxItems}.`
703
+ );
704
+ }
705
+ const items = Array.isArray(readValue(path)) ? readValue(path) : [];
706
+ if (maxItems !== null && items.length > maxItems) {
707
+ errors.push(
708
+ `site-data.json content list "${path}" contains ${items.length} items, exceeding editorSchema maxItems ${maxItems}.`
709
+ );
710
+ }
711
+ const itemPath = `${wildcardPath(path)}[*]`;
712
+ for (const field of node.fields || []) walkSchema(appendPath(itemPath, field.key), field);
713
+ }
714
+
715
+ for (const section of editorSchema?.sections || []) walkSchema(section.path, section);
716
+ return errors;
717
+ }
718
+
719
+ function auditStaticMarkerAuthorship(code, filePath) {
720
+ if (!/\.[cm]?[jt]s$/i.test(filePath) || /\.(tsx|jsx)$/i.test(filePath)) return [];
721
+ const mutation =
722
+ /\b(?:setAttribute|setAttributeNS|toggleAttribute)\s*\(\s*['"`]data-preview-static['"`]/.exec(code) ||
723
+ /\b(?:writeFile|writeFileSync)\s*\([\s\S]{0,200}data-preview-static/.exec(code);
724
+ if (!mutation) return [];
725
+ return [
726
+ `${filePath}:${lineNumberAt(code, mutation.index || 0)} programmatically injects data-preview-static. Author each intentional static annotation directly on the smallest source element.`,
727
+ ];
728
+ }
729
+
730
+ function auditRouteOwnedMarkerCoverage({ editorSchema, pages, markers, controlOnlyPaths }) {
731
+ const errors = [];
732
+ const pagesById = new Map((pages || []).map((page) => [page.id, page]));
733
+ const sections = (editorSchema?.sections || [])
734
+ .map((section) => ({ section, canonicalPath: canonicalizeMarkerPath(section.path) }))
735
+ .filter((entry) => entry.canonicalPath);
736
+
737
+ for (const marker of markers || []) {
738
+ if (marker.kind === 'page' || marker.kind === 'item') continue;
739
+ const canonical = canonicalizeMarkerPath(marker.value);
740
+ if (!canonical) continue;
741
+ if (marker.kind === 'field' && isControlOnly(canonical, controlOnlyPaths)) continue;
742
+ const owner = sections
743
+ .filter((entry) => {
744
+ const sectionPath = wildcardPath(entry.canonicalPath);
745
+ const markerPath = wildcardPath(canonical);
746
+ return markerPath === sectionPath || markerPath.startsWith(`${sectionPath}.`) || markerPath.startsWith(`${sectionPath}[`);
747
+ })
748
+ .sort((left, right) => right.canonicalPath.length - left.canonicalPath.length)[0];
749
+ if (!owner?.section.pageKey) continue;
750
+ const page = pagesById.get(owner.section.pageKey);
751
+ if (!page) {
752
+ errors.push(
753
+ `editorSchema section "${owner.section.path}" assigns ${marker.kind} "${canonical}" to unknown manifest page "${owner.section.pageKey}".`
754
+ );
755
+ }
756
+ }
757
+ return errors;
758
+ }
759
+
505
760
  module.exports = {
506
761
  BROAD_CONTENT_CONTAINERS,
507
762
  PRIMITIVE_TYPES,
@@ -509,11 +764,17 @@ module.exports = {
509
764
  enumerateSchemaPaths,
510
765
  extractMarkers,
511
766
  auditMarkerPlacement,
767
+ auditCoupledListMarkers,
512
768
  auditActionLabelCollision,
513
769
  auditPathCoverage,
514
770
  auditSchemaUniqueness,
515
771
  auditPageCoverage,
516
772
  auditPreviewRuntime,
773
+ auditEmptyStateSource,
774
+ auditSelectOptions,
775
+ auditListBounds,
776
+ auditStaticMarkerAuthorship,
777
+ auditRouteOwnedMarkerCoverage,
517
778
  findUncoveredVisibleText,
518
779
  canonicalizeMarkerPath,
519
780
  wildcardPath,
package/src/arc/index.cjs CHANGED
@@ -14,10 +14,11 @@ const fs = require('fs');
14
14
  const path = require('path');
15
15
  const { ARC_NAME, ARC_VERSION, SCHEMA_VERSION, ENGINE_ID } = require('./version.cjs');
16
16
  const { walkFiles, isJsxFile, rel, copyFilePreserve, writeJson, readJsonSafe, findFirstExisting } = require('./fs-utils.cjs');
17
- const { scanProject, buildDependencyGraph, inferOwnerScope } = require('./scanner.cjs');
17
+ const { scanProject, buildDependencyGraph, inferOwnerScope, resolvePageFileOnDisk } = require('./scanner.cjs');
18
18
  const { analyzeFile, collectDesignSnapshot } = require('./semantic.cjs');
19
19
  const { planTransformations } = require('./planner.cjs');
20
20
  const { applyFilePlan, instrumentLayoutSource, instrumentPageKey, resolveSiteDataSpecifier, resolveSiteDataRuntimeSpecifier, rewriteRecursiveSiteDataContext, ensureJsonModule, sanitizeContradictoryMarkersInSource } = require('./transformer.cjs');
21
+ const { applyResidualPass } = require('./residual.cjs');
21
22
  const { parseSource } = require('./ast.cjs');
22
23
  const { buildSiteDataAndManifest, writeDataBank, loadExistingData, countSchemaFields } = require('./manifest.cjs');
23
24
  const { validateAstFiles, validateContracts, designPreservationScore, coverageMetrics } = require('./validator.cjs');
@@ -34,6 +35,11 @@ const {
34
35
  auditPageCoverage,
35
36
  auditPreviewRuntime,
36
37
  findUncoveredVisibleText,
38
+ auditEmptyStateSource,
39
+ auditSelectOptions,
40
+ auditListBounds,
41
+ auditStaticMarkerAuthorship,
42
+ auditRouteOwnedMarkerCoverage,
37
43
  } = require('./fivora-contract.cjs');
38
44
  const printer = require('./printer.cjs');
39
45
  const { classifyComponents } = require('./component-registry.cjs');
@@ -52,6 +58,7 @@ function parseArcOptions(raw = {}) {
52
58
  json: Boolean(raw.json),
53
59
  aiEnabled: Boolean(raw.aiEnabled),
54
60
  aiDryRun: Boolean(raw.aiDryRun),
61
+ strict: Boolean(raw.strict),
55
62
  };
56
63
  }
57
64
 
@@ -151,6 +158,8 @@ function auditFivoraContract({ profile, siteData, manifest, inventory }) {
151
158
  const placement = [];
152
159
  const collisions = [];
153
160
  const uncoveredText = [];
161
+ const emptyState = [];
162
+ const staticAuthorship = [];
154
163
  const allMarkers = [];
155
164
 
156
165
  for (const source of inventory.sources) {
@@ -159,6 +168,8 @@ function auditFivoraContract({ profile, siteData, manifest, inventory }) {
159
168
  placement.push(...auditMarkerPlacement(source.code, source.rel));
160
169
  collisions.push(...auditActionLabelCollision(source.code, source.rel));
161
170
  uncoveredText.push(...findUncoveredVisibleText(source.code, source.rel));
171
+ emptyState.push(...auditEmptyStateSource(source.code, source.rel));
172
+ staticAuthorship.push(...auditStaticMarkerAuthorship(source.code, source.rel));
162
173
  }
163
174
 
164
175
  const coverage = auditPathCoverage({
@@ -177,19 +188,34 @@ function auditFivoraContract({ profile, siteData, manifest, inventory }) {
177
188
  ...coverage.errors,
178
189
  ...placement,
179
190
  ...collisions,
191
+ ...emptyState,
192
+ ...staticAuthorship,
180
193
  ...auditSchemaUniqueness(manifest.editorSchema),
194
+ ...auditSelectOptions(manifest.editorSchema, manifest.pages),
195
+ ...auditListBounds(manifest.editorSchema, siteData.content),
181
196
  ...auditPageCoverage({
182
197
  pages: manifest.pages,
183
198
  routeFiles,
184
199
  pageMarkersByFile: inventory.pageKeysByFile,
185
200
  }),
201
+ ...auditRouteOwnedMarkerCoverage({
202
+ editorSchema: manifest.editorSchema,
203
+ pages: manifest.pages,
204
+ markers: allMarkers,
205
+ controlOnlyPaths: manifest.visualEditing?.controlOnlyPaths || [],
206
+ }),
186
207
  ...auditPreviewRuntime(inventory.sources.map((source) => source.code)),
187
208
  ];
188
209
 
189
210
  return {
190
- passed: errors.length === 0,
211
+ passed: errors.length === 0 && uncoveredText.length === 0,
191
212
  errors: [...new Set(errors)],
192
213
  uncoveredVisibleText: uncoveredText,
214
+ emptyStatePassed: emptyState.length === 0,
215
+ fieldMarkers: coverage.fieldMarkers,
216
+ listMarkers: coverage.listMarkers,
217
+ itemMarkers: coverage.itemMarkers,
218
+ pathCoverage: coverage,
193
219
  };
194
220
  }
195
221
 
@@ -230,6 +256,21 @@ function analyzeProjectFiles(profile, graph) {
230
256
  return analyses;
231
257
  }
232
258
 
259
+ function mergeDetectedPages(profile, detectedPages) {
260
+ if (!Array.isArray(detectedPages) || !detectedPages.length) return;
261
+ const scannedIds = new Set(profile.routes.map((route) => route.id));
262
+ const scannedRoutes = new Set(profile.routes.map((route) => route.route));
263
+ for (const page of detectedPages) {
264
+ if (!page || !page.id) continue;
265
+ if (scannedIds.has(page.id) || scannedRoutes.has(page.route)) continue;
266
+ const file = resolvePageFileOnDisk(profile.root, page);
267
+ if (!file) continue;
268
+ profile.routes.push({ ...page, file });
269
+ scannedIds.add(page.id);
270
+ scannedRoutes.add(page.route);
271
+ }
272
+ }
273
+
233
274
  async function runDenebArcAsync(projectDir, projectName, options = {}) {
234
275
  const opts = parseArcOptions(options);
235
276
  const runId = createRunId();
@@ -238,15 +279,7 @@ async function runDenebArcAsync(projectDir, projectName, options = {}) {
238
279
  printer.printBanner(opts.dryRun ? 'dry-run' : opts.explain ? 'explain' : 'run');
239
280
 
240
281
  const profile = scanProject(projectDir);
241
- if (opts.detectedPages && Array.isArray(opts.detectedPages) && opts.detectedPages.length) {
242
- const scannedIds = new Set(profile.routes.map((r) => r.id));
243
- for (const page of opts.detectedPages) {
244
- if (page && page.id && !scannedIds.has(page.id)) {
245
- profile.routes.push(page);
246
- }
247
- }
248
- }
249
-
282
+ mergeDetectedPages(profile, opts.detectedPages);
250
283
  printer.printProfile(profile);
251
284
  const graph = buildDependencyGraph(profile);
252
285
  const analyses = analyzeProjectFiles(profile, graph);
@@ -256,7 +289,7 @@ async function runDenebArcAsync(projectDir, projectName, options = {}) {
256
289
  0
257
290
  );
258
291
  const actionCount = analyses.reduce(
259
- (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.kind === 'url').length,
292
+ (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.operation === 'form-submit-action' || c.kind === 'url').length,
260
293
  0
261
294
  );
262
295
  printer.printScan(profile, graph, candidateCount, actionCount);
@@ -379,14 +412,7 @@ function runDenebArcSync(projectDir, projectName, options = {}) {
379
412
  printer.printBanner(opts.dryRun ? 'dry-run' : opts.explain ? 'explain' : 'run');
380
413
 
381
414
  const profile = scanProject(projectDir);
382
- if (opts.detectedPages && Array.isArray(opts.detectedPages) && opts.detectedPages.length) {
383
- const scannedIds = new Set(profile.routes.map((r) => r.id));
384
- for (const page of opts.detectedPages) {
385
- if (page && page.id && !scannedIds.has(page.id)) {
386
- profile.routes.push(page);
387
- }
388
- }
389
- }
415
+ mergeDetectedPages(profile, opts.detectedPages);
390
416
 
391
417
  printer.printProfile(profile);
392
418
  const graph = buildDependencyGraph(profile);
@@ -397,7 +423,7 @@ function runDenebArcSync(projectDir, projectName, options = {}) {
397
423
  0
398
424
  );
399
425
  const actionCount = analyses.reduce(
400
- (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.kind === 'url').length,
426
+ (n, a) => n + (a.candidates || []).filter((c) => c.operation === 'split-action-contract' || c.operation === 'form-submit-action' || c.kind === 'url').length,
401
427
  0
402
428
  );
403
429
  printer.printScan(profile, graph, candidateCount, actionCount);
@@ -545,6 +571,48 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
545
571
  }
546
572
  }
547
573
 
574
+ const residualFields = [];
575
+ const usedPaths = new Set(plan.usedPaths || []);
576
+ for (const relativeFile of profile.jsxFiles || []) {
577
+ const abs = path.join(projectDir, relativeFile);
578
+ if (!fs.existsSync(abs)) continue;
579
+ const original = fs.readFileSync(abs, 'utf8');
580
+ const analysis = analyses.find((item) => item.relativeFile === relativeFile);
581
+ const residual = applyResidualPass({
582
+ code: original,
583
+ file: relativeFile,
584
+ ownerScope: analysis?.ownerScope || inferOwnerScope(profile, graph, relativeFile),
585
+ usedPaths,
586
+ componentName: analysis?.componentMeta?.name,
587
+ role: analysis?.componentMeta?.role,
588
+ });
589
+ if (!residual.changed) continue;
590
+ try {
591
+ parseSource(residual.code, relativeFile);
592
+ } catch {
593
+ continue;
594
+ }
595
+ backupFile(projectDir, backupDir, abs);
596
+ fs.writeFileSync(abs, residual.code, 'utf8');
597
+ if (!changedFiles.includes(relativeFile)) changedFiles.push(relativeFile);
598
+ afterFiles[relativeFile] = residual.code;
599
+ appliedCount += residual.applied || 0;
600
+ residualFields.push(...(residual.fields || []));
601
+ }
602
+
603
+ for (const relativeFile of profile.jsxFiles || []) {
604
+ const abs = path.join(projectDir, relativeFile);
605
+ if (!fs.existsSync(abs)) continue;
606
+ const original = fs.readFileSync(abs, 'utf8');
607
+ const sanitized = sanitizeContradictoryMarkersInSource(original, relativeFile);
608
+ if (sanitized.updated && sanitized.code !== original) {
609
+ backupFile(projectDir, backupDir, abs);
610
+ fs.writeFileSync(abs, sanitized.code, 'utf8');
611
+ if (!changedFiles.includes(relativeFile)) changedFiles.push(relativeFile);
612
+ afterFiles[relativeFile] = sanitized.code;
613
+ }
614
+ }
615
+
548
616
  if (profile.framework === 'nextjs') {
549
617
  const before = findNextConfig(projectDir);
550
618
  if (before) backupFile(projectDir, backupDir, before.abs);
@@ -589,6 +657,8 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
589
657
  existingSiteData: existing.siteData,
590
658
  existingManifest: existing.manifest,
591
659
  boundFieldPaths: markerInventory.fieldPaths,
660
+ boundListPaths: markerInventory.listPaths,
661
+ extraFields: residualFields,
592
662
  markerRoutes: markerInventory.markerRoutes,
593
663
  });
594
664
 
@@ -611,13 +681,6 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
611
681
  const design = designPreservationScore(plan.files, afterFiles);
612
682
  const alreadyEditable = analyses.filter((a) => a.alreadyEditable).length;
613
683
  const skippedDynamic = plan.skipped.filter((s) => /dynamic|api/.test(s.reason || '')).length;
614
- const coverage = coverageMetrics({
615
- analyses,
616
- plan,
617
- appliedCount,
618
- skippedDynamic,
619
- alreadyEditable,
620
- });
621
684
 
622
685
  const fivoraAudit = auditFivoraContract({
623
686
  profile,
@@ -626,11 +689,24 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
626
689
  inventory: collectMarkerInventory(projectDir, profile, graph),
627
690
  });
628
691
 
692
+ const coverage = coverageMetrics({
693
+ analyses,
694
+ plan,
695
+ appliedCount,
696
+ skippedDynamic,
697
+ alreadyEditable,
698
+ content: dataBundle.siteData.content,
699
+ controlOnlyPaths: dataBundle.manifest.visualEditing?.controlOnlyPaths || [],
700
+ pathCoverage: fivoraAudit.pathCoverage,
701
+ uncoveredVisibleText: fivoraAudit.uncoveredVisibleText,
702
+ });
703
+
629
704
  const validation = {
630
705
  syntaxPassed,
631
706
  fivoraContractPassed: fivoraAudit.passed,
632
707
  fivoraContractErrors: fivoraAudit.errors,
633
708
  uncoveredVisibleText: fivoraAudit.uncoveredVisibleText.length,
709
+ emptyStatePassed: fivoraAudit.emptyStatePassed !== false,
634
710
  contractPassed: contracts.contractPassed,
635
711
  orphans: contracts.orphans.length,
636
712
  missingSchema: contracts.missingSchema.length,
@@ -638,9 +714,12 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
638
714
  staticAncestorCollisions: contracts.staticAncestorCollisions,
639
715
  astFailures: astResults.filter((r) => !r.passed),
640
716
  transformFailures,
717
+ designPreservation: design.score,
641
718
  };
642
719
 
643
- const criticalFailure = !syntaxPassed || contracts.actionCollisions > 0 && appliedCount === 0;
720
+ const criticalFailure = !syntaxPassed || (contracts.actionCollisions > 0 && appliedCount === 0);
721
+ const contractFailure = !fivoraAudit.passed || fivoraAudit.uncoveredVisibleText.length > 0;
722
+ const designFailure = design.score < 98 && design.total > 0;
644
723
  let outcome = 'success';
645
724
  if (criticalFailure) {
646
725
  restoreBackup(projectDir, backupDir);
@@ -653,6 +732,21 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
653
732
  }
654
733
  outcome = 'rolled-back';
655
734
  printer.printRollback(validation.astFailures[0]?.error || 'Critical validation failed');
735
+ } else if (opts.strict && (contractFailure || designFailure)) {
736
+ restoreBackup(projectDir, backupDir);
737
+ for (const created of createdDuringRun) {
738
+ try {
739
+ if (fs.existsSync(created)) fs.rmSync(created, { force: true });
740
+ } catch {
741
+ // ignore
742
+ }
743
+ }
744
+ outcome = 'rolled-back';
745
+ printer.printRollback('Strict Fivora contract failed');
746
+ } else if (contractFailure) {
747
+ outcome = 'contract-failed';
748
+ } else if (designFailure) {
749
+ outcome = 'design-regression';
656
750
  }
657
751
 
658
752
  printer.printValidation(validation, coverage, design);
@@ -686,7 +780,10 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
686
780
  validation: {
687
781
  syntaxPassed,
688
782
  contractPassed: contracts.contractPassed,
689
- visualPassed: design.score >= 95,
783
+ fivoraContractPassed: fivoraAudit.passed,
784
+ emptyStatePassed: fivoraAudit.emptyStatePassed !== false,
785
+ uncoveredVisibleText: fivoraAudit.uncoveredVisibleText.length,
786
+ visualPassed: design.score >= 98,
690
787
  idempotencyPassed: true,
691
788
  },
692
789
  outcome,
@@ -749,6 +846,8 @@ function runArcTransformations(projectDir, projectName, opts, profile, graph, an
749
846
  if (outcome === 'success') {
750
847
  printer.printSuccess();
751
848
  printer.printDeveloperNextSteps();
849
+ } else if (outcome === 'contract-failed' || outcome === 'design-regression') {
850
+ printer.printContractFailed();
752
851
  }
753
852
  return result;
754
853
  }
@@ -34,6 +34,7 @@ function recordExperience({ projectDir, profile, plan, validation, outcome, tele
34
34
  if (telemetry && telemetry !== 'off') {
35
35
  // Network upload is intentionally unimplemented. Local persistence only.
36
36
  }
37
+ const learningOutcome = honestOutcome(validation, outcome);
37
38
  const records = [];
38
39
  for (const file of plan.files || []) {
39
40
  for (const t of file.transformations || []) {
@@ -51,10 +52,12 @@ function recordExperience({ projectDir, profile, plan, validation, outcome, tele
51
52
  typecheckPassed: validation.typecheckPassed,
52
53
  buildPassed: validation.buildPassed,
53
54
  contractPassed: Boolean(validation.contractPassed),
55
+ fivoraContractPassed: Boolean(validation.fivoraContractPassed),
56
+ emptyStatePassed: validation.emptyStatePassed !== false,
54
57
  visualPassed: validation.visualPassed,
55
58
  idempotencyPassed: validation.idempotencyPassed,
56
59
  },
57
- outcome,
60
+ outcome: learningOutcome,
58
61
  anonymizedFeatures: {
59
62
  tag: t.tag,
60
63
  operation: t.operation,
@@ -85,6 +88,20 @@ function recordExperience({ projectDir, profile, plan, validation, outcome, tele
85
88
  return records;
86
89
  }
87
90
 
91
+ function honestOutcome(validation, outcome) {
92
+ if (outcome === 'rolled-back') return 'rolled-back';
93
+ const passed =
94
+ validation &&
95
+ validation.syntaxPassed !== false &&
96
+ validation.contractPassed !== false &&
97
+ validation.fivoraContractPassed !== false &&
98
+ validation.emptyStatePassed !== false &&
99
+ !(validation.uncoveredVisibleText > 0);
100
+ if (!passed) return 'failure';
101
+ if (outcome && outcome !== 'success' && outcome !== 'dry-run') return 'failure';
102
+ return outcome === 'dry-run' ? 'dry-run' : 'success';
103
+ }
104
+
88
105
  function loadFingerprintBoost(fingerprint) {
89
106
  if (!fingerprint) {
90
107
  return { boost: 0, skip: false, state: null };
@@ -118,6 +135,7 @@ function mapOperation(operation) {
118
135
  case 'extract-url':
119
136
  return 'url-extraction';
120
137
  case 'split-action-contract':
138
+ case 'form-submit-action':
121
139
  return 'contract-split';
122
140
  case 'style-bind':
123
141
  return 'style-bind';
@@ -235,6 +253,7 @@ module.exports = {
235
253
  registryArchitecture,
236
254
  redactSecrets,
237
255
  promoteState,
256
+ honestOutcome,
238
257
  localStorePath,
239
258
  fingerprintStorePath,
240
259
  RULE_STATES,