@jevitate/cli 0.10.0 → 0.12.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 (141) hide show
  1. package/dist/approval-provenance.d.ts +6 -0
  2. package/dist/approval-provenance.js +9 -1
  3. package/dist/approval-provenance.js.map +1 -1
  4. package/dist/approved-demo.d.ts +86 -0
  5. package/dist/approved-demo.js +184 -0
  6. package/dist/approved-demo.js.map +1 -0
  7. package/dist/author-journey-cli.js +2 -1
  8. package/dist/author-journey-cli.js.map +1 -1
  9. package/dist/bin.js +16525 -13523
  10. package/dist/build-info.generated.js +2 -2
  11. package/dist/campaign-api.d.ts +15 -0
  12. package/dist/campaign-api.js +67 -6
  13. package/dist/campaign-api.js.map +1 -1
  14. package/dist/campaign-cli.js +7 -2
  15. package/dist/campaign-cli.js.map +1 -1
  16. package/dist/campaign-preflight.d.ts +76 -0
  17. package/dist/campaign-preflight.js +117 -0
  18. package/dist/campaign-preflight.js.map +1 -0
  19. package/dist/catalog-bundle-api.d.ts +15 -5
  20. package/dist/catalog-bundle-api.js +117 -14
  21. package/dist/catalog-bundle-api.js.map +1 -1
  22. package/dist/catalog-bundle-findings.d.ts +10 -0
  23. package/dist/catalog-bundle-findings.js +75 -1
  24. package/dist/catalog-bundle-findings.js.map +1 -1
  25. package/dist/catalog-bundle.d.ts +111 -7
  26. package/dist/catalog-bundle.js +208 -9
  27. package/dist/catalog-bundle.js.map +1 -1
  28. package/dist/catalog-cli.js +3 -2
  29. package/dist/catalog-cli.js.map +1 -1
  30. package/dist/check-api.js +20 -2
  31. package/dist/check-api.js.map +1 -1
  32. package/dist/check-cli.d.ts +2 -0
  33. package/dist/check-cli.js +25 -0
  34. package/dist/check-cli.js.map +1 -1
  35. package/dist/check-execute.d.ts +6 -0
  36. package/dist/check-execute.js +1 -0
  37. package/dist/check-execute.js.map +1 -1
  38. package/dist/check-types.d.ts +7 -0
  39. package/dist/check-types.js.map +1 -1
  40. package/dist/cli-output.js +15 -1
  41. package/dist/cli-output.js.map +1 -1
  42. package/dist/cold-start-report.d.ts +32 -0
  43. package/dist/cold-start-report.js +31 -0
  44. package/dist/cold-start-report.js.map +1 -0
  45. package/dist/defect-evidence.js +10 -5
  46. package/dist/defect-evidence.js.map +1 -1
  47. package/dist/demo-aspect-api.d.ts +11 -0
  48. package/dist/demo-aspect-api.js +27 -2
  49. package/dist/demo-aspect-api.js.map +1 -1
  50. package/dist/demo-aspect-cli.js +7 -1
  51. package/dist/demo-aspect-cli.js.map +1 -1
  52. package/dist/demo-capture.d.ts +79 -2
  53. package/dist/demo-capture.js +504 -12
  54. package/dist/demo-capture.js.map +1 -1
  55. package/dist/environments.d.ts +8 -1
  56. package/dist/environments.js +14 -3
  57. package/dist/environments.js.map +1 -1
  58. package/dist/exit-codes.js +1 -0
  59. package/dist/exit-codes.js.map +1 -1
  60. package/dist/explore-adversarial.js +2 -2
  61. package/dist/explore-adversarial.js.map +1 -1
  62. package/dist/explore-cli.js +3 -2
  63. package/dist/explore-cli.js.map +1 -1
  64. package/dist/explore-goal.js +4 -3
  65. package/dist/explore-goal.js.map +1 -1
  66. package/dist/explore-shared.d.ts +11 -0
  67. package/dist/explore-shared.js +9 -0
  68. package/dist/explore-shared.js.map +1 -1
  69. package/dist/findings-filing.js +7 -3
  70. package/dist/findings-filing.js.map +1 -1
  71. package/dist/github-issue-filer.js +15 -9
  72. package/dist/github-issue-filer.js.map +1 -1
  73. package/dist/journeeze-api.d.ts +8 -2
  74. package/dist/journeeze-api.js +105 -19
  75. package/dist/journeeze-api.js.map +1 -1
  76. package/dist/journey-anchor-api.d.ts +37 -0
  77. package/dist/journey-anchor-api.js +58 -0
  78. package/dist/journey-anchor-api.js.map +1 -0
  79. package/dist/journey-anchor-cli.d.ts +17 -0
  80. package/dist/journey-anchor-cli.js +104 -0
  81. package/dist/journey-anchor-cli.js.map +1 -0
  82. package/dist/journey-api.d.ts +38 -1
  83. package/dist/journey-api.js +107 -10
  84. package/dist/journey-api.js.map +1 -1
  85. package/dist/journey-cli.d.ts +0 -1
  86. package/dist/journey-cli.js +124 -22
  87. package/dist/journey-cli.js.map +1 -1
  88. package/dist/journey-demo-api.d.ts +29 -0
  89. package/dist/journey-demo-api.js +29 -7
  90. package/dist/journey-demo-api.js.map +1 -1
  91. package/dist/journey-labels-migrate-api.d.ts +100 -0
  92. package/dist/journey-labels-migrate-api.js +194 -0
  93. package/dist/journey-labels-migrate-api.js.map +1 -0
  94. package/dist/journey-lint-sarif.js +6 -0
  95. package/dist/journey-lint-sarif.js.map +1 -1
  96. package/dist/journey-network-checks.d.ts +11 -0
  97. package/dist/journey-network-checks.js +36 -13
  98. package/dist/journey-network-checks.js.map +1 -1
  99. package/dist/journey-review.d.ts +12 -2
  100. package/dist/journey-review.js +522 -108
  101. package/dist/journey-review.js.map +1 -1
  102. package/dist/load-api.js +4 -2
  103. package/dist/load-api.js.map +1 -1
  104. package/dist/mcp-api.d.ts +3 -1
  105. package/dist/mcp-api.js +21 -2
  106. package/dist/mcp-api.js.map +1 -1
  107. package/dist/mcp-cli-tools.d.ts +1 -0
  108. package/dist/mcp-cli-tools.js +32 -9
  109. package/dist/mcp-cli-tools.js.map +1 -1
  110. package/dist/openrouter-call.d.ts +23 -10
  111. package/dist/openrouter-call.js +14 -11
  112. package/dist/openrouter-call.js.map +1 -1
  113. package/dist/png-pixels.d.ts +12 -0
  114. package/dist/png-pixels.js +96 -0
  115. package/dist/png-pixels.js.map +1 -0
  116. package/dist/regression-api.js +8 -5
  117. package/dist/regression-api.js.map +1 -1
  118. package/dist/review-render-options.d.ts +17 -0
  119. package/dist/review-render-options.js +17 -0
  120. package/dist/review-render-options.js.map +1 -0
  121. package/dist/server-log-verify.js +2 -1
  122. package/dist/server-log-verify.js.map +1 -1
  123. package/dist/signal-triage.js +7 -3
  124. package/dist/signal-triage.js.map +1 -1
  125. package/dist/source-run-api.js +2 -2
  126. package/dist/source-run-api.js.map +1 -1
  127. package/dist/ux-usability.js +5 -2
  128. package/dist/ux-usability.js.map +1 -1
  129. package/dist/verify-fix-api.d.ts +29 -0
  130. package/dist/verify-fix-api.js +45 -2
  131. package/dist/verify-fix-api.js.map +1 -1
  132. package/dist/verify-fix-cli.js +4 -0
  133. package/dist/verify-fix-cli.js.map +1 -1
  134. package/dist/zip-store.d.ts +15 -0
  135. package/dist/zip-store.js +93 -0
  136. package/dist/zip-store.js.map +1 -0
  137. package/package.json +14 -14
  138. package/skills/jevitate-demo/SKILL.md +4 -1
  139. package/skills/jevitate-run-journey/SKILL.md +4 -0
  140. package/skills/jevitate-test-campaign/SKILL.md +13 -0
  141. package/skills/jevitate-verify-fix/SKILL.md +5 -0
@@ -18,23 +18,30 @@ import { CatalogInputError, type Catalog } from "./catalog.js";
18
18
  * - checks: one per check item that ran, keyed (target.id, commit, runId); a baseline only from a
19
19
  * clean run whose `journeyHash` is the exported Journey's approved hash.
20
20
  * - findings: whatever the d464c source hands in (`CatalogBundleFinding`), guarded; none by default.
21
- * - demos and media: NONE in 0.10 — a demo must carry the jz-mask-v1 + synthetic-data attestation,
22
- * which jevitate cannot make yet, and the contract says a producer that cannot meet it exports no
23
- * media. So no `demos`, no `files`.
21
+ * - demos and media (#471): one `demos[]` entry per exported, APPROVED Journey that has an
22
+ * approved-demo record (approved-demo.ts: written by `demo approve` only on a `synthetic: true`
23
+ * environment, from a jz-mask-v1 render), whose `renderedFrom` is the Journey's current approved
24
+ * hash. Its `privacy` is the record's attestation. Media is only what the record proved masked
25
+ * and the API re-verified (present, same sha256, the right file type): `media/<id>/step-NN.png`,
26
+ * `media/<id>/demo.webm` and `demo.vtt` (subtitles only with the video), each listed in `files[]`.
27
+ * Over a §10 limit (2 MiB / 50 MiB / 256 KiB per file, 512 MiB and 4,000 files per bundle), a
28
+ * file is left out with a warning, never sent. A demo whose caption breaks §7's text rules is
29
+ * left out whole (its subtitles carry the same text). A draft demo has no record: never exported.
24
30
  * - `generatedAt` is left out on purpose: the same catalog gives the same bytes, so the upload's
25
31
  * `Idempotency-Key` (sha256 of bundle.json) dedupes a re-publish.
26
32
  *
27
33
  * Failure policy: personal data or a credential typed in clear anywhere, or a job the bundle cannot
28
34
  * carry (a structural reference problem, text that is not one line), REFUSES the export
29
- * (`CatalogBundleInputError`, exit 64) — nothing is guessed or silently rewritten. A Journey or a
30
- * check the bundle cannot carry is LEFT OUT with a warning (nothing else references it in a way the
35
+ * (`CatalogBundleInputError`, exit 64) — nothing is guessed or silently rewritten. So does a
36
+ * PROMOTED Journey the bundle cannot carry (#478: never a silent omission). A draft Journey, or a
37
+ * check the bundle cannot carry, is LEFT OUT with a warning (nothing else references it in a way the
31
38
  * reader would refuse). A persona key the bundle cannot carry drops that key and the persona's
32
39
  * approval (its hash could not recompute) with a warning.
33
40
  */
34
41
  export declare const BUNDLE_KIND: "journeeze.catalog-bundle";
35
42
  export declare const BUNDLE_VERSION: 1;
36
- /** The schema minor this producer is built against (contract 61f8c92). */
37
- export declare const BUNDLE_MINOR: 0;
43
+ /** The schema minor this producer is built against (minor 1, journeeze-saas #173: optional `finding.locator` and `check.appVersion`). */
44
+ export declare const BUNDLE_MINOR: 1;
38
45
  export interface BundleApproval {
39
46
  readonly contentHash: string;
40
47
  readonly at: string;
@@ -90,6 +97,8 @@ export interface BundleCheck {
90
97
  readonly kind?: BundleCheckTargetKind;
91
98
  };
92
99
  readonly commit: string;
100
+ /** #479 (spec minor 1 §2): the app's release/version label the check ran against; `commit` stays the build identity. */
101
+ readonly appVersion?: string;
93
102
  readonly at: string;
94
103
  readonly runId?: string;
95
104
  readonly journey?: string;
@@ -101,6 +110,18 @@ export interface BundleCheck {
101
110
  readonly durationMs?: number;
102
111
  readonly baseline?: BundleBaseline;
103
112
  }
113
+ export interface BundleLocator {
114
+ /** The element in words, e.g. `the "Save" button`. One printable line, 1-200, no URL or personal data. */
115
+ readonly element: string;
116
+ /** The attribute the fix adds (the convention's first). */
117
+ readonly attribute: string;
118
+ /** The suggested test id. */
119
+ readonly testId: string;
120
+ /** The fix, e.g. `add data-testid="save-contact" to the "Save" button on /contacts/new`. One line, 1-300. */
121
+ readonly fix: string;
122
+ /** The steps (across Journeys) that use the element. */
123
+ readonly steps: number;
124
+ }
104
125
  /**
105
126
  * d464c HOOK: one machine finding as the contract's `findings[]` carries it (§4.4) — UX claims with
106
127
  * the derived fingerprint, defects and hangs with jevitate's. 0.10 exports no media, so no
@@ -127,11 +148,47 @@ export interface CatalogBundleFinding {
127
148
  readonly observation: string;
128
149
  readonly userImpact?: string;
129
150
  readonly recommendation?: string;
151
+ /** Minor 1 (§1): the brittle-element fix; only on kind `ux` + claim `other` + producerClaim `locator-brittle`, required there. */
152
+ readonly locator?: BundleLocator;
130
153
  readonly citation?: {
131
154
  readonly source: string;
132
155
  readonly ref: string;
133
156
  };
134
157
  }
158
+ /** #471: one guide step of a demo (contract §4.2). */
159
+ export interface BundleDemoStep {
160
+ readonly number: number;
161
+ readonly caption: string;
162
+ readonly expected?: string;
163
+ /** `media/<journey>/step-NN.png`. */
164
+ readonly screenshot?: string;
165
+ }
166
+ /** #471: the jz-mask-v1 + synthetic-data attestation every demo carries (contract §7). */
167
+ export interface BundlePrivacy {
168
+ readonly mask: "jz-mask-v1";
169
+ readonly data: "synthetic";
170
+ readonly method: "dom-before-capture" | "pixels-after-capture";
171
+ readonly regions: number;
172
+ }
173
+ /** #471: one approved demo's media manifest (contract §4.2). */
174
+ export interface BundleDemo {
175
+ readonly journey: string;
176
+ readonly renderedFrom: string;
177
+ readonly status: "approved";
178
+ readonly title?: string;
179
+ readonly steps: readonly BundleDemoStep[];
180
+ readonly video?: string;
181
+ readonly subtitles?: string;
182
+ readonly privacy: BundlePrivacy;
183
+ }
184
+ export type BundleFileType = "image/png" | "image/webp" | "video/webm" | "text/vtt";
185
+ /** #471: one media file of the bundle (contract §2): listed ⇔ present ⇔ referenced. */
186
+ export interface BundleFile {
187
+ readonly path: string;
188
+ readonly sha256: string;
189
+ readonly bytes: number;
190
+ readonly type: BundleFileType;
191
+ }
135
192
  export interface CatalogBundleV1 {
136
193
  readonly kind: typeof BUNDLE_KIND;
137
194
  readonly version: typeof BUNDLE_VERSION;
@@ -149,8 +206,40 @@ export interface CatalogBundleV1 {
149
206
  readonly jobs: readonly BundleJob[];
150
207
  readonly journeys: readonly BundleJourneyEntry[];
151
208
  };
209
+ /** #471: present (with `files`) only when at least one approved demo is exported. */
210
+ readonly demos?: readonly BundleDemo[];
152
211
  readonly checks: readonly BundleCheck[];
153
212
  readonly findings: readonly CatalogBundleFinding[];
213
+ readonly files?: readonly BundleFile[];
214
+ }
215
+ /**
216
+ * #471: an approved demo as the API hands it in: the record's manifest, and the media files the API
217
+ * re-verified on disk (present, sha256 and size as recorded, the right file type), by record name.
218
+ */
219
+ export interface CatalogBundleDemoInput {
220
+ readonly journey: string;
221
+ readonly renderedFrom: string;
222
+ readonly title?: string;
223
+ readonly steps: ReadonlyArray<{
224
+ readonly number: number;
225
+ readonly caption: string;
226
+ readonly expected?: string;
227
+ readonly screenshot?: string;
228
+ }>;
229
+ readonly video?: string;
230
+ readonly subtitles?: string;
231
+ readonly privacy: BundlePrivacy;
232
+ /** Verified media by record file name (`step-01.png`, `demo.webm`, `demo.vtt`): `source` is the file on disk. */
233
+ readonly media: Readonly<Record<string, {
234
+ readonly source: string;
235
+ readonly sha256: string;
236
+ readonly bytes: number;
237
+ }>>;
238
+ }
239
+ /** #471: a media file to place in the bundle: `source` on disk → `path` under the bundle root. */
240
+ export interface BundleMediaCopy {
241
+ readonly path: string;
242
+ readonly source: string;
154
243
  }
155
244
  /** One `jevitate check` record (`check.json`'s `data`, or the bare result) and where it came from. */
156
245
  export interface CheckRecordInput {
@@ -170,9 +259,13 @@ export interface CatalogBundleInput {
170
259
  readonly checks: readonly CheckRecordInput[];
171
260
  /** d464c: the machine findings to export (default none). */
172
261
  readonly findings?: readonly CatalogBundleFinding[];
262
+ /** #471: approved demos with their verified media (default none). */
263
+ readonly demos?: readonly CatalogBundleDemoInput[];
173
264
  }
174
265
  export interface CatalogBundleBuild {
175
266
  readonly bundle: CatalogBundleV1;
267
+ /** #471: the media files to place beside bundle.json (exactly `bundle.files`, with their sources). */
268
+ readonly media: readonly BundleMediaCopy[];
176
269
  /** Items left out or degraded, each with why. */
177
270
  readonly warnings: readonly string[];
178
271
  }
@@ -182,10 +275,21 @@ export declare class CatalogBundleInputError extends CatalogInputError {
182
275
  readonly code: string;
183
276
  constructor(issues: readonly string[]);
184
277
  }
278
+ /** #471: contract §10's media limits. */
279
+ export declare const MEDIA_LIMITS: {
280
+ readonly screenshotBytes: number;
281
+ readonly videoBytes: number;
282
+ readonly subtitlesBytes: number;
283
+ readonly totalBytes: number;
284
+ readonly files: 4000;
285
+ readonly demoSteps: 200;
286
+ };
185
287
  /** Looks like personal data (an email, or 9+ digits) — the contract's `noPersonalData`. */
186
288
  export declare function looksPersonal(s: string): boolean;
187
289
  /** The contract's `routeTemplate`: a path template — no query string, fragment, host or record id. */
188
290
  export declare function isRouteTemplate(route: string): boolean;
291
+ /** Why a subtitles file's text breaks §7 (a cue line that is not machine text, a DRAFT note), or null. */
292
+ export declare function subtitlesTextProblem(vtt: string): string | null;
189
293
  /** Why a finding handed in by the d464c source breaks the contract's privacy rules (§7), or []. */
190
294
  export declare function findingIssues(f: CatalogBundleFinding): string[];
191
295
  /** Builds the bundle (pure). Throws `CatalogBundleInputError` when the catalog cannot be exported. */
@@ -20,24 +20,31 @@ import { describeRefIssue, jobRefIssues, journeyRefIssues, RESERVED_ANCHORS } fr
20
20
  * - checks: one per check item that ran, keyed (target.id, commit, runId); a baseline only from a
21
21
  * clean run whose `journeyHash` is the exported Journey's approved hash.
22
22
  * - findings: whatever the d464c source hands in (`CatalogBundleFinding`), guarded; none by default.
23
- * - demos and media: NONE in 0.10 — a demo must carry the jz-mask-v1 + synthetic-data attestation,
24
- * which jevitate cannot make yet, and the contract says a producer that cannot meet it exports no
25
- * media. So no `demos`, no `files`.
23
+ * - demos and media (#471): one `demos[]` entry per exported, APPROVED Journey that has an
24
+ * approved-demo record (approved-demo.ts: written by `demo approve` only on a `synthetic: true`
25
+ * environment, from a jz-mask-v1 render), whose `renderedFrom` is the Journey's current approved
26
+ * hash. Its `privacy` is the record's attestation. Media is only what the record proved masked
27
+ * and the API re-verified (present, same sha256, the right file type): `media/<id>/step-NN.png`,
28
+ * `media/<id>/demo.webm` and `demo.vtt` (subtitles only with the video), each listed in `files[]`.
29
+ * Over a §10 limit (2 MiB / 50 MiB / 256 KiB per file, 512 MiB and 4,000 files per bundle), a
30
+ * file is left out with a warning, never sent. A demo whose caption breaks §7's text rules is
31
+ * left out whole (its subtitles carry the same text). A draft demo has no record: never exported.
26
32
  * - `generatedAt` is left out on purpose: the same catalog gives the same bytes, so the upload's
27
33
  * `Idempotency-Key` (sha256 of bundle.json) dedupes a re-publish.
28
34
  *
29
35
  * Failure policy: personal data or a credential typed in clear anywhere, or a job the bundle cannot
30
36
  * carry (a structural reference problem, text that is not one line), REFUSES the export
31
- * (`CatalogBundleInputError`, exit 64) — nothing is guessed or silently rewritten. A Journey or a
32
- * check the bundle cannot carry is LEFT OUT with a warning (nothing else references it in a way the
37
+ * (`CatalogBundleInputError`, exit 64) — nothing is guessed or silently rewritten. So does a
38
+ * PROMOTED Journey the bundle cannot carry (#478: never a silent omission). A draft Journey, or a
39
+ * check the bundle cannot carry, is LEFT OUT with a warning (nothing else references it in a way the
33
40
  * reader would refuse). A persona key the bundle cannot carry drops that key and the persona's
34
41
  * approval (its hash could not recompute) with a warning.
35
42
  */
36
43
  // ── The bundle (catalog-bundle.v1.json), as jevitate produces it ─────────────────────────────
37
44
  export const BUNDLE_KIND = "journeeze.catalog-bundle";
38
45
  export const BUNDLE_VERSION = 1;
39
- /** The schema minor this producer is built against (contract 61f8c92). */
40
- export const BUNDLE_MINOR = 0;
46
+ /** The schema minor this producer is built against (minor 1, journeeze-saas #173: optional `finding.locator` and `check.appVersion`). */
47
+ export const BUNDLE_MINOR = 1;
41
48
  export const CHECK_TARGET_KINDS = ["journey", "goal", "mission", "invariant", "verify_fix", "regression"];
42
49
  /** The catalog cannot be exported as it is (personal data, a credential typed in clear, a job the bundle cannot carry) — exit 64. */
43
50
  export class CatalogBundleInputError extends CatalogInputError {
@@ -56,6 +63,7 @@ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
56
63
  const SEMVER_RE = /^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]{1,40})?$/;
57
64
  const DATE_TIME_RE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})$/;
58
65
  const RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
66
+ const APP_VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
59
67
  const FAILURE_KIND_RE = /^[a-z][a-z0-9-]{0,63}$/;
60
68
  const OTHER_FIELD_KEY_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
61
69
  const ONE_LINE_RE = /^(?=.*\S)[^\u0000-\u001f\u007f]+$/u;
@@ -69,13 +77,25 @@ const RECORD_ID_RES = [
69
77
  const ROUTE_TEMPLATE_RE = /^(\/(\{[A-Za-z][A-Za-z0-9_]{0,63}\}|[A-Za-z0-9._~-]+))*\/?$/u;
70
78
  const TFLOW_ID_RE = /^[a-z0-9._:-]{1,96}$/u;
71
79
  const FINGERPRINT_RE = /^[0-9a-f]{16}$/u;
80
+ const LOCATOR_ATTRIBUTE_RE = /^[a-z][a-z0-9-]{0,39}$/u;
81
+ const LOCATOR_TEST_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/u;
82
+ const LOCATOR_MAX_STEPS = 10_000;
72
83
  const APPROVAL_CHANNELS = new Set(["tty", "mcp", "ci", "non-interactive", "pr-review", "other"]);
73
84
  const RESERVED = new Set(RESERVED_ANCHORS);
74
85
  const SESSION_KEYS = new Set(["storageState", "login"]);
75
86
  const PERSONA_NAMED_KEYS = new Set(["id", "name", "description", "role", "approval", "storageState", "login"]);
76
87
  const MAX_MS = 86_400_000;
77
88
  const MAX_STEP = 200;
78
- const LIMITS = { personas: 200, jobs: 500, journeys: 1000, checks: 5000, findings: 5000 };
89
+ const LIMITS = { personas: 200, jobs: 500, journeys: 1000, checks: 5000, findings: 5000, demos: 1000 };
90
+ /** #471: contract §10's media limits. */
91
+ export const MEDIA_LIMITS = {
92
+ screenshotBytes: 2 * 1024 * 1024,
93
+ videoBytes: 50 * 1024 * 1024,
94
+ subtitlesBytes: 256 * 1024,
95
+ totalBytes: 512 * 1024 * 1024,
96
+ files: 4000,
97
+ demoSteps: 200,
98
+ };
79
99
  /** Looks like personal data (an email, or 9+ digits) — the contract's `noPersonalData`. */
80
100
  export function looksPersonal(s) {
81
101
  return PERSONAL_DATA_RES.some((re) => re.test(s));
@@ -329,8 +349,9 @@ function bundleJourneys(catalog, refuse, warn) {
329
349
  refuse.push(`journey ${cj.id}: ${c.path.slice(1)} types personal data in clear (an email or 9+ digits) — use a parameter or a synthetic value`);
330
350
  }
331
351
  const linked = journeyLink(catalog, cj);
352
+ // #478: a promoted Journey the bundle cannot carry refuses the export — never a silent omission.
332
353
  if ("omit" in linked) {
333
- warn.push(`journey ${cj.id}: ${linked.omit} — left out`);
354
+ refuse.push(`journey ${cj.id}: ${linked.omit} — fix it (the bundle cannot carry it), or demote it`);
334
355
  continue;
335
356
  }
336
357
  const file = asJson(cj.journey);
@@ -446,6 +467,16 @@ function bundleChecks(input, journeys, warn) {
446
467
  warn.push(`${source}: the check names no commit (run it with --target-build <commit sha>) — its items are left out`);
447
468
  continue;
448
469
  }
470
+ // #479 (spec minor 1 §2): the app's release label travels beside the commit; an invalid one is
471
+ // left out with a warning (the check itself still exports).
472
+ const rawAppVersion = typeof data.appVersion === "string" ? data.appVersion : undefined;
473
+ let appVersion;
474
+ if (rawAppVersion !== undefined) {
475
+ if (APP_VERSION_RE.test(rawAppVersion))
476
+ appVersion = rawAppVersion;
477
+ else
478
+ warn.push(`${source}: the check's appVersion is not a release label (1-64 of [A-Za-z0-9._+-], starting alphanumeric) — left out`);
479
+ }
449
480
  const at = typeof data.startedAt === "string" ? data.startedAt : "";
450
481
  if (!DATE_TIME_RE.test(at)) {
451
482
  warn.push(`${source}: the check's start time is not an ISO date-time — its items are left out`);
@@ -493,6 +524,7 @@ function bundleChecks(input, journeys, warn) {
493
524
  const check = {
494
525
  target: { id, ...(kind === undefined ? {} : { kind }) },
495
526
  commit,
527
+ ...(appVersion === undefined ? {} : { appVersion }),
496
528
  at,
497
529
  runId,
498
530
  ...(journeyId === undefined ? {} : { journey: journeyId }),
@@ -509,6 +541,147 @@ function bundleChecks(input, journeys, warn) {
509
541
  }
510
542
  return [...out.values()].sort((a, b) => a.at.localeCompare(b.at) || a.target.id.localeCompare(b.target.id) || (a.runId ?? "").localeCompare(b.runId ?? ""));
511
543
  }
544
+ // ── Demos and media (#471) ─────────────────────────────────────────────────────────────────
545
+ /** Why a subtitles file's text breaks §7 (a cue line that is not machine text, a DRAFT note), or null. */
546
+ export function subtitlesTextProblem(vtt) {
547
+ const lines = vtt.split(/\r?\n/u);
548
+ if (lines[0]?.trim() !== "WEBVTT")
549
+ return "it does not start with WEBVTT";
550
+ for (const [i, raw] of lines.entries()) {
551
+ const line = raw.trim();
552
+ if (i === 0 || line === "" || /^step-[0-9]{1,3}$/u.test(line) || /^[0-9:.]+ --> [0-9:.]+$/u.test(line))
553
+ continue;
554
+ if (/^NOTE\b/u.test(line))
555
+ return "it carries a NOTE block";
556
+ const p = machineTextProblem(line, 600);
557
+ if (p !== null)
558
+ return `a cue ${p}`;
559
+ }
560
+ return null;
561
+ }
562
+ function bundleDemos(input, journeys, warn) {
563
+ const demos = [];
564
+ const files = [];
565
+ const media = [];
566
+ let total = 0;
567
+ const byId = new Map(journeys.map((j) => [j.cj.id, j]));
568
+ const sorted = [...(input.demos ?? [])].sort((a, b) => (a.journey < b.journey ? -1 : a.journey > b.journey ? 1 : 0));
569
+ for (const d of sorted) {
570
+ const where = `demo ${d.journey}`;
571
+ const ex = byId.get(d.journey);
572
+ if (ex === undefined) {
573
+ warn.push(`${where}: its Journey is not in the bundle (not promoted, or left out) — the demo is left out`);
574
+ continue;
575
+ }
576
+ const approved = ex.cj.approval !== undefined && ex.cj.approval.contentHash === ex.cj.contentHash;
577
+ if (!approved) {
578
+ warn.push(`${where}: its Journey changed since it was approved — the demo is left out (approve the Journey and its demo again)`);
579
+ continue;
580
+ }
581
+ if (d.renderedFrom !== ex.cj.contentHash) {
582
+ warn.push(`${where}: rendered from another version of its Journey (${d.renderedFrom.slice(0, 12)}, approved ${ex.cj.contentHash.slice(0, 12)}) — the demo is left out (run \`jevitate demo approve\` again)`);
583
+ continue;
584
+ }
585
+ if (d.steps.length === 0 || d.steps.length > MEDIA_LIMITS.demoSteps) {
586
+ warn.push(`${where}: ${d.steps.length} steps (a demo has 1-${MEDIA_LIMITS.demoSteps}) — the demo is left out`);
587
+ continue;
588
+ }
589
+ const captionIssues = d.steps.flatMap((s) => {
590
+ const p = machineTextProblem(s.caption, 200);
591
+ return p === null ? [] : [`step ${s.number} caption ${p}`];
592
+ });
593
+ if (captionIssues.length > 0) {
594
+ warn.push(`${where}: ${captionIssues.join("; ")} — the demo is left out (captions are producer text: one line, no personal data, URL or record id)`);
595
+ continue;
596
+ }
597
+ let title;
598
+ if (d.title !== undefined) {
599
+ const p = machineTextProblem(d.title, 200);
600
+ if (p !== null)
601
+ warn.push(`${where}: title ${p} — sent without a title`);
602
+ else if (/^DRAFT/u.test(d.title))
603
+ warn.push(`${where}: title starts with DRAFT — sent without a title`);
604
+ else
605
+ title = d.title;
606
+ }
607
+ // Media, each within its per-file and the bundle's limits; a left-out file is never referenced.
608
+ const dir = `media/${d.journey}`;
609
+ const take = (name, type, max, what) => {
610
+ if (name === undefined)
611
+ return undefined;
612
+ const f = d.media[name];
613
+ if (f === undefined)
614
+ return undefined; // the API already warned why
615
+ if (f.bytes > max) {
616
+ warn.push(`${where}: ${what} ${name} is ${f.bytes} bytes, over the contract's ${max} — left out`);
617
+ return undefined;
618
+ }
619
+ if (files.length + 1 > MEDIA_LIMITS.files || total + f.bytes > MEDIA_LIMITS.totalBytes) {
620
+ warn.push(`${where}: ${what} ${name} would take the bundle over ${MEDIA_LIMITS.files} files or ${MEDIA_LIMITS.totalBytes} bytes of media — left out`);
621
+ return undefined;
622
+ }
623
+ const path = `${dir}/${name}`;
624
+ files.push({ path, sha256: f.sha256, bytes: f.bytes, type });
625
+ media.push({ path, source: f.source });
626
+ total += f.bytes;
627
+ return path;
628
+ };
629
+ const steps = d.steps.map((s) => {
630
+ let expected;
631
+ if (s.expected !== undefined) {
632
+ const p = machineTextProblem(s.expected, 500);
633
+ if (p !== null)
634
+ warn.push(`${where}: step ${s.number} expected result ${p} — sent without it`);
635
+ else
636
+ expected = s.expected;
637
+ }
638
+ const shot = s.screenshot !== undefined && /^step-[0-9]{2,3}\.png$/u.test(s.screenshot) ? take(s.screenshot, "image/png", MEDIA_LIMITS.screenshotBytes, "screenshot") : undefined;
639
+ return { number: s.number, caption: s.caption, ...(expected === undefined ? {} : { expected }), ...(shot === undefined ? {} : { screenshot: shot }) };
640
+ });
641
+ // Subtitles only with their video: reserve both or neither.
642
+ let video;
643
+ let subtitles;
644
+ const v = d.video === undefined ? undefined : d.media[d.video];
645
+ const t = d.subtitles === undefined ? undefined : d.media[d.subtitles];
646
+ if (v !== undefined) {
647
+ if (d.subtitles !== undefined && t === undefined)
648
+ warn.push(`${where}: its subtitles could not be verified — the video is left out with them`);
649
+ else if (t !== undefined && t.bytes > MEDIA_LIMITS.subtitlesBytes)
650
+ warn.push(`${where}: subtitles ${d.subtitles} are ${t.bytes} bytes, over the contract's ${MEDIA_LIMITS.subtitlesBytes} — the video is left out with them`);
651
+ else {
652
+ video = take(d.video, "video/webm", MEDIA_LIMITS.videoBytes, "video");
653
+ if (video !== undefined && t !== undefined) {
654
+ subtitles = take(d.subtitles, "text/vtt", MEDIA_LIMITS.subtitlesBytes, "subtitles");
655
+ if (subtitles === undefined) {
656
+ // Never a video without its captions file: un-take the video.
657
+ const at = files.findIndex((f) => f.path === video);
658
+ total -= files[at].bytes;
659
+ files.splice(at, 1);
660
+ media.splice(media.findIndex((m) => m.path === video), 1);
661
+ video = undefined;
662
+ }
663
+ }
664
+ }
665
+ }
666
+ demos.push({
667
+ journey: d.journey,
668
+ renderedFrom: d.renderedFrom,
669
+ status: "approved",
670
+ ...(title === undefined ? {} : { title }),
671
+ steps,
672
+ ...(video === undefined ? {} : { video }),
673
+ ...(subtitles === undefined ? {} : { subtitles }),
674
+ privacy: { mask: d.privacy.mask, data: d.privacy.data, method: d.privacy.method, regions: d.privacy.regions },
675
+ });
676
+ }
677
+ if (demos.length > LIMITS.demos) {
678
+ warn.push(`${demos.length} demos: only the first ${LIMITS.demos} are exported`);
679
+ const kept = new Set(demos.slice(0, LIMITS.demos).map((d) => `media/${d.journey}/`));
680
+ const keep = (p) => [...kept].some((k) => p.startsWith(k));
681
+ return { demos: demos.slice(0, LIMITS.demos), files: files.filter((f) => keep(f.path)), media: media.filter((m) => keep(m.path)) };
682
+ }
683
+ return { demos, files, media };
684
+ }
512
685
  // ── Findings (the d464c hook's guard) ────────────────────────────────────────────────────────
513
686
  /** Why a finding handed in by the d464c source breaks the contract's privacy rules (§7), or []. */
514
687
  export function findingIssues(f) {
@@ -531,6 +704,28 @@ export function findingIssues(f) {
531
704
  text("userImpact", f.userImpact, 600);
532
705
  text("recommendation", f.recommendation, 600);
533
706
  (f.controls ?? []).forEach((c, i) => text(`controls[${i}]`, c, 120));
707
+ // Minor 1 §1: `locator` belongs only to a locator claim, and a locator claim must carry it.
708
+ const locatorClaim = f.kind === "ux" && f.claim === "other" && f.producerClaim === "locator-brittle";
709
+ if (locatorClaim && f.locator === undefined)
710
+ out.push(`${where}: a locator finding needs its locator`);
711
+ if (f.locator !== undefined) {
712
+ if (!locatorClaim)
713
+ out.push(`${where}: locator is only for kind ux + claim other + producerClaim locator-brittle`);
714
+ const loc = f.locator;
715
+ const locText = (k, max) => {
716
+ const p = machineTextProblem(loc[k], max);
717
+ if (p !== null)
718
+ out.push(`${where}: locator.${k} ${p}`);
719
+ };
720
+ locText("element", 200);
721
+ locText("fix", 300);
722
+ if (!LOCATOR_ATTRIBUTE_RE.test(loc.attribute))
723
+ out.push(`${where}: locator.attribute is not a test-id attribute name`);
724
+ if (!LOCATOR_TEST_ID_RE.test(loc.testId))
725
+ out.push(`${where}: locator.testId is not a test id`);
726
+ if (!Number.isInteger(loc.steps) || loc.steps < 1 || loc.steps > LOCATOR_MAX_STEPS)
727
+ out.push(`${where}: locator.steps is not an integer 1-${LOCATOR_MAX_STEPS}`);
728
+ }
534
729
  return out;
535
730
  }
536
731
  // ── The bundle ───────────────────────────────────────────────────────────────────────────────
@@ -561,7 +756,9 @@ export function buildCatalogBundle(input) {
561
756
  checks = checks.slice(-LIMITS.checks);
562
757
  }
563
758
  const commit = input.producer.commit !== undefined && COMMIT_RE.test(input.producer.commit) ? input.producer.commit : undefined;
759
+ const shown = bundleDemos(input, journeys, warn);
564
760
  return {
761
+ media: shown.media,
565
762
  bundle: {
566
763
  kind: BUNDLE_KIND,
567
764
  version: BUNDLE_VERSION,
@@ -569,8 +766,10 @@ export function buildCatalogBundle(input) {
569
766
  producer: { tool: "jevitate", version: input.producer.version, ...(commit === undefined ? {} : { commit }) },
570
767
  product: { name: input.productName },
571
768
  catalog: { personas, jobs, journeys: journeys.map((j) => j.entry) },
769
+ ...(shown.demos.length === 0 ? {} : { demos: shown.demos }),
572
770
  checks,
573
771
  findings: [...findings],
772
+ ...(shown.demos.length === 0 ? {} : { files: shown.files }),
574
773
  },
575
774
  warnings: warn,
576
775
  };