@jevitate/cli 0.9.0 → 0.11.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.
- package/dist/anchor-baselines.d.ts +26 -0
- package/dist/anchor-baselines.js +25 -0
- package/dist/anchor-baselines.js.map +1 -0
- package/dist/approval-provenance.d.ts +32 -2
- package/dist/approval-provenance.js +92 -5
- package/dist/approval-provenance.js.map +1 -1
- package/dist/approved-demo.d.ts +86 -0
- package/dist/approved-demo.js +184 -0
- package/dist/approved-demo.js.map +1 -0
- package/dist/bin.js +13352 -7050
- package/dist/build-info.generated.js +2 -2
- package/dist/campaign-api.d.ts +15 -0
- package/dist/campaign-api.js +67 -6
- package/dist/campaign-api.js.map +1 -1
- package/dist/campaign-cli.js +7 -2
- package/dist/campaign-cli.js.map +1 -1
- package/dist/campaign-preflight.d.ts +76 -0
- package/dist/campaign-preflight.js +117 -0
- package/dist/campaign-preflight.js.map +1 -0
- package/dist/catalog-analysis.js +14 -0
- package/dist/catalog-analysis.js.map +1 -1
- package/dist/catalog-api.js +12 -4
- package/dist/catalog-api.js.map +1 -1
- package/dist/catalog-bundle-api.d.ts +83 -0
- package/dist/catalog-bundle-api.js +309 -0
- package/dist/catalog-bundle-api.js.map +1 -0
- package/dist/catalog-bundle-findings.d.ts +64 -0
- package/dist/catalog-bundle-findings.js +326 -0
- package/dist/catalog-bundle-findings.js.map +1 -0
- package/dist/catalog-bundle.d.ts +296 -0
- package/dist/catalog-bundle.js +777 -0
- package/dist/catalog-bundle.js.map +1 -0
- package/dist/catalog-cli.d.ts +6 -0
- package/dist/catalog-cli.js +181 -5
- package/dist/catalog-cli.js.map +1 -1
- package/dist/catalog-refs.d.ts +42 -0
- package/dist/catalog-refs.js +188 -0
- package/dist/catalog-refs.js.map +1 -0
- package/dist/catalog-review.d.ts +3 -1
- package/dist/catalog-review.js +61 -0
- package/dist/catalog-review.js.map +1 -1
- package/dist/catalog.d.ts +27 -3
- package/dist/catalog.js +46 -2
- package/dist/catalog.js.map +1 -1
- package/dist/check-api.js +180 -9
- package/dist/check-api.js.map +1 -1
- package/dist/check-cli.d.ts +2 -0
- package/dist/check-cli.js +96 -2
- package/dist/check-cli.js.map +1 -1
- package/dist/check-execute.d.ts +18 -0
- package/dist/check-execute.js +56 -3
- package/dist/check-execute.js.map +1 -1
- package/dist/check-types.d.ts +105 -0
- package/dist/check-types.js.map +1 -1
- package/dist/cli-output.d.ts +6 -0
- package/dist/cli-output.js +38 -1
- package/dist/cli-output.js.map +1 -1
- package/dist/cli-shared.d.ts +10 -0
- package/dist/cli-shared.js +0 -4
- package/dist/cli-shared.js.map +1 -1
- package/dist/cold-start-report.d.ts +32 -0
- package/dist/cold-start-report.js +31 -0
- package/dist/cold-start-report.js.map +1 -0
- package/dist/defect-evidence.js +10 -5
- package/dist/defect-evidence.js.map +1 -1
- package/dist/demo-aspect-api.d.ts +11 -0
- package/dist/demo-aspect-api.js +27 -2
- package/dist/demo-aspect-api.js.map +1 -1
- package/dist/demo-aspect-cli.js +5 -0
- package/dist/demo-aspect-cli.js.map +1 -1
- package/dist/demo-capture.d.ts +79 -2
- package/dist/demo-capture.js +504 -12
- package/dist/demo-capture.js.map +1 -1
- package/dist/environments.d.ts +8 -1
- package/dist/environments.js +14 -3
- package/dist/environments.js.map +1 -1
- package/dist/exit-codes.js +16 -0
- package/dist/exit-codes.js.map +1 -1
- package/dist/explore-adversarial.js +2 -2
- package/dist/explore-adversarial.js.map +1 -1
- package/dist/explore-shared.d.ts +11 -0
- package/dist/explore-shared.js +9 -0
- package/dist/explore-shared.js.map +1 -1
- package/dist/findings-filing.js +7 -3
- package/dist/findings-filing.js.map +1 -1
- package/dist/forge-verify.d.ts +224 -0
- package/dist/forge-verify.js +649 -0
- package/dist/forge-verify.js.map +1 -0
- package/dist/github-issue-filer.js +15 -9
- package/dist/github-issue-filer.js.map +1 -1
- package/dist/init-codeowners.d.ts +19 -0
- package/dist/init-codeowners.js +66 -0
- package/dist/init-codeowners.js.map +1 -1
- package/dist/job-draft-api.d.ts +52 -0
- package/dist/job-draft-api.js +110 -0
- package/dist/job-draft-api.js.map +1 -0
- package/dist/journeeze-api.d.ts +77 -0
- package/dist/journeeze-api.js +371 -0
- package/dist/journeeze-api.js.map +1 -0
- package/dist/journeeze-connect.d.ts +156 -0
- package/dist/journeeze-connect.js +357 -0
- package/dist/journeeze-connect.js.map +1 -0
- package/dist/journey-anchor-api.d.ts +37 -0
- package/dist/journey-anchor-api.js +58 -0
- package/dist/journey-anchor-api.js.map +1 -0
- package/dist/journey-anchor-cli.d.ts +17 -0
- package/dist/journey-anchor-cli.js +104 -0
- package/dist/journey-anchor-cli.js.map +1 -0
- package/dist/journey-anchor-gate.d.ts +52 -0
- package/dist/journey-anchor-gate.js +101 -0
- package/dist/journey-anchor-gate.js.map +1 -0
- package/dist/journey-api.d.ts +30 -1
- package/dist/journey-api.js +118 -16
- package/dist/journey-api.js.map +1 -1
- package/dist/journey-cli.js +93 -10
- package/dist/journey-cli.js.map +1 -1
- package/dist/journey-demo-api.d.ts +29 -0
- package/dist/journey-demo-api.js +29 -7
- package/dist/journey-demo-api.js.map +1 -1
- package/dist/journey-heal.js +1 -1
- package/dist/journey-heal.js.map +1 -1
- package/dist/journey-lint-sarif.d.ts +3 -2
- package/dist/journey-lint-sarif.js +11 -2
- package/dist/journey-lint-sarif.js.map +1 -1
- package/dist/journey-migrate-api.d.ts +54 -0
- package/dist/journey-migrate-api.js +64 -0
- package/dist/journey-migrate-api.js.map +1 -0
- package/dist/journey-network-checks.d.ts +11 -0
- package/dist/journey-network-checks.js +36 -13
- package/dist/journey-network-checks.js.map +1 -1
- package/dist/journey-proposal-store.d.ts +9 -0
- package/dist/journey-proposal-store.js +23 -0
- package/dist/journey-proposal-store.js.map +1 -1
- package/dist/journey-result-record.d.ts +2 -0
- package/dist/journey-result-record.js +5 -0
- package/dist/journey-result-record.js.map +1 -1
- package/dist/journey-review-api.js +9 -1
- package/dist/journey-review-api.js.map +1 -1
- package/dist/journey-review.d.ts +11 -1
- package/dist/journey-review.js +77 -5
- package/dist/journey-review.js.map +1 -1
- package/dist/journey-stale-api.d.ts +40 -0
- package/dist/journey-stale-api.js +57 -0
- package/dist/journey-stale-api.js.map +1 -0
- package/dist/load-api.js +4 -2
- package/dist/load-api.js.map +1 -1
- package/dist/locator-health-api.d.ts +101 -0
- package/dist/locator-health-api.js +203 -0
- package/dist/locator-health-api.js.map +1 -0
- package/dist/locator-health.d.ts +195 -0
- package/dist/locator-health.js +419 -0
- package/dist/locator-health.js.map +1 -0
- package/dist/mcp-api.d.ts +3 -1
- package/dist/mcp-api.js +21 -2
- package/dist/mcp-api.js.map +1 -1
- package/dist/mcp-cli-tools.d.ts +4 -1
- package/dist/mcp-cli-tools.js +82 -10
- package/dist/mcp-cli-tools.js.map +1 -1
- package/dist/openrouter-call.d.ts +23 -10
- package/dist/openrouter-call.js +14 -11
- package/dist/openrouter-call.js.map +1 -1
- package/dist/png-pixels.d.ts +12 -0
- package/dist/png-pixels.js +96 -0
- package/dist/png-pixels.js.map +1 -0
- package/dist/project-config.d.ts +29 -0
- package/dist/project-config.js +76 -0
- package/dist/project-config.js.map +1 -0
- package/dist/regression-api.js +2 -1
- package/dist/regression-api.js.map +1 -1
- package/dist/server-log-verify.js +2 -1
- package/dist/server-log-verify.js.map +1 -1
- package/dist/signal-triage.js +7 -3
- package/dist/signal-triage.js.map +1 -1
- package/dist/source-run-api.js +2 -2
- package/dist/source-run-api.js.map +1 -1
- package/dist/verify-fix-api.d.ts +29 -0
- package/dist/verify-fix-api.js +43 -2
- package/dist/verify-fix-api.js.map +1 -1
- package/dist/verify-fix-cli.js +4 -0
- package/dist/verify-fix-cli.js.map +1 -1
- package/dist/zip-store.d.ts +15 -0
- package/dist/zip-store.js +93 -0
- package/dist/zip-store.js.map +1 -0
- package/package.json +12 -10
- package/skills/jevitate-ci-check/SKILL.md +16 -0
- package/skills/jevitate-demo/SKILL.md +4 -1
- package/skills/jevitate-getting-started/SKILL.md +7 -2
- package/skills/jevitate-run-journey/SKILL.md +15 -0
- package/skills/jevitate-test-campaign/SKILL.md +26 -0
- package/skills/jevitate-verify-fix/SKILL.md +5 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import { JOURNEY_MISSION_OUTCOME, JOURNEY_RUN_OUTCOMES, MISSION_EXIT_CODES, MISSION_OUTCOMES } from "@jevitate/domain";
|
|
2
|
+
import { flatJourneySteps, SECRET_PARAM_NAME_RE } from "@jevitate/journey";
|
|
3
|
+
import { CatalogInputError, findJob, findPersona } from "./catalog.js";
|
|
4
|
+
import { describeRefIssue, jobRefIssues, journeyRefIssues, RESERVED_ANCHORS } from "./catalog-refs.js";
|
|
5
|
+
/**
|
|
6
|
+
* #464 (d464a) — the PURE builder of a Journeeze catalog bundle, version 1.0 (journeeze-saas
|
|
7
|
+
* `docs/contract/catalog-bundle-v1.md` at 61f8c92, schema `catalog-bundle.v1.json`). No I/O, no
|
|
8
|
+
* clock: catalog-bundle-api.ts loads the catalog, the raw persona entries, the `check.json` records
|
|
9
|
+
* and the producer identity, and writes what this returns.
|
|
10
|
+
*
|
|
11
|
+
* What goes out, and how (each rule is the contract's):
|
|
12
|
+
* - personas: `{id, description, role, otherFields, approval}` — every descriptive key, so §8's
|
|
13
|
+
* persona hash (`{id, …description, role and otherFields}`) recomputes to jevitate's
|
|
14
|
+
* `personaContentHash`. Session keys (`storageState`, `login`) never leave.
|
|
15
|
+
* - jobs: the `jobs.json` entry as loaded (every jtbd and campaign field — the hash input), with its
|
|
16
|
+
* approval reduced to the contract's `{contentHash, at, channel}` (approval is outside the hash).
|
|
17
|
+
* - journeys: PROMOTED Journeys only, the file as jevitate reads it (the review-hash input), with
|
|
18
|
+
* approval provenance reduced to `{channel, agentSignals}` (no OS user, no reason, no PR reviewer —
|
|
19
|
+
* bookkeeping outside the hash), and `link {job, persona, anchors, serves}` from its metadata.
|
|
20
|
+
* - checks: one per check item that ran, keyed (target.id, commit, runId); a baseline only from a
|
|
21
|
+
* clean run whose `journeyHash` is the exported Journey's approved hash.
|
|
22
|
+
* - findings: whatever the d464c source hands in (`CatalogBundleFinding`), guarded; none by default.
|
|
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.
|
|
32
|
+
* - `generatedAt` is left out on purpose: the same catalog gives the same bytes, so the upload's
|
|
33
|
+
* `Idempotency-Key` (sha256 of bundle.json) dedupes a re-publish.
|
|
34
|
+
*
|
|
35
|
+
* Failure policy: personal data or a credential typed in clear anywhere, or a job the bundle cannot
|
|
36
|
+
* carry (a structural reference problem, text that is not one line), REFUSES the export
|
|
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
|
|
40
|
+
* reader would refuse). A persona key the bundle cannot carry drops that key and the persona's
|
|
41
|
+
* approval (its hash could not recompute) with a warning.
|
|
42
|
+
*/
|
|
43
|
+
// ── The bundle (catalog-bundle.v1.json), as jevitate produces it ─────────────────────────────
|
|
44
|
+
export const BUNDLE_KIND = "journeeze.catalog-bundle";
|
|
45
|
+
export const BUNDLE_VERSION = 1;
|
|
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;
|
|
48
|
+
export const CHECK_TARGET_KINDS = ["journey", "goal", "mission", "invariant", "verify_fix", "regression"];
|
|
49
|
+
/** The catalog cannot be exported as it is (personal data, a credential typed in clear, a job the bundle cannot carry) — exit 64. */
|
|
50
|
+
export class CatalogBundleInputError extends CatalogInputError {
|
|
51
|
+
issues;
|
|
52
|
+
code = "E_CATALOG_EXPORT_INPUT";
|
|
53
|
+
constructor(issues) {
|
|
54
|
+
super(`the catalog cannot be exported as a Journeeze bundle (${issues.length} problem(s)): ${issues.join("; ")}`);
|
|
55
|
+
this.issues = issues;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── The contract's text and id rules (catalog-bundle.v1.json $defs) ──────────────────────────
|
|
59
|
+
const CATALOG_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
60
|
+
const TARGET_ID_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
61
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
62
|
+
const COMMIT_RE = /^[0-9a-f]{7,40}$/;
|
|
63
|
+
const SEMVER_RE = /^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]{1,40})?$/;
|
|
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})$/;
|
|
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}$/;
|
|
67
|
+
const FAILURE_KIND_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
68
|
+
const OTHER_FIELD_KEY_RE = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
69
|
+
const ONE_LINE_RE = /^(?=.*\S)[^\u0000-\u001f\u007f]+$/u;
|
|
70
|
+
const PERSONAL_DATA_RES = [/[^\s@/]+@[^\s@/]+\.[A-Za-z]{2,}/u, /[0-9]{9,}/u];
|
|
71
|
+
const URL_RES = [/:\/\//u, /(?:^|[^A-Za-z0-9])[Ww][Ww][Ww]\./u];
|
|
72
|
+
const RECORD_ID_RES = [
|
|
73
|
+
/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/u,
|
|
74
|
+
/(?<![0-9A-Za-z])(?=[0-9a-fA-F]*[0-9])[0-9a-fA-F]{8,}(?![0-9A-Za-z])/u,
|
|
75
|
+
/[0-9]{6,}/u,
|
|
76
|
+
];
|
|
77
|
+
const ROUTE_TEMPLATE_RE = /^(\/(\{[A-Za-z][A-Za-z0-9_]{0,63}\}|[A-Za-z0-9._~-]+))*\/?$/u;
|
|
78
|
+
const TFLOW_ID_RE = /^[a-z0-9._:-]{1,96}$/u;
|
|
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;
|
|
83
|
+
const APPROVAL_CHANNELS = new Set(["tty", "mcp", "ci", "non-interactive", "pr-review", "other"]);
|
|
84
|
+
const RESERVED = new Set(RESERVED_ANCHORS);
|
|
85
|
+
const SESSION_KEYS = new Set(["storageState", "login"]);
|
|
86
|
+
const PERSONA_NAMED_KEYS = new Set(["id", "name", "description", "role", "approval", "storageState", "login"]);
|
|
87
|
+
const MAX_MS = 86_400_000;
|
|
88
|
+
const MAX_STEP = 200;
|
|
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
|
+
};
|
|
99
|
+
/** Looks like personal data (an email, or 9+ digits) — the contract's `noPersonalData`. */
|
|
100
|
+
export function looksPersonal(s) {
|
|
101
|
+
return PERSONAL_DATA_RES.some((re) => re.test(s));
|
|
102
|
+
}
|
|
103
|
+
/** The contract's `catalogText`: one printable line, no personal data. */
|
|
104
|
+
function catalogTextProblem(s) {
|
|
105
|
+
if (typeof s !== "string")
|
|
106
|
+
return "is not text";
|
|
107
|
+
if (!ONE_LINE_RE.test(s))
|
|
108
|
+
return "is not one printable line";
|
|
109
|
+
if (looksPersonal(s))
|
|
110
|
+
return "looks like personal data (an email or 9+ digits)";
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/** The contract's `machineText`: `catalogText`, and no URL and no record id. */
|
|
114
|
+
function machineTextProblem(s, max) {
|
|
115
|
+
const p = catalogTextProblem(s);
|
|
116
|
+
if (p !== null)
|
|
117
|
+
return p;
|
|
118
|
+
const t = s;
|
|
119
|
+
if (t.length > max)
|
|
120
|
+
return `is longer than ${max} characters`;
|
|
121
|
+
if (URL_RES.some((re) => re.test(t)))
|
|
122
|
+
return "carries a URL";
|
|
123
|
+
if (RECORD_ID_RES.some((re) => re.test(t)))
|
|
124
|
+
return "carries a record id";
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/** The contract's `routeTemplate`: a path template — no query string, fragment, host or record id. */
|
|
128
|
+
export function isRouteTemplate(route) {
|
|
129
|
+
return route.length >= 1 && route.length <= 256 && ROUTE_TEMPLATE_RE.test(route) && !RECORD_ID_RES.some((re) => re.test(route));
|
|
130
|
+
}
|
|
131
|
+
function isRecord(v) {
|
|
132
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
133
|
+
}
|
|
134
|
+
/** A deep copy through JSON — exactly what the reader sees (and hashes). */
|
|
135
|
+
function asJson(v) {
|
|
136
|
+
return JSON.parse(JSON.stringify(v));
|
|
137
|
+
}
|
|
138
|
+
/** A catalog approval in the contract's shape; null when it cannot be carried (`at` not a date-time). */
|
|
139
|
+
function bundleApproval(a) {
|
|
140
|
+
if (!SHA256_RE.test(a.contentHash) || !DATE_TIME_RE.test(a.at))
|
|
141
|
+
return null;
|
|
142
|
+
const channel = a.provenance?.channel;
|
|
143
|
+
return { contentHash: a.contentHash, at: a.at, ...(channel !== undefined && APPROVAL_CHANNELS.has(channel) ? { channel } : {}) };
|
|
144
|
+
}
|
|
145
|
+
// ── Personas ─────────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
function bundlePersonas(input, refuse, warn) {
|
|
147
|
+
return input.catalog.personas.map((p) => {
|
|
148
|
+
const fields = input.personaFields.get(p.id) ?? {};
|
|
149
|
+
const where = `persona ${p.id}`;
|
|
150
|
+
const dropped = [];
|
|
151
|
+
const text = (key, max) => {
|
|
152
|
+
const v = fields[key];
|
|
153
|
+
if (typeof v !== "string")
|
|
154
|
+
return undefined;
|
|
155
|
+
if (looksPersonal(v)) {
|
|
156
|
+
refuse.push(`${where}: ${key} looks like personal data (an email or 9+ digits)`);
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (catalogTextProblem(v) !== null || v.length > max) {
|
|
160
|
+
dropped.push(key);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return v;
|
|
164
|
+
};
|
|
165
|
+
const description = text("description", 2000);
|
|
166
|
+
const role = text("role", 200);
|
|
167
|
+
const otherFields = {};
|
|
168
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
169
|
+
if (PERSONA_NAMED_KEYS.has(k) || SESSION_KEYS.has(k))
|
|
170
|
+
continue;
|
|
171
|
+
if (typeof v === "string" && looksPersonal(v)) {
|
|
172
|
+
refuse.push(`${where}: ${k} looks like personal data (an email or 9+ digits)`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const scalar = (typeof v === "string" && v.length <= 1000) || (typeof v === "number" && Number.isFinite(v)) || typeof v === "boolean";
|
|
176
|
+
if (!OTHER_FIELD_KEY_RE.test(k) || !scalar) {
|
|
177
|
+
dropped.push(k);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
otherFields[k] = v;
|
|
181
|
+
}
|
|
182
|
+
if (Object.keys(otherFields).length > 20) {
|
|
183
|
+
dropped.push(...Object.keys(otherFields).slice(20));
|
|
184
|
+
for (const k of Object.keys(otherFields).slice(20))
|
|
185
|
+
delete otherFields[k];
|
|
186
|
+
}
|
|
187
|
+
let approval = p.approval === undefined ? null : bundleApproval(p.approval);
|
|
188
|
+
if (dropped.length > 0) {
|
|
189
|
+
warn.push(`${where}: ${dropped.join(", ")} cannot travel in the bundle (one line of text; other keys: a simple name and a short text, number or boolean) — left out${p.approval === undefined ? "" : ", and so is its approval (its hash could not recompute)"}`);
|
|
190
|
+
approval = null;
|
|
191
|
+
}
|
|
192
|
+
else if (p.approval !== undefined && approval === null) {
|
|
193
|
+
warn.push(`${where}: its approval's time is not an ISO date-time — exported without its approval`);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
id: p.id,
|
|
197
|
+
...(description === undefined ? {} : { description }),
|
|
198
|
+
...(role === undefined ? {} : { role }),
|
|
199
|
+
...(Object.keys(otherFields).length === 0 ? {} : { otherFields }),
|
|
200
|
+
...(approval === null ? {} : { approval }),
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// ── Jobs ─────────────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
function jobTextIssues(job) {
|
|
206
|
+
const out = [];
|
|
207
|
+
const catalogText = (path, v) => {
|
|
208
|
+
if (v === undefined)
|
|
209
|
+
return;
|
|
210
|
+
const p = catalogTextProblem(v);
|
|
211
|
+
if (p !== null)
|
|
212
|
+
out.push(`${path} ${p}`);
|
|
213
|
+
};
|
|
214
|
+
const personal = (path, v) => {
|
|
215
|
+
if (typeof v === "string" && looksPersonal(v))
|
|
216
|
+
out.push(`${path} looks like personal data (an email or 9+ digits)`);
|
|
217
|
+
};
|
|
218
|
+
for (const k of ["trigger", "motivation", "outcome"])
|
|
219
|
+
catalogText(k, job[k]);
|
|
220
|
+
for (const k of ["context", "constraints"])
|
|
221
|
+
if (Array.isArray(job[k]))
|
|
222
|
+
job[k].forEach((v, i) => catalogText(`${k}[${i}]`, v));
|
|
223
|
+
if (Array.isArray(job.steps))
|
|
224
|
+
job.steps.forEach((s, i) => isRecord(s) && catalogText(`steps[${i}].name`, s.name));
|
|
225
|
+
if (Array.isArray(job.desiredOutcomes)) {
|
|
226
|
+
job.desiredOutcomes.forEach((o, i) => {
|
|
227
|
+
if (!isRecord(o))
|
|
228
|
+
return;
|
|
229
|
+
catalogText(`desiredOutcomes[${i}].object`, o.object);
|
|
230
|
+
catalogText(`desiredOutcomes[${i}].clarifier`, o.clarifier);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
personal("goal", job.goal);
|
|
234
|
+
for (const k of ["success", "preconditions"])
|
|
235
|
+
if (Array.isArray(job[k]))
|
|
236
|
+
job[k].forEach((v, i) => personal(`${k}[${i}]`, v));
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
function bundleJobs(input, refuse, warn) {
|
|
240
|
+
const { catalog } = input;
|
|
241
|
+
return catalog.jobs.map((j) => {
|
|
242
|
+
const { approval: _a, ...rest } = asJson(j.job);
|
|
243
|
+
for (const issue of jobTextIssues(rest))
|
|
244
|
+
refuse.push(`job ${j.id}: ${issue}`);
|
|
245
|
+
for (const issue of jobRefIssues(catalog, j.id, { enforce: true }))
|
|
246
|
+
if (issue.severity === "error")
|
|
247
|
+
refuse.push(describeRefIssue(issue));
|
|
248
|
+
let approval = null;
|
|
249
|
+
if (j.approval !== undefined) {
|
|
250
|
+
approval = bundleApproval(j.approval);
|
|
251
|
+
if (approval === null)
|
|
252
|
+
warn.push(`job ${j.id}: its approval's time is not an ISO date-time — exported without its approval`);
|
|
253
|
+
}
|
|
254
|
+
return { ...rest, id: j.id, ...(approval === null ? {} : { approval }) };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// ── Journeys ─────────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
/** Every `{redacted:false, value}` under `node`, with its path — the typed values kept in clear. */
|
|
259
|
+
function clearValues(node, path, out, secretField = false) {
|
|
260
|
+
if (Array.isArray(node)) {
|
|
261
|
+
node.forEach((v, i) => clearValues(v, `${path}[${i}]`, out, secretField));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (!isRecord(node))
|
|
265
|
+
return;
|
|
266
|
+
// A step whose target reads as a credential (password, token, otp, …): its literal is a secret.
|
|
267
|
+
const target = node.target;
|
|
268
|
+
const secretHere = isRecord(target) && ["name", "label", "testId", "text"].some((k) => typeof target[k] === "string" && SECRET_PARAM_NAME_RE.test(target[k]));
|
|
269
|
+
if (node.redacted === false && typeof node.value === "string")
|
|
270
|
+
out.push({ path, value: node.value, secretField });
|
|
271
|
+
for (const [k, v] of Object.entries(node))
|
|
272
|
+
clearValues(v, `${path}.${k}`, out, k === "value" ? secretHere || secretField : secretField);
|
|
273
|
+
}
|
|
274
|
+
/** Journey approval bookkeeping (outside the review hash) keeps only the channel and agent markers: no OS user, reason or reviewer. */
|
|
275
|
+
function trimProvenance(v) {
|
|
276
|
+
if (Array.isArray(v))
|
|
277
|
+
return v.map(trimProvenance);
|
|
278
|
+
if (!isRecord(v))
|
|
279
|
+
return v;
|
|
280
|
+
const out = {};
|
|
281
|
+
for (const [k, x] of Object.entries(v)) {
|
|
282
|
+
if (k === "provenance" && isRecord(x))
|
|
283
|
+
out[k] = { channel: x.channel, agentSignals: Array.isArray(x.agentSignals) ? x.agentSignals : [] };
|
|
284
|
+
else
|
|
285
|
+
out[k] = trimProvenance(x);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/** Why a promoted Journey cannot travel in the bundle, or null when it can (with its link). */
|
|
290
|
+
function journeyLink(catalog, cj) {
|
|
291
|
+
const m = cj.journey.metadata;
|
|
292
|
+
if (!CATALOG_ID_RE.test(m.id))
|
|
293
|
+
return { omit: "its id is not a bundle id (1-64 of [A-Za-z0-9._-])" };
|
|
294
|
+
if (m.name.length < 1 || m.name.length > 200)
|
|
295
|
+
return { omit: "its name is not 1-200 characters" };
|
|
296
|
+
if (m.persona !== undefined) {
|
|
297
|
+
if (!CATALOG_ID_RE.test(m.persona))
|
|
298
|
+
return { omit: `metadata.persona ${JSON.stringify(m.persona)} is free text — the bundle needs a persona id from personas.json` };
|
|
299
|
+
if (findPersona(catalog, m.persona) === undefined)
|
|
300
|
+
return { omit: `metadata.persona '${m.persona}' is not a persona in personas.json` };
|
|
301
|
+
}
|
|
302
|
+
if (m.job !== undefined && findJob(catalog, m.job) === undefined)
|
|
303
|
+
return { omit: `metadata.job '${m.job}' is not a job in jobs.json` };
|
|
304
|
+
const structural = journeyRefIssues(catalog, cj, { enforce: true }).filter((i) => i.severity === "error");
|
|
305
|
+
if (structural.length > 0)
|
|
306
|
+
return { omit: structural.map(describeRefIssue).join("; ") };
|
|
307
|
+
const flat = flatJourneySteps(cj.journey);
|
|
308
|
+
const anchors = [];
|
|
309
|
+
for (const a of m.anchors ?? []) {
|
|
310
|
+
if (!CATALOG_ID_RE.test(a.name) || RESERVED.has(a.name))
|
|
311
|
+
return { omit: `anchor '${a.name}' is not a bundle anchor name (1-64 of [A-Za-z0-9._-], never job_start/job_end)` };
|
|
312
|
+
if (a.stepId !== undefined) {
|
|
313
|
+
const at = flat.findIndex((s) => s.recorded.stepId === a.stepId);
|
|
314
|
+
if (at < 0)
|
|
315
|
+
return { omit: `anchor '${a.name}' names stepId '${a.stepId}', which no step has` };
|
|
316
|
+
if (at + 1 !== a.step)
|
|
317
|
+
return { omit: `anchor '${a.name}': step ${a.step}, but its stepId '${a.stepId}' is step ${at + 1} — re-save the Journey so they agree` };
|
|
318
|
+
}
|
|
319
|
+
if (a.step > flat.length || a.step > MAX_STEP)
|
|
320
|
+
return { omit: `anchor '${a.name}': step ${a.step} is not one of its ${flat.length} steps (at most ${MAX_STEP})` };
|
|
321
|
+
if (m.job === undefined)
|
|
322
|
+
continue;
|
|
323
|
+
anchors.push({ name: a.name, step: a.step, ...(a.jobStep === undefined ? {} : { jobStep: a.jobStep }), ...(a.boundary === undefined ? {} : { boundary: a.boundary }) });
|
|
324
|
+
}
|
|
325
|
+
if (m.job === undefined)
|
|
326
|
+
return {};
|
|
327
|
+
return {
|
|
328
|
+
link: {
|
|
329
|
+
job: m.job,
|
|
330
|
+
...(m.persona === undefined ? {} : { persona: m.persona }),
|
|
331
|
+
...(anchors.length === 0 ? {} : { anchors }),
|
|
332
|
+
...(m.serves === undefined || m.serves.length === 0 ? {} : { serves: [...m.serves] }),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function bundleJourneys(catalog, refuse, warn) {
|
|
337
|
+
const out = [];
|
|
338
|
+
for (const cj of catalog.journeys) {
|
|
339
|
+
if (!cj.promoted) {
|
|
340
|
+
warn.push(`journey ${cj.id}: not promoted — left out (only promoted Journeys are exported)`);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const clear = [];
|
|
344
|
+
clearValues(cj.journey, "", clear);
|
|
345
|
+
for (const c of clear) {
|
|
346
|
+
if (c.secretField)
|
|
347
|
+
refuse.push(`journey ${cj.id}: ${c.path.slice(1)} types a credential-looking field in clear — make it a secret parameter`);
|
|
348
|
+
else if (looksPersonal(c.value))
|
|
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`);
|
|
350
|
+
}
|
|
351
|
+
const linked = journeyLink(catalog, cj);
|
|
352
|
+
// #478: a promoted Journey the bundle cannot carry refuses the export — never a silent omission.
|
|
353
|
+
if ("omit" in linked) {
|
|
354
|
+
refuse.push(`journey ${cj.id}: ${linked.omit} — fix it (the bundle cannot carry it), or demote it`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const file = asJson(cj.journey);
|
|
358
|
+
if (file.metadata.approval !== undefined)
|
|
359
|
+
file.metadata.approval = trimProvenance(file.metadata.approval);
|
|
360
|
+
out.push({ cj, entry: { journey: file, ...(linked.link === undefined ? {} : { link: linked.link }) } });
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
// ── Checks ───────────────────────────────────────────────────────────────────────────────────
|
|
365
|
+
const VERIFY_FIX_OUTCOME = {
|
|
366
|
+
fixed: "clean",
|
|
367
|
+
"still-reproduces": "defects-found",
|
|
368
|
+
intermittent: "intermittent",
|
|
369
|
+
inconclusive: "inconclusive",
|
|
370
|
+
};
|
|
371
|
+
const TARGET_KIND = { journey: "journey", goal: "goal", mission: "mission", "verify-fix": "verify_fix" };
|
|
372
|
+
function kebab(s) {
|
|
373
|
+
return s
|
|
374
|
+
.replace(/^E_/, "")
|
|
375
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
376
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
377
|
+
.replace(/^-+|-+$/g, "")
|
|
378
|
+
.toLowerCase()
|
|
379
|
+
.slice(0, 64);
|
|
380
|
+
}
|
|
381
|
+
/** A check item's canonical outcome (+ the Journey run's own ending, + why it is inconclusive). */
|
|
382
|
+
function itemOutcome(item) {
|
|
383
|
+
const raw = typeof item.outcome === "string" ? item.outcome : undefined;
|
|
384
|
+
const errorType = isRecord(item.error) && typeof item.error.type === "string" ? item.error.type : undefined;
|
|
385
|
+
if (item.verdict === "pending-review")
|
|
386
|
+
return { outcome: "pending-review", journeyOutcome: "healed-pending-review" };
|
|
387
|
+
if (raw !== undefined && JOURNEY_RUN_OUTCOMES.includes(raw)) {
|
|
388
|
+
const jo = raw;
|
|
389
|
+
return { outcome: JOURNEY_MISSION_OUTCOME[jo], journeyOutcome: jo };
|
|
390
|
+
}
|
|
391
|
+
if (raw !== undefined && MISSION_OUTCOMES.includes(raw)) {
|
|
392
|
+
const outcome = raw;
|
|
393
|
+
const kind = errorType !== undefined && errorType !== raw && outcome === "inconclusive" ? kebab(errorType) : "";
|
|
394
|
+
return { outcome, ...(FAILURE_KIND_RE.test(kind) ? { failureKind: kind } : {}) };
|
|
395
|
+
}
|
|
396
|
+
if (raw !== undefined && item.kind === "verify-fix" && VERIFY_FIX_OUTCOME[raw] !== undefined)
|
|
397
|
+
return { outcome: VERIFY_FIX_OUTCOME[raw] };
|
|
398
|
+
// `not-started`, `host-starved`, an item error with no verdict: inconclusive, and why (contract §12.3).
|
|
399
|
+
if (item.status === "error" || raw !== undefined) {
|
|
400
|
+
const why = kebab(raw ?? errorType ?? "error");
|
|
401
|
+
if (why === "crashed")
|
|
402
|
+
return { outcome: "crashed" };
|
|
403
|
+
return { outcome: "inconclusive", ...(FAILURE_KIND_RE.test(why) && why !== "inconclusive" ? { failureKind: why } : {}) };
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
function bundleTargetId(name) {
|
|
408
|
+
const id = name.replace(/[^A-Za-z0-9_.-]+/g, "-").slice(0, 64);
|
|
409
|
+
return TARGET_ID_RE.test(id) ? id : null;
|
|
410
|
+
}
|
|
411
|
+
function intMs(v) {
|
|
412
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0)
|
|
413
|
+
return null;
|
|
414
|
+
const ms = Math.round(v);
|
|
415
|
+
return ms <= MAX_MS ? ms : null;
|
|
416
|
+
}
|
|
417
|
+
function bundleBaseline(raw, journey) {
|
|
418
|
+
if (!isRecord(raw))
|
|
419
|
+
return { drop: "it is not a baseline record" };
|
|
420
|
+
const steps = raw.steps;
|
|
421
|
+
const totalMs = intMs(raw.totalMs);
|
|
422
|
+
if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_STEP)
|
|
423
|
+
return { drop: `its step count ${String(steps)} is not 1-${MAX_STEP}` };
|
|
424
|
+
if (totalMs === null)
|
|
425
|
+
return { drop: "its total time is not 0-86400000 ms" };
|
|
426
|
+
const flat = flatJourneySteps(journey);
|
|
427
|
+
const anchors = [];
|
|
428
|
+
for (const a of Array.isArray(raw.anchors) ? raw.anchors : []) {
|
|
429
|
+
if (!isRecord(a) || typeof a.name !== "string")
|
|
430
|
+
return { drop: "an anchor point has no name" };
|
|
431
|
+
if (!CATALOG_ID_RE.test(a.name))
|
|
432
|
+
return { drop: `anchor point '${a.name}' is not a bundle anchor name` };
|
|
433
|
+
let step;
|
|
434
|
+
if (typeof a.stepId === "string") {
|
|
435
|
+
const at = flat.findIndex((s) => s.recorded.stepId === a.stepId);
|
|
436
|
+
if (at >= 0)
|
|
437
|
+
step = at + 1;
|
|
438
|
+
}
|
|
439
|
+
if (step === undefined && typeof a.step === "number" && Number.isInteger(a.step))
|
|
440
|
+
step = a.step;
|
|
441
|
+
if (step === undefined && a.name === "job_start")
|
|
442
|
+
step = 1;
|
|
443
|
+
if (step === undefined && a.name === "job_end")
|
|
444
|
+
step = steps;
|
|
445
|
+
if (step === undefined || step < 1 || step > MAX_STEP)
|
|
446
|
+
return { drop: `anchor point '${a.name}' has no step` };
|
|
447
|
+
const atMs = intMs(a.atMs);
|
|
448
|
+
if (atMs === null)
|
|
449
|
+
return { drop: `anchor point '${a.name}': its time is not 0-86400000 ms` };
|
|
450
|
+
anchors.push({ anchor: a.name, step, atMs });
|
|
451
|
+
}
|
|
452
|
+
if (anchors.length > MAX_STEP + 2)
|
|
453
|
+
return { drop: "it has more anchor points than the bundle carries" };
|
|
454
|
+
return { baseline: { steps, totalMs, anchors } };
|
|
455
|
+
}
|
|
456
|
+
function bundleChecks(input, journeys, warn) {
|
|
457
|
+
const byId = new Map(journeys.map((j) => [j.cj.id, j.cj]));
|
|
458
|
+
const out = new Map();
|
|
459
|
+
for (const { source, record } of input.checks) {
|
|
460
|
+
const data = isRecord(record) && isRecord(record.data) ? record.data : record;
|
|
461
|
+
if (!isRecord(data) || data.kind !== "jevitate-check" || !Array.isArray(data.items)) {
|
|
462
|
+
warn.push(`${source}: not a jevitate check record — left out`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const commit = typeof data.targetBuild === "string" ? data.targetBuild.trim().toLowerCase() : "";
|
|
466
|
+
if (!COMMIT_RE.test(commit)) {
|
|
467
|
+
warn.push(`${source}: the check names no commit (run it with --target-build <commit sha>) — its items are left out`);
|
|
468
|
+
continue;
|
|
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
|
+
}
|
|
480
|
+
const at = typeof data.startedAt === "string" ? data.startedAt : "";
|
|
481
|
+
if (!DATE_TIME_RE.test(at)) {
|
|
482
|
+
warn.push(`${source}: the check's start time is not an ISO date-time — its items are left out`);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const checkRunId = `check-${at.replace(/[^0-9A-Za-z]+/g, "-").replace(/-+$/, "")}`;
|
|
486
|
+
for (const item of data.items) {
|
|
487
|
+
if (!isRecord(item) || item.kind === "approvals" || item.status === "skipped")
|
|
488
|
+
continue;
|
|
489
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
490
|
+
const label = `${source}: item ${JSON.stringify(name)}`;
|
|
491
|
+
const id = bundleTargetId(name);
|
|
492
|
+
const outcome = itemOutcome(item);
|
|
493
|
+
if (id === null || outcome === null) {
|
|
494
|
+
warn.push(`${label}: ${id === null ? "its name is not a bundle target id" : `outcome ${JSON.stringify(item.outcome)} is not one the bundle knows`} — left out`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const kind = typeof item.kind === "string" ? TARGET_KIND[item.kind] : undefined;
|
|
498
|
+
const runId = typeof item.runId === "string" && RUN_ID_RE.test(item.runId) ? item.runId : checkRunId;
|
|
499
|
+
const journeyId = item.kind === "journey" && CATALOG_ID_RE.test(name) ? name : undefined;
|
|
500
|
+
const journeyHash = typeof item.journeyHash === "string" && SHA256_RE.test(item.journeyHash) ? item.journeyHash : undefined;
|
|
501
|
+
const durationMs = intMs(item.durationMs);
|
|
502
|
+
let baseline;
|
|
503
|
+
if (item.baseline !== undefined) {
|
|
504
|
+
const cj = journeyId === undefined ? undefined : byId.get(journeyId);
|
|
505
|
+
let why;
|
|
506
|
+
if (outcome.outcome !== "clean")
|
|
507
|
+
why = `the run was ${outcome.outcome}, and a baseline comes only from a clean run`;
|
|
508
|
+
else if (journeyId === undefined || journeyHash === undefined)
|
|
509
|
+
why = "the item names no Journey review hash";
|
|
510
|
+
else if (cj === undefined)
|
|
511
|
+
why = `Journey ${journeyId} is not in the bundle`;
|
|
512
|
+
else if (cj.approval?.contentHash !== journeyHash || cj.contentHash !== journeyHash)
|
|
513
|
+
why = `it was measured on a Journey revision (${journeyHash.slice(0, 12)}…) that is not ${journeyId}'s approved one`;
|
|
514
|
+
else {
|
|
515
|
+
const b = bundleBaseline(item.baseline, cj.journey);
|
|
516
|
+
if ("drop" in b)
|
|
517
|
+
why = b.drop;
|
|
518
|
+
else
|
|
519
|
+
baseline = b.baseline;
|
|
520
|
+
}
|
|
521
|
+
if (why !== undefined)
|
|
522
|
+
warn.push(`${label}: baseline left out — ${why}`);
|
|
523
|
+
}
|
|
524
|
+
const check = {
|
|
525
|
+
target: { id, ...(kind === undefined ? {} : { kind }) },
|
|
526
|
+
commit,
|
|
527
|
+
...(appVersion === undefined ? {} : { appVersion }),
|
|
528
|
+
at,
|
|
529
|
+
runId,
|
|
530
|
+
...(journeyId === undefined ? {} : { journey: journeyId }),
|
|
531
|
+
...(journeyHash === undefined ? {} : { journeyHash }),
|
|
532
|
+
outcome: outcome.outcome,
|
|
533
|
+
exitCode: MISSION_EXIT_CODES[outcome.outcome],
|
|
534
|
+
...(outcome.journeyOutcome === undefined ? {} : { journeyOutcome: outcome.journeyOutcome }),
|
|
535
|
+
...(outcome.failureKind === undefined ? {} : { failureKind: outcome.failureKind }),
|
|
536
|
+
...(durationMs === null ? {} : { durationMs }),
|
|
537
|
+
...(baseline === undefined ? {} : { baseline }),
|
|
538
|
+
};
|
|
539
|
+
out.set(`${id}\u0000${commit}\u0000${runId}`, check);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return [...out.values()].sort((a, b) => a.at.localeCompare(b.at) || a.target.id.localeCompare(b.target.id) || (a.runId ?? "").localeCompare(b.runId ?? ""));
|
|
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
|
+
}
|
|
685
|
+
// ── Findings (the d464c hook's guard) ────────────────────────────────────────────────────────
|
|
686
|
+
/** Why a finding handed in by the d464c source breaks the contract's privacy rules (§7), or []. */
|
|
687
|
+
export function findingIssues(f) {
|
|
688
|
+
const out = [];
|
|
689
|
+
const where = `finding ${f.fingerprint}`;
|
|
690
|
+
if (!FINGERPRINT_RE.test(f.fingerprint))
|
|
691
|
+
out.push(`${where}: fingerprint is not 16 hex characters`);
|
|
692
|
+
if (f.route !== undefined && !isRouteTemplate(f.route))
|
|
693
|
+
out.push(`${where}: route ${JSON.stringify(f.route)} is not a route template (no query, fragment, host or record id)`);
|
|
694
|
+
if (f.tflowId !== undefined && !TFLOW_ID_RE.test(f.tflowId))
|
|
695
|
+
out.push(`${where}: tflowId is not a tflow id`);
|
|
696
|
+
const text = (k, v, max) => {
|
|
697
|
+
if (v === undefined)
|
|
698
|
+
return;
|
|
699
|
+
const p = machineTextProblem(v, max);
|
|
700
|
+
if (p !== null)
|
|
701
|
+
out.push(`${where}: ${k} ${p}`);
|
|
702
|
+
};
|
|
703
|
+
text("observation", f.observation, 600);
|
|
704
|
+
text("userImpact", f.userImpact, 600);
|
|
705
|
+
text("recommendation", f.recommendation, 600);
|
|
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
|
+
}
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
// ── The bundle ───────────────────────────────────────────────────────────────────────────────
|
|
732
|
+
/** Builds the bundle (pure). Throws `CatalogBundleInputError` when the catalog cannot be exported. */
|
|
733
|
+
export function buildCatalogBundle(input) {
|
|
734
|
+
const refuse = [];
|
|
735
|
+
const warn = [];
|
|
736
|
+
if (!SEMVER_RE.test(input.producer.version))
|
|
737
|
+
refuse.push(`producer version ${JSON.stringify(input.producer.version)} is not semver`);
|
|
738
|
+
const nameProblem = catalogTextProblem(input.productName);
|
|
739
|
+
if (nameProblem !== null || input.productName.length > 200)
|
|
740
|
+
refuse.push(`product name ${JSON.stringify(input.productName)} ${nameProblem ?? "is longer than 200 characters"}`);
|
|
741
|
+
const personas = bundlePersonas(input, refuse, warn);
|
|
742
|
+
const jobs = bundleJobs(input, refuse, warn);
|
|
743
|
+
const journeys = bundleJourneys(input.catalog, refuse, warn);
|
|
744
|
+
const findings = input.findings ?? [];
|
|
745
|
+
for (const f of findings)
|
|
746
|
+
refuse.push(...findingIssues(f));
|
|
747
|
+
for (const [what, n] of [["personas", personas.length], ["jobs", jobs.length], ["journeys", journeys.length], ["findings", findings.length]]) {
|
|
748
|
+
if (n > LIMITS[what])
|
|
749
|
+
refuse.push(`${n} ${what}: the bundle carries at most ${LIMITS[what]}`);
|
|
750
|
+
}
|
|
751
|
+
if (refuse.length > 0)
|
|
752
|
+
throw new CatalogBundleInputError(refuse);
|
|
753
|
+
let checks = bundleChecks(input, journeys, warn);
|
|
754
|
+
if (checks.length > LIMITS.checks) {
|
|
755
|
+
warn.push(`${checks.length} check results: only the latest ${LIMITS.checks} are exported`);
|
|
756
|
+
checks = checks.slice(-LIMITS.checks);
|
|
757
|
+
}
|
|
758
|
+
const commit = input.producer.commit !== undefined && COMMIT_RE.test(input.producer.commit) ? input.producer.commit : undefined;
|
|
759
|
+
const shown = bundleDemos(input, journeys, warn);
|
|
760
|
+
return {
|
|
761
|
+
media: shown.media,
|
|
762
|
+
bundle: {
|
|
763
|
+
kind: BUNDLE_KIND,
|
|
764
|
+
version: BUNDLE_VERSION,
|
|
765
|
+
minor: BUNDLE_MINOR,
|
|
766
|
+
producer: { tool: "jevitate", version: input.producer.version, ...(commit === undefined ? {} : { commit }) },
|
|
767
|
+
product: { name: input.productName },
|
|
768
|
+
catalog: { personas, jobs, journeys: journeys.map((j) => j.entry) },
|
|
769
|
+
...(shown.demos.length === 0 ? {} : { demos: shown.demos }),
|
|
770
|
+
checks,
|
|
771
|
+
findings: [...findings],
|
|
772
|
+
...(shown.demos.length === 0 ? {} : { files: shown.files }),
|
|
773
|
+
},
|
|
774
|
+
warnings: warn,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
//# sourceMappingURL=catalog-bundle.js.map
|