@invarn/cibuild 2.3.5 → 2.3.7

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,16 @@
8
8
  * 1. Reads `.ci/inputs/params.json`
9
9
  * ({ "screens": { "<ViewTypeName>": "<referenceFileBasename>" } });
10
10
  * reference images live at `.ci/inputs/<basename>`.
11
+ * Optional reference-provenance sidecar: `"render_locale": "<locale>"`
12
+ * plus `"provenance": { "<ViewTypeName>": { figmaNode, locale, resolved,
13
+ * exportedAt } }`. The gate arms per screen from provenance presence and
14
+ * checks METADATA only (never pixels/OCR): an absent reference is FATAL
15
+ * (status `unverifiable`, REFERENCE_ABSENT); `resolved == false` or
16
+ * `locale != render_locale` downgrade ONLY the copy channel — the screen
17
+ * renders and is judged on every non-copy channel, and the downgrade
18
+ * rides as a per-screen `copy_advisory` (`copy_unverifiable` + reason).
19
+ * Unprovenanced screens keep legacy behaviour (no NO_PROVENANCE arming
20
+ * on iOS).
11
21
  * 2. Synthesizes a throwaway SwiftPM harness package that depends on the
12
22
  * user's package and contains ONE executable target per screen, so a
13
23
  * compile failure for one screen (unknown view type, unavailable init)
@@ -317,6 +327,77 @@ function setError(entry, code, message) {
317
327
  entry.error = { code: code, message: sanitizeMessage(message) };
318
328
  }
319
329
 
330
+ // A reference the gate cannot honestly judge at all: a hard, mechanical
331
+ // metadata verdict (never pixels/OCR), distinct from render_failed so a rotten
332
+ // reference fails loud instead of producing a confident-but-false PASS.
333
+ function setUnverifiable(entry, code, message) {
334
+ entry.status = 'unverifiable';
335
+ entry.error = { code: code, message: sanitizeMessage(message) };
336
+ }
337
+
338
+ // Inlined twins of src/yaml/steps/fidelity-reference-provenance.ts (this script
339
+ // ships self-contained and cannot import it). The render-step integration tests
340
+ // exercise BOTH against the same fixtures so they cannot silently drift. The
341
+ // iOS gate arms ONLY from provenance presence — the step never refused
342
+ // unprovenanced screens, so NO_PROVENANCE (an Android-legacy arming via
343
+ // render_locale) is deliberately not wired here.
344
+
345
+ // Provenance is only "present" when the fields the gate reasons over are there.
346
+ // A partial object (e.g. a hand-dropped { figmaNode }) is treated as no
347
+ // provenance — verifiable is earned only through the full export path.
348
+ function hasReferenceProvenance(provenance) {
349
+ return (
350
+ provenance != null &&
351
+ typeof provenance === 'object' &&
352
+ typeof provenance.locale === 'string' &&
353
+ typeof provenance.resolved === 'boolean'
354
+ );
355
+ }
356
+
357
+ // Assesses one provenanced screen's reference. Returns { fault, copyAdvisory }:
358
+ // a fatal fault makes the screen unverifiable; a copy advisory downgrades only
359
+ // the copy channel (the screen still renders and is judged on every non-copy
360
+ // channel). An absent reference wins over a downgrade — there is nothing to
361
+ // judge against.
362
+ function assessReferenceProvenance(screen, provenance, renderLocale, referenceExists) {
363
+ if (!referenceExists) {
364
+ return {
365
+ fault: {
366
+ code: 'REFERENCE_ABSENT',
367
+ message: 'reference image for ' + screen + ' is absent',
368
+ },
369
+ copyAdvisory: null,
370
+ };
371
+ }
372
+ if (provenance.resolved === false) {
373
+ return {
374
+ fault: null,
375
+ copyAdvisory: {
376
+ code: 'copy_unverifiable',
377
+ reason: 'unresolved_placeholders',
378
+ message:
379
+ 'reference for ' + screen + ' carries unresolved copy placeholders ' +
380
+ '(resolved=false); copy comparisons are skipped — non-copy judging ' +
381
+ 'still holds',
382
+ },
383
+ };
384
+ }
385
+ if (renderLocale !== null && provenance.locale !== renderLocale) {
386
+ return {
387
+ fault: null,
388
+ copyAdvisory: {
389
+ code: 'copy_unverifiable',
390
+ reason: 'locale_mismatch',
391
+ message:
392
+ 'reference for ' + screen + ' is locale "' + provenance.locale +
393
+ '" but the render locale is "' + renderLocale + '"; copy comparisons ' +
394
+ 'are skipped — non-copy judging still holds',
395
+ },
396
+ };
397
+ }
398
+ return { fault: null, copyAdvisory: null };
399
+ }
400
+
320
401
  function outputTail(result) {
321
402
  var combined = ((result.stdout || '') + '\n' + (result.stderr || '')).trim();
322
403
  if (combined.length > 2000) combined = '...' + combined.slice(-2000);
@@ -488,12 +569,25 @@ function main() {
488
569
  return 1;
489
570
  }
490
571
 
572
+ // Reference-provenance sidecar (optional): per-screen export provenance plus
573
+ // the run's declared render locale. The gate arms per screen from provenance
574
+ // presence; the locale is only needed for the locale comparison itself.
575
+ var renderLocale =
576
+ typeof params.render_locale === 'string' && params.render_locale !== ''
577
+ ? params.render_locale
578
+ : null;
579
+ var provenanceMap =
580
+ params.provenance && typeof params.provenance === 'object' && !Array.isArray(params.provenance)
581
+ ? params.provenance
582
+ : {};
583
+
491
584
  var screens = Object.keys(params.screens);
492
585
  currentEntries = screens.map(function (screen) {
493
586
  return {
494
587
  screen: screen,
495
588
  status: 'render_failed',
496
589
  error: null,
590
+ copy_advisory: null,
497
591
  reference_image_path: null,
498
592
  rendered_image_path: null,
499
593
  };
@@ -536,7 +630,39 @@ function main() {
536
630
  } catch (statError) {
537
631
  sourceStat = null;
538
632
  }
539
- if (!sourceStat || !sourceStat.isFile()) {
633
+ var referenceExists = !!(sourceStat && sourceStat.isFile());
634
+
635
+ var screenProvenance = provenanceMap[entry.screen];
636
+ if (hasReferenceProvenance(screenProvenance)) {
637
+ // Provenance assessment (armed by provenance presence): an absent
638
+ // reference is FATAL (unverifiable — never a silent PASS); unresolved
639
+ // placeholders and locale mismatches downgrade ONLY the copy channel —
640
+ // the screen renders and is judged on every non-copy channel, and the
641
+ // downgrade rides in the result as a structured copy_advisory.
642
+ var assessment = assessReferenceProvenance(
643
+ entry.screen,
644
+ screenProvenance,
645
+ renderLocale,
646
+ referenceExists
647
+ );
648
+ if (assessment.fault) {
649
+ setUnverifiable(entry, assessment.fault.code, assessment.fault.message);
650
+ return;
651
+ }
652
+ if (assessment.copyAdvisory) {
653
+ entry.copy_advisory = {
654
+ code: assessment.copyAdvisory.code,
655
+ reason: assessment.copyAdvisory.reason,
656
+ message: sanitizeMessage(assessment.copyAdvisory.message),
657
+ };
658
+ log(
659
+ entry.screen + ': copy channel downgraded (' +
660
+ assessment.copyAdvisory.reason + '), non-copy judging proceeds'
661
+ );
662
+ }
663
+ } else if (!referenceExists) {
664
+ // Legacy (no provenance for this screen): an absent reference stays a
665
+ // render_failed REFERENCE_MISSING, exactly as before.
540
666
  setError(
541
667
  entry,
542
668
  'REFERENCE_MISSING',
@@ -529,6 +529,7 @@ describe('render script runtime', () => {
529
529
  screen: 'HomeView',
530
530
  status: 'rendered',
531
531
  error: null,
532
+ copy_advisory: null,
532
533
  reference_image_path: 'ui-fidelity/references/HomeView.png',
533
534
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
534
535
  source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
@@ -537,6 +538,7 @@ describe('render script runtime', () => {
537
538
  screen: 'SettingsView',
538
539
  status: 'rendered',
539
540
  error: null,
541
+ copy_advisory: null,
540
542
  reference_image_path: 'ui-fidelity/references/SettingsView.png',
541
543
  rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
542
544
  source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
@@ -927,6 +929,7 @@ describe('render script runtime (package_source: repo)', () => {
927
929
  screen: 'HomeView',
928
930
  status: 'rendered',
929
931
  error: null,
932
+ copy_advisory: null,
930
933
  reference_image_path: 'ui-fidelity/references/HomeView.png',
931
934
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
932
935
  source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
@@ -1107,6 +1110,7 @@ describe('render script runtime (package_source: inputs)', () => {
1107
1110
  screen: 'HomeView',
1108
1111
  status: 'rendered',
1109
1112
  error: null,
1113
+ copy_advisory: null,
1110
1114
  reference_image_path: 'ui-fidelity/references/HomeView.png',
1111
1115
  rendered_image_path: 'ui-fidelity/rendered/HomeView.png',
1112
1116
  source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
@@ -1121,6 +1125,7 @@ describe('render script runtime (package_source: inputs)', () => {
1121
1125
  screen: 'SettingsView',
1122
1126
  status: 'rendered',
1123
1127
  error: null,
1128
+ copy_advisory: null,
1124
1129
  reference_image_path: 'ui-fidelity/references/SettingsView.png',
1125
1130
  rendered_image_path: 'ui-fidelity/rendered/SettingsView.png',
1126
1131
  source_digest: expect.stringMatching(/^[0-9a-f]{64}$/),
@@ -1580,4 +1585,121 @@ describe('reference_source: repo', () => {
1580
1585
  expect(ghost?.error?.code).toBe('REFERENCE_MISSING');
1581
1586
  });
1582
1587
  });
1588
+ describe('render script runtime — reference provenance', () => {
1589
+ const GOOD_PROV = {
1590
+ figmaNode: '12:345',
1591
+ locale: 'en',
1592
+ resolved: true,
1593
+ exportedAt: '2026-06-18T10:00:00Z',
1594
+ };
1595
+ test('a placeholder reference (resolved=false) renders and downgrades only the copy channel', async () => {
1596
+ const project = makeProject({
1597
+ screens: { HomeView: 'home.png' },
1598
+ render_locale: 'en',
1599
+ provenance: { HomeView: { ...GOOD_PROV, resolved: false } },
1600
+ });
1601
+ writeReference(project, 'home.png');
1602
+ const run = runScript(project, await buildScript(project));
1603
+ expect(run.status).toBe(0);
1604
+ const home = readResult(project).screens[0];
1605
+ expect(home.status).toBe('rendered');
1606
+ expect(home.error).toBeNull();
1607
+ expect(home.copy_advisory?.code).toBe('copy_unverifiable');
1608
+ expect(home.copy_advisory?.reason).toBe('unresolved_placeholders');
1609
+ });
1610
+ test('the placeholder downgrade is identical without a render_locale (same input, same verdict)', async () => {
1611
+ const project = makeProject({
1612
+ screens: { HomeView: 'home.png' },
1613
+ provenance: { HomeView: { ...GOOD_PROV, resolved: false } },
1614
+ });
1615
+ writeReference(project, 'home.png');
1616
+ const run = runScript(project, await buildScript(project));
1617
+ expect(run.status).toBe(0);
1618
+ const home = readResult(project).screens[0];
1619
+ expect(home.status).toBe('rendered');
1620
+ expect(home.copy_advisory?.reason).toBe('unresolved_placeholders');
1621
+ });
1622
+ test('an Italian reference against an English render downgrades the copy channel', async () => {
1623
+ const project = makeProject({
1624
+ screens: { HomeView: 'home.png' },
1625
+ render_locale: 'en',
1626
+ provenance: { HomeView: { ...GOOD_PROV, locale: 'it' } },
1627
+ });
1628
+ writeReference(project, 'home.png');
1629
+ const run = runScript(project, await buildScript(project));
1630
+ expect(run.status).toBe(0);
1631
+ const home = readResult(project).screens[0];
1632
+ expect(home.status).toBe('rendered');
1633
+ expect(home.copy_advisory?.reason).toBe('locale_mismatch');
1634
+ });
1635
+ test('locale comparison requires a declared render_locale — its absence skips only that', async () => {
1636
+ const project = makeProject({
1637
+ screens: { HomeView: 'home.png' },
1638
+ provenance: { HomeView: { ...GOOD_PROV, locale: 'it' } },
1639
+ });
1640
+ writeReference(project, 'home.png');
1641
+ const run = runScript(project, await buildScript(project));
1642
+ expect(run.status).toBe(0);
1643
+ const home = readResult(project).screens[0];
1644
+ expect(home.status).toBe('rendered');
1645
+ expect(home.copy_advisory ?? null).toBeNull();
1646
+ });
1647
+ test('an absent reference with provenance is fatal (REFERENCE_ABSENT, unverifiable)', async () => {
1648
+ const project = makeProject({
1649
+ screens: { HomeView: 'home.png' },
1650
+ provenance: { HomeView: GOOD_PROV },
1651
+ });
1652
+ // No writeReference — the file is absent.
1653
+ const run = runScript(project, await buildScript(project));
1654
+ expect(run.status).not.toBe(0);
1655
+ const home = readResult(project).screens[0];
1656
+ expect(home.status).toBe('unverifiable');
1657
+ expect(home.error?.code).toBe('REFERENCE_ABSENT');
1658
+ });
1659
+ test('a clean provenanced reference renders with no copy advisory', async () => {
1660
+ const project = makeProject({
1661
+ screens: { HomeView: 'home.png' },
1662
+ render_locale: 'en',
1663
+ provenance: { HomeView: GOOD_PROV },
1664
+ });
1665
+ writeReference(project, 'home.png');
1666
+ const run = runScript(project, await buildScript(project));
1667
+ expect(run.status).toBe(0);
1668
+ const home = readResult(project).screens[0];
1669
+ expect(home.status).toBe('rendered');
1670
+ expect(home.copy_advisory ?? null).toBeNull();
1671
+ });
1672
+ test('an unprovenanced screen keeps its legacy behaviour even with a render_locale (no NO_PROVENANCE arming on iOS)', async () => {
1673
+ // NO_PROVENANCE hard-arming is Android-legacy only; the iOS step never
1674
+ // refused unprovenanced screens and must not start now.
1675
+ const project = makeProject({
1676
+ screens: { HomeView: 'home.png' },
1677
+ render_locale: 'en',
1678
+ provenance: {},
1679
+ });
1680
+ writeReference(project, 'home.png');
1681
+ const run = runScript(project, await buildScript(project));
1682
+ expect(run.status).toBe(0);
1683
+ const home = readResult(project).screens[0];
1684
+ expect(home.status).toBe('rendered');
1685
+ expect(home.copy_advisory ?? null).toBeNull();
1686
+ });
1687
+ test('independent verdicts: a clean sibling carries no advisory while the placeholder state does', async () => {
1688
+ const project = makeProject({
1689
+ screens: { HomeView: 'home.png', SettingsView: 'settings.png' },
1690
+ provenance: {
1691
+ HomeView: GOOD_PROV,
1692
+ SettingsView: { ...GOOD_PROV, resolved: false },
1693
+ },
1694
+ });
1695
+ writeReference(project, 'home.png');
1696
+ writeReference(project, 'settings.png');
1697
+ const run = runScript(project, await buildScript(project));
1698
+ expect(run.status).toBe(0);
1699
+ const [home, settings] = readResult(project).screens;
1700
+ expect(home.copy_advisory ?? null).toBeNull();
1701
+ expect(settings.status).toBe('rendered');
1702
+ expect(settings.copy_advisory?.reason).toBe('unresolved_placeholders');
1703
+ });
1704
+ });
1583
1705
  //# sourceMappingURL=ui-fidelity-render.test.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invarn/cibuild",
3
- "version": "2.3.5",
3
+ "version": "2.3.7",
4
4
  "description": "CI Build CLI — local pipeline orchestration and validation",
5
5
  "type": "module",
6
6
  "main": "dist/cli.cjs",