@deneb-ui/cli 2.0.51 → 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.
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);
@@ -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);
@@ -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 };
@@ -236,6 +253,7 @@ module.exports = {
236
253
  registryArchitecture,
237
254
  redactSecrets,
238
255
  promoteState,
256
+ honestOutcome,
239
257
  localStorePath,
240
258
  fingerprintStorePath,
241
259
  RULE_STATES,
@@ -11,6 +11,43 @@ const {
11
11
  wildcardPath,
12
12
  } = require('./fivora-contract.cjs');
13
13
 
14
+ function pruneUnboundLeaves(content, isBound) {
15
+ function walk(node, path) {
16
+ if (Array.isArray(node)) {
17
+ node.forEach((item, index) => {
18
+ if (item && typeof item === 'object') walk(item, `${path}[${index}]`);
19
+ });
20
+ return;
21
+ }
22
+ if (!isPlainObject(node)) return;
23
+ for (const key of Object.keys(node)) {
24
+ const next = path ? `${path}.${key}` : key;
25
+ const value = node[key];
26
+ if (Array.isArray(value)) {
27
+ const listBound =
28
+ isBound(next) || isBound(`${next}[0]`) || isBound(`${next}[*]`);
29
+ if (!listBound && !isAllowedControlOnly(next)) {
30
+ delete node[key];
31
+ continue;
32
+ }
33
+ walk(value, next);
34
+ continue;
35
+ }
36
+ if (isPlainObject(value)) {
37
+ walk(value, next);
38
+ if (Object.keys(value).length === 0 && !isBound(next) && !isAllowedControlOnly(next)) {
39
+ delete node[key];
40
+ }
41
+ continue;
42
+ }
43
+ if (!isBound(next) && !isAllowedControlOnly(next)) {
44
+ delete node[key];
45
+ }
46
+ }
47
+ }
48
+ walk(content, '');
49
+ }
50
+
14
51
  function setDeep(target, pathStr, value) {
15
52
  const parts = String(pathStr).split('.').filter(Boolean);
16
53
  let curr = target;
@@ -254,11 +291,32 @@ function baseContent(projectName, routes) {
254
291
 
255
292
  /**
256
293
  * Fivora strict mode requires every concrete site-data field to either render a
257
- * data-preview-field-path marker or be declared control-only. ARC always emits
258
- * a merchant-facing baseline (business phone, logo, nav labels) that an
259
- * arbitrary project may not render, so every unbound path is declared here
260
- * instead of being silently shipped as an ingest failure.
294
+ * data-preview-field-path marker or be declared control-only. Control-only is
295
+ * reserved for ids, internal flags, and the unrendered merchant baseline — not
296
+ * as a dump for fields ARC planned but failed to bind.
261
297
  */
298
+ const BASELINE_CONTROL_ONLY = /^(common\.(websiteTitle|shortDescription|logoUrl|headerCtaLabel|copyright|navLabels(\.[^.]+)?|business(\.[^.]+)*))$/;
299
+ const SYSTEM_FIELD = /(^|\.)(id|key|slug|internalId|sku|_id)$/i;
300
+
301
+ function slimListItems(items, itemFields) {
302
+ const keys = (itemFields || []).map((field) => field.key).filter(Boolean);
303
+ if (!keys.length || !Array.isArray(items)) return items || [];
304
+ return items.map((item) => {
305
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
306
+ const out = {};
307
+ for (const key of keys) {
308
+ if (!(key in item)) continue;
309
+ if (Array.isArray(item[key])) continue;
310
+ out[key] = item[key];
311
+ }
312
+ return Object.keys(out).length ? out : item;
313
+ });
314
+ }
315
+
316
+ function isAllowedControlOnly(path) {
317
+ return BASELINE_CONTROL_ONLY.test(path) || SYSTEM_FIELD.test(path);
318
+ }
319
+
262
320
  function computeControlOnlyPaths(content, boundPaths, declared = []) {
263
321
  const inventory = enumerateContentPaths(content);
264
322
  const bound = new Set([...(boundPaths || [])].map(wildcardPath));
@@ -266,9 +324,7 @@ function computeControlOnlyPaths(content, boundPaths, declared = []) {
266
324
 
267
325
  for (const declaredPath of declared) {
268
326
  const canonical = canonicalizeMarkerPath(declaredPath);
269
- if (!canonical) continue;
270
- // Drop stale declarations that no longer exist in content; the platform
271
- // rejects controlOnlyPaths entries it cannot resolve.
327
+ if (!canonical || !isAllowedControlOnly(canonical)) continue;
272
328
  const known = [...inventory.fieldPatterns, ...inventory.concreteFields].some(
273
329
  (path) => wildcardPath(path) === wildcardPath(canonical)
274
330
  );
@@ -276,7 +332,8 @@ function computeControlOnlyPaths(content, boundPaths, declared = []) {
276
332
  }
277
333
 
278
334
  for (const path of inventory.concreteFields) {
279
- if (!bound.has(wildcardPath(path))) controlOnly.add(path);
335
+ if (bound.has(wildcardPath(path))) continue;
336
+ if (isAllowedControlOnly(path)) controlOnly.add(path);
280
337
  }
281
338
 
282
339
  return [...controlOnly].sort();
@@ -314,6 +371,8 @@ function buildSiteDataAndManifest({
314
371
  existingSiteData,
315
372
  existingManifest,
316
373
  boundFieldPaths = [],
374
+ boundListPaths = [],
375
+ extraFields = [],
317
376
  markerRoutes = {},
318
377
  }) {
319
378
  const routes = (profile.routes && profile.routes.length ? profile.routes : [{ id: 'home', label: 'Home', route: '/', required: true }])
@@ -329,7 +388,15 @@ function buildSiteDataAndManifest({
329
388
  // Recipes may hint schema shape, but must not dump another storefront's content
330
389
  // into an unrelated project. Extracted values always win.
331
390
 
332
- const plannedFields = collectFieldsFromPlan(plan);
391
+ const plannedFields = [...collectFieldsFromPlan(plan), ...(extraFields || [])];
392
+ const boundSet = new Set(
393
+ [...(boundFieldPaths || []), ...(boundListPaths || [])].map((path) => wildcardPath(path))
394
+ );
395
+ function isBound(path) {
396
+ if (!path) return false;
397
+ const wild = wildcardPath(path);
398
+ return boundSet.has(wild) || [...boundSet].some((marker) => wildcardPath(marker) === wild);
399
+ }
333
400
 
334
401
  // A second `init` run re-reads already-transformed sources, where the former
335
402
  // literals are now site-data expressions and therefore no longer detectable.
@@ -373,8 +440,15 @@ function buildSiteDataAndManifest({
373
440
 
374
441
  for (const field of plannedFields) {
375
442
  if (field.type === 'list') {
376
- setDeep(content, field.path, field.value ?? []);
377
- upsertSchemaList(editorSections, field.path, field.itemFields, field.value);
443
+ if (!isBound(field.path) && !isBound(`${field.path}[0]`) && !isBound(`${field.path}[*]`)) continue;
444
+ const value = slimListItems(field.value ?? [], field.itemFields);
445
+ setDeep(content, field.path, value);
446
+ upsertSchemaList(editorSections, field.path, field.itemFields, value);
447
+ continue;
448
+ }
449
+ if (!isBound(field.path) && !isAllowedControlOnly(field.path)) continue;
450
+ if (!isBound(field.path) && isAllowedControlOnly(field.path)) {
451
+ setDeep(content, field.path, field.value ?? '');
378
452
  continue;
379
453
  }
380
454
  setDeep(content, field.path, field.value ?? '');
@@ -387,12 +461,15 @@ function buildSiteDataAndManifest({
387
461
  // Planned extracted values should win over empty recipe defaults when existing is absent,
388
462
  // but never erase merchant-configured existing values.
389
463
  for (const field of plannedFields) {
464
+ if (!isBound(field.path) && field.type !== 'list' && !isAllowedControlOnly(field.path)) continue;
390
465
  const existingVal = getDeep(existingSiteData.content, field.path);
391
466
  if (existingVal !== undefined) setDeep(content, field.path, existingVal);
392
467
  else if (field.value !== undefined) setDeep(content, field.path, field.value);
393
468
  }
394
469
  }
395
470
 
471
+ pruneUnboundLeaves(content, isBound);
472
+
396
473
  const siteData = {
397
474
  denebVersion: existingSiteData?.denebVersion || undefined,
398
475
  arcVersion: ARC_VERSION,
@@ -503,4 +580,5 @@ module.exports = {
503
580
  upsertSchemaList,
504
581
  enrichSchemasFromContent,
505
582
  isListActionCtaKey,
583
+ isAllowedControlOnly,
506
584
  };
@@ -189,7 +189,8 @@ function planTransformations({ profile, analyses, recipe }) {
189
189
  type: typeof sample[k] === 'number' ? 'number' : /image|photo|avatar/i.test(k) ? 'image' : /url|link/i.test(k) ? 'url' : 'text',
190
190
  }));
191
191
  }
192
- if (!transform.itemFields.length || !extra.objectItems || extra.hasComponentRef) transform.decision = 'skip';
192
+ transform.hasComponentRef = Boolean(extra.hasComponentRef);
193
+ if (!transform.itemFields.length || !extra.objectItems) transform.decision = 'skip';
193
194
  } else {
194
195
  transform.field = buildFieldPath({
195
196
  scope,
@@ -84,9 +84,12 @@ function printValidation(validation, coverage, design) {
84
84
  else warn('AST validation reported parse issues');
85
85
  if (validation.contractPassed) ok('editable contracts');
86
86
  else warn('editable contract issues detected');
87
+ if (validation.fivoraContractPassed) ok('Fivora strict contract');
88
+ else warn('Fivora strict contract failed — package is not upload-ready');
87
89
  ok('manifest');
88
90
  if (validation.idempotencyPassed !== false) ok('idempotency');
89
91
  console.log('');
92
+ console.log(` Visual coverage: ${coverage.visualCoverage != null ? coverage.visualCoverage : coverage.editableCoverage}%`);
90
93
  console.log(` Editable coverage: ${coverage.editableCoverage}%`);
91
94
  console.log(` Design preservation: ${design.score}%`);
92
95
  }
@@ -135,6 +138,11 @@ function printSuccess() {
135
138
  console.log(`\n${C.green}${C.bold}Deneb ARC completed successfully.${C.reset}\n`);
136
139
  }
137
140
 
141
+ function printContractFailed() {
142
+ console.log(`\n${C.yellow}${C.bold}Deneb ARC finished with Fivora contract findings.${C.reset}`);
143
+ console.log(`${C.dim}Files were kept for debugging. This package is not upload-ready. Re-run with --strict to roll back.${C.reset}\n`);
144
+ }
145
+
138
146
  function printUncoveredText(findings = []) {
139
147
  if (!findings.length) return;
140
148
  warn(`${findings.length} visible text node(s) still uncovered — run deneb validate . before packaging`);
@@ -229,6 +237,7 @@ module.exports = {
229
237
  printDryRun,
230
238
  printError,
231
239
  printSuccess,
240
+ printContractFailed,
232
241
  printUncoveredText,
233
242
  printDeveloperNextSteps,
234
243
  printRollback,