@jevitate/cli 0.11.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.
- package/dist/approval-provenance.d.ts +6 -0
- package/dist/approval-provenance.js +9 -1
- package/dist/approval-provenance.js.map +1 -1
- package/dist/author-journey-cli.js +2 -1
- package/dist/author-journey-cli.js.map +1 -1
- package/dist/bin.js +10188 -9336
- package/dist/build-info.generated.js +2 -2
- package/dist/demo-aspect-cli.js +2 -1
- package/dist/demo-aspect-cli.js.map +1 -1
- package/dist/explore-cli.js +3 -2
- package/dist/explore-cli.js.map +1 -1
- package/dist/explore-goal.js +4 -3
- package/dist/explore-goal.js.map +1 -1
- package/dist/journey-api.d.ts +12 -3
- package/dist/journey-api.js +34 -12
- package/dist/journey-api.js.map +1 -1
- package/dist/journey-cli.d.ts +0 -1
- package/dist/journey-cli.js +88 -17
- package/dist/journey-cli.js.map +1 -1
- package/dist/journey-labels-migrate-api.d.ts +100 -0
- package/dist/journey-labels-migrate-api.js +194 -0
- package/dist/journey-labels-migrate-api.js.map +1 -0
- package/dist/journey-lint-sarif.js +4 -0
- package/dist/journey-lint-sarif.js.map +1 -1
- package/dist/journey-review.d.ts +12 -2
- package/dist/journey-review.js +522 -108
- package/dist/journey-review.js.map +1 -1
- package/dist/mcp-cli-tools.js +2 -1
- package/dist/mcp-cli-tools.js.map +1 -1
- package/dist/regression-api.js +6 -4
- package/dist/regression-api.js.map +1 -1
- package/dist/review-render-options.d.ts +17 -0
- package/dist/review-render-options.js +17 -0
- package/dist/review-render-options.js.map +1 -0
- package/dist/ux-usability.js +5 -2
- package/dist/ux-usability.js.map +1 -1
- package/dist/verify-fix-api.js +2 -0
- package/dist/verify-fix-api.js.map +1 -1
- package/package.json +9 -9
package/dist/journey-review.js
CHANGED
|
@@ -2,7 +2,7 @@ import { contentHash } from "@jevitate/domain";
|
|
|
2
2
|
import { describeProvenance } from "./approval-provenance.js";
|
|
3
3
|
import { SECRET_PARAM_NAME_RE, describeStep, flatJourneySteps, isNoClaimExpect, isOwnTargetVisible, journeyAssertions, journeyEndState, journeyRunParams, journeyWriteSteps, lintJourney, secretParamNames, } from "@jevitate/journey";
|
|
4
4
|
import { renderFindings } from "./catalog-review.js";
|
|
5
|
-
import { SafetyPolicy, describeCheck } from "@jevitate/explore";
|
|
5
|
+
import { SafetyPolicy, describeCheck, explainCheck } from "@jevitate/explore";
|
|
6
6
|
import { navigateUrlParams, writeClassifier } from "@jevitate/recording";
|
|
7
7
|
/**
|
|
8
8
|
* #432 — `jevitate journey review <id>`: one human-readable review sheet for promotion sign-off.
|
|
@@ -45,7 +45,7 @@ function reviewTarget(t) {
|
|
|
45
45
|
}
|
|
46
46
|
/** Does the field read as a credential (its name, label or test id)? Its literal value is then never shown. */
|
|
47
47
|
function secretLooking(t) {
|
|
48
|
-
return [t.name, t.label, t.testId, t.text].some((s) => s !== undefined && SECRET_PARAM_NAME_RE.test(s));
|
|
48
|
+
return [t.name, t.label, t.testId, t.text, t.display?.name].some((s) => s !== undefined && SECRET_PARAM_NAME_RE.test(s));
|
|
49
49
|
}
|
|
50
50
|
function hideValue(v) {
|
|
51
51
|
return "var" in v || v.redacted ? v : { redacted: true, length: 0 };
|
|
@@ -91,6 +91,28 @@ function evidenceLine(e) {
|
|
|
91
91
|
const where = e.file === undefined ? "" : ` (${e.file}${e.line === undefined ? "" : `:${e.line}`})`;
|
|
92
92
|
return `${e.kind}${change}${where}`;
|
|
93
93
|
}
|
|
94
|
+
/** A target's exact locator, e.g. `data-testid=publish-now` or `role=button name="Save"`. */
|
|
95
|
+
function locatorSpec(t) {
|
|
96
|
+
if (t.testId !== undefined)
|
|
97
|
+
return `${t.testIdAttr ?? "data-testid"}=${t.testId}`;
|
|
98
|
+
const parts = [
|
|
99
|
+
t.role === undefined ? null : `role=${t.role}`,
|
|
100
|
+
t.name === undefined ? null : `name="${t.name}"`,
|
|
101
|
+
t.label === undefined ? null : `label="${t.label}"`,
|
|
102
|
+
t.text === undefined ? null : `text="${t.text}"`,
|
|
103
|
+
t.css === undefined ? null : `css=${t.css}`,
|
|
104
|
+
].filter((x) => x !== null);
|
|
105
|
+
return parts.join(" ");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* #497: a self-heal proposal changes a step's LOCATOR, which a recorded display label hides
|
|
109
|
+
* (`click "Import"` before and after) — so a proposal's step shows its exact locator too.
|
|
110
|
+
*/
|
|
111
|
+
function withLocator(step) {
|
|
112
|
+
const target = targetOf(step);
|
|
113
|
+
const spec = target === undefined ? "" : locatorSpec(target);
|
|
114
|
+
return spec === "" ? describeStep(sanitized(step)) : `${describeStep(sanitized(step))} · locator ${spec}`;
|
|
115
|
+
}
|
|
94
116
|
function buildProposal(ctx, journeyId) {
|
|
95
117
|
const p = ctx.proposal;
|
|
96
118
|
return {
|
|
@@ -103,8 +125,8 @@ function buildProposal(ctx, journeyId) {
|
|
|
103
125
|
changes: { ...(p.changes.range === undefined ? {} : { range: p.changes.range }), ...(p.changes.baseSha === undefined ? {} : { baseSha: p.changes.baseSha }), ...(p.changes.headSha === undefined ? {} : { headSha: p.changes.headSha }), notes: [...p.changes.notes] },
|
|
104
126
|
steps: p.steps.map((s) => ({
|
|
105
127
|
number: s.index + 1,
|
|
106
|
-
before:
|
|
107
|
-
after:
|
|
128
|
+
before: withLocator(s.before),
|
|
129
|
+
after: withLocator(s.after),
|
|
108
130
|
hypothesis: s.justification.hypothesis + (s.justification.anchorNotInChange === true ? " (the new anchor is not named by any change evidence)" : ""),
|
|
109
131
|
evidence: s.justification.evidence.map(evidenceLine),
|
|
110
132
|
screenshots: [...(s.screenshots ?? [])],
|
|
@@ -113,15 +135,34 @@ function buildProposal(ctx, journeyId) {
|
|
|
113
135
|
acceptCommand: `jevitate journey promote ${journeyId} --proposal ${p.proposalId} --reviewed-hash ${p.proposedHash}`,
|
|
114
136
|
};
|
|
115
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* 0.12.0 review M4: the short exact locator shown beside a step's recorded display label — the
|
|
140
|
+
* test id when there is one, else role and name (else the full `locatorSpec`). A display label is
|
|
141
|
+
* free text a shared Journey can set (`{testId: "delete-account", display: {name: "Cancel"}}`), so
|
|
142
|
+
* the approver sees what the step really acts on. Undefined when the target has no display label.
|
|
143
|
+
*/
|
|
144
|
+
function displayedLocator(t) {
|
|
145
|
+
if (t === undefined || t.display === undefined)
|
|
146
|
+
return undefined;
|
|
147
|
+
const short = t.testId !== undefined
|
|
148
|
+
? { testId: t.testId, ...(t.testIdAttr === undefined ? {} : { testIdAttr: t.testIdAttr }) }
|
|
149
|
+
: t.role !== undefined || t.name !== undefined
|
|
150
|
+
? { ...(t.role === undefined ? {} : { role: t.role }), ...(t.name === undefined ? {} : { name: t.name }) }
|
|
151
|
+
: t;
|
|
152
|
+
const spec = locatorSpec(short);
|
|
153
|
+
return spec === "" ? undefined : spec;
|
|
154
|
+
}
|
|
116
155
|
function buildSteps(journey) {
|
|
117
156
|
return flatJourneySteps(journey).map(({ index, recorded }) => {
|
|
118
157
|
const step = recorded.step;
|
|
119
158
|
const target = reviewTarget(targetOf(step));
|
|
120
159
|
const assertion = ownAssertion(step);
|
|
160
|
+
const locator = displayedLocator(targetOf(step));
|
|
121
161
|
return {
|
|
122
162
|
number: index + 1,
|
|
123
163
|
kind: step.kind,
|
|
124
164
|
action: describeStep(sanitized(step)),
|
|
165
|
+
...(locator === undefined ? {} : { locator }),
|
|
125
166
|
...(target === undefined ? {} : { target }),
|
|
126
167
|
...(recorded.objective === undefined || recorded.objective.trim() === "" ? {} : { objective: recorded.objective }),
|
|
127
168
|
...(recorded.expectedResult === undefined || recorded.expectedResult.trim() === "" ? {} : { expectedResult: recorded.expectedResult }),
|
|
@@ -328,13 +369,25 @@ export function buildJourneyReview(journey, ctx = {}) {
|
|
|
328
369
|
const stepAssertions = journeyAssertions(journey).flatMap((site) => {
|
|
329
370
|
if (site.where === "end-state")
|
|
330
371
|
return [];
|
|
372
|
+
const explained = explainCheck(site.check);
|
|
331
373
|
if (site.where === "step-request")
|
|
332
|
-
return [{ step: site.step, check: describeCheck(site.check), weak:
|
|
374
|
+
return [{ step: site.step, check: describeCheck(site.check), weak: explained.weak, text: explained.text }];
|
|
333
375
|
const step = flat[site.step - 1]?.recorded.step;
|
|
334
|
-
const weak = site.check.assertion.kind === "visible" || (site.field === "expect" && step !== undefined && (isOwnTargetVisible(step) || isNoClaimExpect(step)));
|
|
335
|
-
return [{ step: site.step, check: describeCheck(site.check), weak }];
|
|
376
|
+
const weak = explained.weak || site.check.assertion.kind === "visible" || (site.field === "expect" && step !== undefined && (isOwnTargetVisible(step) || isNoClaimExpect(step)));
|
|
377
|
+
return [{ step: site.step, check: describeCheck(site.check), weak, text: explained.text }];
|
|
336
378
|
});
|
|
337
|
-
|
|
379
|
+
const endStateChecks = journeyEndState(journey);
|
|
380
|
+
const endStateExplained = endStateChecks.map(explainCheck);
|
|
381
|
+
const anchors = (m.anchors ?? []).map((a) => ({ name: a.name, step: a.step }));
|
|
382
|
+
const writeRequests = buildWriteRequests(journey);
|
|
383
|
+
const enriched = steps.map((st) => {
|
|
384
|
+
const checks = stepAssertions.filter((a) => a.step === st.number).map((a) => ({ text: a.text, weak: a.weak }));
|
|
385
|
+
const mine = anchors.filter((a) => a.step === st.number).map((a) => a.name);
|
|
386
|
+
const rec = flat[st.number - 1]?.recorded.step;
|
|
387
|
+
const file = rec !== undefined && rec.kind === "upload" ? describeFile(sanitized(rec)) : undefined;
|
|
388
|
+
return { ...st, ...(checks.length === 0 ? {} : { checks }), ...(mine.length === 0 ? {} : { anchors: mine }), ...(file === undefined ? {} : { file }) };
|
|
389
|
+
});
|
|
390
|
+
const review = {
|
|
338
391
|
id: m.id,
|
|
339
392
|
name: m.name,
|
|
340
393
|
promoted: m.promoted,
|
|
@@ -343,6 +396,7 @@ export function buildJourneyReview(journey, ctx = {}) {
|
|
|
343
396
|
...(m.goal === undefined ? {} : { goal: m.goal }),
|
|
344
397
|
...(m.persona === undefined ? {} : { persona: m.persona }),
|
|
345
398
|
...(m.role === undefined ? {} : { role: m.role }),
|
|
399
|
+
...((journey.recording.intent ?? "").trim() === "" ? {} : { intent: journey.recording.intent.trim() }),
|
|
346
400
|
requiresAuth: m.requiresAuth === true,
|
|
347
401
|
preconditions: (m.preconditions ?? []).map((p) => p.description),
|
|
348
402
|
successCriteria: (m.successCriteria ?? []).map((c) => ({
|
|
@@ -351,8 +405,8 @@ export function buildJourneyReview(journey, ctx = {}) {
|
|
|
351
405
|
})),
|
|
352
406
|
missingIntent,
|
|
353
407
|
},
|
|
354
|
-
steps,
|
|
355
|
-
sideEffects: { writeRequests
|
|
408
|
+
steps: enriched,
|
|
409
|
+
sideEffects: { writeRequests, riskyControls: buildRiskyControls(journey, ctx.safety), origins: buildOrigins(journey) },
|
|
356
410
|
inputs: {
|
|
357
411
|
params: paramNames.map((name) => {
|
|
358
412
|
const description = described.get(name);
|
|
@@ -361,7 +415,8 @@ export function buildJourneyReview(journey, ctx = {}) {
|
|
|
361
415
|
secrets: (m.secretRefs ?? []).map((r) => ({ field: r.field, manager: r.manager, origin: r.origin })),
|
|
362
416
|
},
|
|
363
417
|
proof: {
|
|
364
|
-
endState:
|
|
418
|
+
endState: endStateChecks.map(describeCheck),
|
|
419
|
+
endStateText: endStateExplained.map((e) => e.text),
|
|
365
420
|
stepAssertions,
|
|
366
421
|
lint: {
|
|
367
422
|
errors: findings.filter((f) => f.level === "error").length,
|
|
@@ -392,8 +447,118 @@ export function buildJourneyReview(journey, ctx = {}) {
|
|
|
392
447
|
...(ctx.anchorIssues === undefined ? {} : { anchorWarnings: anchorWarningsOf(ctx.anchorIssues) }),
|
|
393
448
|
...(ctx.locatorHealth === undefined ? {} : { locatorHealth: locatorHealthOf(ctx.locatorHealth) }),
|
|
394
449
|
...(ctx.proposal === undefined ? {} : { proposal: buildProposal(ctx.proposal, m.id) }),
|
|
450
|
+
...(anchors.length === 0 ? {} : { anchors }),
|
|
395
451
|
contentHash: hash,
|
|
396
452
|
};
|
|
453
|
+
return { ...review, attention: buildAttention(review, endStateExplained.filter((e) => e.weak).map((e) => e.text), m.goal) };
|
|
454
|
+
}
|
|
455
|
+
/** An upload step's file in words: the recorded path, `{var}` or «redacted». */
|
|
456
|
+
function describeFile(step) {
|
|
457
|
+
const f = step.file;
|
|
458
|
+
if (f === undefined)
|
|
459
|
+
return undefined;
|
|
460
|
+
if ("var" in f)
|
|
461
|
+
return `{${f.var}}`;
|
|
462
|
+
return f.redacted ? "«redacted»" : f.value;
|
|
463
|
+
}
|
|
464
|
+
/** Step numbers as ranges: 1, 2, 3, 5 becomes `1–3, 5`. */
|
|
465
|
+
export function stepRanges(numbers) {
|
|
466
|
+
const sorted = [...new Set(numbers)].sort((a, b) => a - b);
|
|
467
|
+
const parts = [];
|
|
468
|
+
for (let i = 0; i < sorted.length;) {
|
|
469
|
+
let j = i;
|
|
470
|
+
while (j + 1 < sorted.length && sorted[j + 1] === sorted[j] + 1)
|
|
471
|
+
j++;
|
|
472
|
+
parts.push(j === i ? `${sorted[i]}` : `${sorted[i]}–${sorted[j]}`);
|
|
473
|
+
i = j + 1;
|
|
474
|
+
}
|
|
475
|
+
return parts.join(", ");
|
|
476
|
+
}
|
|
477
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
478
|
+
/** Everything that needs the reviewer's decision, warnings first (stable within a severity). */
|
|
479
|
+
function buildAttention(r, weakEndState, goal) {
|
|
480
|
+
const out = [];
|
|
481
|
+
const warn = (text, hint) => void out.push({ severity: "warn", text, ...(hint === undefined ? {} : { hint }) });
|
|
482
|
+
const info = (text, hint) => void out.push({ severity: "info", text, ...(hint === undefined ? {} : { hint }) });
|
|
483
|
+
const weakSteps = [...new Set(r.proof.stepAssertions.filter((a) => a.weak).map((a) => a.step))];
|
|
484
|
+
if (weakSteps.length === 1)
|
|
485
|
+
warn(`Step ${stepRanges(weakSteps)} proves nothing: its check passes even if the step fails`);
|
|
486
|
+
else if (weakSteps.length > 1)
|
|
487
|
+
warn(`Steps ${stepRanges(weakSteps)} prove nothing: their checks pass even if the step fails`);
|
|
488
|
+
for (const t of weakEndState)
|
|
489
|
+
warn(`An end-state check proves nothing: ${t}`);
|
|
490
|
+
const annotate = `jevitate journey annotate ${r.id}`;
|
|
491
|
+
const noGoal = (goal ?? "").trim() === "";
|
|
492
|
+
const noCriteria = r.summary.successCriteria.length === 0;
|
|
493
|
+
if (noGoal && noCriteria)
|
|
494
|
+
warn("No goal or success criteria written", annotate);
|
|
495
|
+
else if (noGoal)
|
|
496
|
+
warn("No goal written", annotate);
|
|
497
|
+
else if (noCriteria)
|
|
498
|
+
warn("No success criteria written", annotate);
|
|
499
|
+
for (const f of r.proof.lint.findings) {
|
|
500
|
+
warn(`Lint ${f.level} ${f.rule}${f.step === undefined ? "" : ` (step ${f.step})`}: ${f.message}`);
|
|
501
|
+
}
|
|
502
|
+
for (const c of r.sideEffects.riskyControls) {
|
|
503
|
+
const text = `Risky control at step ${c.step}: ${c.role === undefined ? "" : `${c.role} `}"${c.control}" (${c.risk})`;
|
|
504
|
+
if (c.waivedBy === undefined)
|
|
505
|
+
warn(text);
|
|
506
|
+
else
|
|
507
|
+
info(`${text}, exempted by allowControl`);
|
|
508
|
+
}
|
|
509
|
+
if (r.catalog !== undefined) {
|
|
510
|
+
if (r.catalog.unvetted.length > 0)
|
|
511
|
+
warn(`Unvetted catalog links: ${r.catalog.unvetted.join(", ")}`, `approve them, or promote with --accept-unvetted "<reason>"`);
|
|
512
|
+
if (r.catalog.needsReReview.length > 0)
|
|
513
|
+
warn(`Catalog links need re-review: ${r.catalog.needsReReview.join("; ")}`);
|
|
514
|
+
}
|
|
515
|
+
for (const f of r.findings ?? []) {
|
|
516
|
+
if (f.analyzer === "catalog-analysis")
|
|
517
|
+
continue;
|
|
518
|
+
if (f.severity !== "info")
|
|
519
|
+
warn(f.message, f.fix);
|
|
520
|
+
else if (f.analyzer === "readiness" || f.analyzer === "readiness-jev")
|
|
521
|
+
info(f.message, f.fix);
|
|
522
|
+
}
|
|
523
|
+
const c = r.changeSinceApproval;
|
|
524
|
+
if ((c.kind === "diff" || c.kind === "snapshot-missing") && c.changed) {
|
|
525
|
+
if (c.kind === "diff") {
|
|
526
|
+
const bit = (sign, n, word, many) => (n === 0 ? [] : [`${sign}${plural(n, word, many)}`]);
|
|
527
|
+
const parts = [
|
|
528
|
+
...bit("+", c.steps.added.length, "step"),
|
|
529
|
+
...bit("−", c.steps.removed.length, "step"),
|
|
530
|
+
...bit("+", c.assertions.added.length, "check"),
|
|
531
|
+
...bit("−", c.assertions.removed.length, "check"),
|
|
532
|
+
...bit("+", c.sideEffects.added.length, "side effect"),
|
|
533
|
+
...bit("−", c.sideEffects.removed.length, "side effect"),
|
|
534
|
+
];
|
|
535
|
+
warn(`Changed since approval${parts.length === 0 ? "" : `: ${parts.join(", ")}`}${c.stepIdOnly === true ? " (step ids only)" : ""}`, "--full lists every change");
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
warn(`Changed since approval (its approved snapshot is missing, so only the hash differs)${c.stepIdOnly === true ? " (step ids only)" : ""}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
for (const w of r.anchorWarnings ?? [])
|
|
542
|
+
warn(`Anchor ${w.severity} ${w.path}${w.step === undefined ? "" : ` (step ${w.step})`}: ${w.message}`, w.fix);
|
|
543
|
+
if (r.proposal !== undefined) {
|
|
544
|
+
const p = r.proposal;
|
|
545
|
+
if (p.stale || p.problem !== undefined)
|
|
546
|
+
warn(`A self-heal proposal is pending but cannot be accepted: ${p.stale ? "the Journey changed since" : p.problem}`);
|
|
547
|
+
else
|
|
548
|
+
warn(`A self-heal proposal changes ${plural(p.steps.length, "step")}`, p.acceptCommand);
|
|
549
|
+
}
|
|
550
|
+
const v = r.proof.verify;
|
|
551
|
+
if (v.status === "not-verified")
|
|
552
|
+
info("Not mutation-tested", `jevitate journey verify ${r.id} --mutate`);
|
|
553
|
+
else if (v.stale)
|
|
554
|
+
warn("The mutation proof is out of date: the Journey changed after it ran", `jevitate journey verify ${r.id} --mutate`);
|
|
555
|
+
else if (v.verdict === "insensitive")
|
|
556
|
+
warn("Mutation proof: some checks cannot fail", `jevitate journey verify ${r.id} --mutate`);
|
|
557
|
+
if (r.proof.acceptedWeak !== undefined)
|
|
558
|
+
info(`Weak checks were accepted: "${r.proof.acceptedWeak.reason}"`);
|
|
559
|
+
if (r.locatorHealth !== undefined && r.locatorHealth.brittle > 0)
|
|
560
|
+
info(`${plural(r.locatorHealth.brittle, "step")} on brittle locators`, "--full lists the fixes");
|
|
561
|
+
return [...out.filter((a) => a.severity === "warn"), ...out.filter((a) => a.severity === "info")];
|
|
397
562
|
}
|
|
398
563
|
/** #437: a copy of an approval's provenance (the sheet never shares the Journey's objects). */
|
|
399
564
|
function copyProvenance(p) {
|
|
@@ -454,125 +619,374 @@ function prLines(p) {
|
|
|
454
619
|
];
|
|
455
620
|
return [`Pull-request approval: ${parts.join(" · ")}`];
|
|
456
621
|
}
|
|
457
|
-
|
|
622
|
+
const DEFAULT_WIDTH = 100;
|
|
623
|
+
const NB = " ";
|
|
624
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
625
|
+
/** `2026-10-11T08:33:26.979Z` as `11 Oct 2026 08:33 UTC` — parsed, not locale-formatted, so it is deterministic. */
|
|
626
|
+
export function humanTime(iso) {
|
|
627
|
+
const m = /^(\d{4})-(\d\d)-(\d\d)T(\d\d):(\d\d)/.exec(iso);
|
|
628
|
+
if (m === null)
|
|
629
|
+
return iso;
|
|
630
|
+
return `${Number(m[3])} ${MONTHS[Number(m[2]) - 1] ?? m[2]} ${m[1]} ${m[4]}:${m[5]} UTC`;
|
|
631
|
+
}
|
|
632
|
+
/** Spaces inside a command or hash must not become line breaks. */
|
|
633
|
+
const nb = (s) => s.replace(/ /g, NB);
|
|
634
|
+
/** Wraps `text` after `prefix`; continuation lines are indented `hang` columns. Words (and nb-joined commands) are never cut. */
|
|
635
|
+
function wrapText(prefix, text, width, hang = prefix.length) {
|
|
636
|
+
const words = text.split(" ").filter((w) => w !== "");
|
|
637
|
+
const lines = [];
|
|
638
|
+
let cur = prefix;
|
|
639
|
+
let empty = true;
|
|
640
|
+
for (const w of words) {
|
|
641
|
+
if (!empty && cur.length + 1 + w.length > width) {
|
|
642
|
+
lines.push(cur);
|
|
643
|
+
cur = " ".repeat(hang);
|
|
644
|
+
empty = true;
|
|
645
|
+
}
|
|
646
|
+
cur += `${empty ? "" : " "}${w}`;
|
|
647
|
+
empty = false;
|
|
648
|
+
}
|
|
649
|
+
lines.push(cur);
|
|
650
|
+
return lines.map((l) => l.replaceAll(NB, " ").trimEnd());
|
|
651
|
+
}
|
|
652
|
+
const HEADERS = ["NEEDS YOUR ATTENTION", "WHAT IT DOES", "STEPS", "PROVES AT THE END", "WRITES", "CLEAR", "APPROVE", "INPUTS", "PROPOSED REVISION (SELF-HEAL)", "DETAIL", "Review"];
|
|
653
|
+
function paint(line, color) {
|
|
654
|
+
if (!color)
|
|
655
|
+
return line;
|
|
656
|
+
const header = HEADERS.find((h) => line.startsWith(h) && (line.length === h.length || line[h.length] === " "));
|
|
657
|
+
const body = line.replaceAll("⚠", "\u001b[33m⚠\u001b[0m").replaceAll("✓", "\u001b[32m✓\u001b[0m");
|
|
658
|
+
return header === undefined ? body : `\u001b[1m${header}\u001b[0m${body.slice(header.length)}`;
|
|
659
|
+
}
|
|
660
|
+
function renderSheet(r, style, opts = {}) {
|
|
458
661
|
const md = style === "markdown";
|
|
662
|
+
const width = Math.max(40, opts.width ?? DEFAULT_WIDTH);
|
|
663
|
+
const full = opts.full === true;
|
|
459
664
|
const code = (s) => (md ? `\`${s.replace(/`/g, "'")}\`` : s);
|
|
460
|
-
const h1 = (s) => (md ? `# ${s}` : `${s}\n${"=".repeat(Math.min(s.length, 80))}`);
|
|
461
|
-
const h2 = (s) => (md ? `## ${s}` : `${s.toUpperCase()}`);
|
|
462
|
-
const h3 = (s) => (md ? `### ${s}` : `${s}:`);
|
|
463
665
|
const em = (s) => (md ? `_${s}_` : s);
|
|
464
|
-
const li = (s, depth = 0) => `${" ".repeat(md ? depth : depth + 1)}- ${s}`;
|
|
465
666
|
const out = [];
|
|
466
|
-
|
|
467
|
-
|
|
667
|
+
/** One item: wrapped and hanging in text, a single line in Markdown. */
|
|
668
|
+
const put = (prefix, text, hang) => {
|
|
669
|
+
if (md)
|
|
670
|
+
out.push(`${prefix}${text}`.replaceAll(NB, " "));
|
|
671
|
+
else
|
|
672
|
+
out.push(...wrapText(prefix, text, width, hang));
|
|
468
673
|
};
|
|
469
|
-
const
|
|
470
|
-
section
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
};
|
|
482
|
-
sub(`Objective: ${st.objective ?? em("none")}`);
|
|
483
|
-
if (st.expectedResult !== undefined)
|
|
484
|
-
sub(`Expected result: ${st.expectedResult}`);
|
|
485
|
-
if (st.assertion !== undefined)
|
|
486
|
-
sub(`Checks: ${code(st.assertion)}`);
|
|
487
|
-
if (st.params.length > 0)
|
|
488
|
-
sub(`Params: ${st.params.map(code).join(", ")}`);
|
|
489
|
-
}
|
|
490
|
-
section(h2("Steps"), "", ...(stepLinesOut.length === 0 ? [li(em("no steps"))] : stepLinesOut));
|
|
491
|
-
const fx = r.sideEffects;
|
|
492
|
-
section(h2("Side effects"), "", h3("Write requests"), ...list(fx.writeRequests.map((w) => `${code(`${w.method} ${w.endpoint}`)} — ${w.source === "recorded" ? `recorded at step ${w.step}` : w.source === "expect-request" ? `expected at step ${w.step}` : "end-state check"}`), "none expected"), "", h3("Controls matching safety rules"), ...list(fx.riskyControls.map((c) => `step ${c.step}: ${c.role === undefined ? "" : `${c.role} `}"${c.control}" — ${c.risk} [${code(c.ruleId)}]${c.waivedBy === undefined ? "" : ` (exempted by allowControl ${code(c.waivedBy)})`}`), "none"), "", h3("Origins"), ...list(fx.origins.map(code), "none"));
|
|
493
|
-
section(h2("Inputs"), "", h3("Parameters"), ...list(r.inputs.params.map((p) => `${code(p.name)}${p.secret ? " (secret)" : ""}${p.description === undefined ? "" : ` — ${p.description}`}`), "none"), "", h3("Secret references"), ...list(r.inputs.secrets.map((x) => `${code(x.field)} from ${x.manager} on ${x.origin}`), "none"));
|
|
494
|
-
const p = r.proof;
|
|
495
|
-
const verify = p.verify.status === "not-verified"
|
|
496
|
-
? `not verified — ${p.verify.hint}`
|
|
497
|
-
: `${p.verify.verdict} (${p.verify.at})${p.verify.stale ? " — STALE: the Journey changed after this proof ran" : ""}${p.verify.reason === undefined ? "" : ` — ${p.verify.reason}`}: ${Object.entries(p.verify.summary)
|
|
498
|
-
.map(([k, v]) => `${v} ${k}`)
|
|
499
|
-
.join(", ")}`;
|
|
500
|
-
section(h2("Proof"), "", h3("End-state checks"), ...list(p.endState.map(code), "none"), "", h3("Per-step assertions"), ...list(p.stepAssertions.map((a) => `step ${a.step}: ${code(a.check)}${a.weak ? " (weak: proves nothing about the step's effect)" : ""}`), "none"), "", h3(`Lint: ${p.lint.errors} error(s), ${p.lint.warnings} warning(s)`), ...list(p.lint.findings.map((f) => `${f.level} ${f.rule}${f.step === undefined ? "" : ` (step ${f.step})`}: ${f.message}`), "no findings"), ...(p.acceptedWeak === undefined ? [] : ["", `Accepted weak: "${p.acceptedWeak.reason}" (waived: ${p.acceptedWeak.rules.join(", ")})`]), "", `Mutation proof: ${verify}`);
|
|
674
|
+
const gap = () => void out.push("");
|
|
675
|
+
/** A section header: caps in text, a heading in Markdown. */
|
|
676
|
+
const head = (label, title = label.charAt(0) + label.slice(1).toLowerCase()) => {
|
|
677
|
+
if (!md)
|
|
678
|
+
out.push(label);
|
|
679
|
+
else
|
|
680
|
+
out.push(`## ${title}`, "");
|
|
681
|
+
};
|
|
682
|
+
const bullet = md ? "- " : " ";
|
|
683
|
+
const cmd = (c) => (md ? code(c) : nb(c));
|
|
684
|
+
// ── Header
|
|
685
|
+
const prov = r.approval?.provenance;
|
|
501
686
|
const c = r.changeSinceApproval;
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
687
|
+
const changed = (c.kind === "diff" || c.kind === "snapshot-missing") && c.changed;
|
|
688
|
+
const plainApproval = prov !== undefined && prov.channel === "tty" && prov.agentSignals.filter((x) => !/not.?tty/i.test(x)).length === 0;
|
|
689
|
+
const approvedBy = prov?.user === undefined ? "" : ` by ${prov.user}`;
|
|
690
|
+
const status = !r.promoted
|
|
691
|
+
? "not promoted"
|
|
692
|
+
: r.approval === undefined
|
|
693
|
+
? "promoted (no approval recorded)"
|
|
694
|
+
: `approved ${humanTime(r.approval.at)}${plainApproval ? `${approvedBy} (terminal)` : ` — ${describeProvenance(prov)}`} · ${nb(changed ? "CHANGED since approval" : "unchanged since approval")}`;
|
|
695
|
+
if (md)
|
|
696
|
+
out.push(`# Review: ${r.name}`, "", `**${r.id}** · ${status}`);
|
|
697
|
+
else {
|
|
698
|
+
put("Review ", r.name);
|
|
699
|
+
put(" ", `${r.id} · ${status}`);
|
|
700
|
+
}
|
|
701
|
+
for (const l of prLines(prov))
|
|
702
|
+
put(md ? "" : " ", l);
|
|
703
|
+
gap();
|
|
704
|
+
// ── Needs your attention
|
|
705
|
+
const attention = r.attention ?? [];
|
|
706
|
+
if (attention.length === 0) {
|
|
707
|
+
if (md)
|
|
708
|
+
out.push("## Needs your attention", "", "Nothing — every gate is clear.");
|
|
709
|
+
else
|
|
710
|
+
out.push("NEEDS YOUR ATTENTION nothing — every gate is clear");
|
|
511
711
|
}
|
|
512
712
|
else {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
["Assertions", c.assertions],
|
|
519
|
-
["Side effects", c.sideEffects],
|
|
520
|
-
]) {
|
|
521
|
-
if (d.added.length === 0 && d.removed.length === 0)
|
|
522
|
-
continue;
|
|
523
|
-
change.push("", h3(label), ...d.removed.map((l) => li(`− ${l}`)), ...d.added.map((l) => li(`+ ${l}`)));
|
|
713
|
+
head("NEEDS YOUR ATTENTION", "Needs your attention");
|
|
714
|
+
for (const a of attention) {
|
|
715
|
+
const icon = a.severity === "warn" ? "⚠" : "○";
|
|
716
|
+
const hint = a.hint === undefined ? "" : ` → ${/^(jevitate|--)/.test(a.hint) ? cmd(a.hint) : a.hint}`;
|
|
717
|
+
put(`${bullet}${icon} `, `${a.text}${hint}`, bullet.length + 2);
|
|
524
718
|
}
|
|
525
719
|
}
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
720
|
+
gap();
|
|
721
|
+
// ── What it does
|
|
722
|
+
const s = r.summary;
|
|
723
|
+
const wid = [];
|
|
724
|
+
const primary = s.goal ?? s.intent ?? s.description;
|
|
725
|
+
head("WHAT IT DOES", "What it does");
|
|
726
|
+
if (primary !== undefined)
|
|
727
|
+
put(bullet, primary);
|
|
728
|
+
if (s.goal !== undefined && s.intent !== undefined && s.intent !== s.goal && full)
|
|
729
|
+
put(bullet, s.intent);
|
|
730
|
+
if (s.description !== undefined && s.description !== primary && full)
|
|
731
|
+
put(bullet, s.description);
|
|
732
|
+
for (const cr of s.successCriteria)
|
|
733
|
+
put(bullet, `Success: ${cr.description}${cr.check === undefined || !full ? "" : ` (checked by ${code(cr.check)})`}`);
|
|
734
|
+
for (const pc of s.preconditions)
|
|
735
|
+
put(bullet, `Needs: ${pc}`);
|
|
736
|
+
if (s.persona !== undefined)
|
|
737
|
+
wid.push(`persona ${s.persona}`);
|
|
738
|
+
if (s.role !== undefined)
|
|
739
|
+
wid.push(`runs as ${s.role}`);
|
|
740
|
+
if (s.requiresAuth)
|
|
741
|
+
wid.push("Signed in");
|
|
742
|
+
wid.push(...r.sideEffects.origins.slice(0, 1));
|
|
743
|
+
const files = r.steps.flatMap((st) => (st.file === undefined ? [] : [st.file]));
|
|
744
|
+
if (wid.length > 0)
|
|
745
|
+
put(bullet, wid.join(" · "));
|
|
746
|
+
if (files.length > 0)
|
|
747
|
+
put(bullet, `Uploads ${files.map(nb).join(", ")}`);
|
|
748
|
+
gap();
|
|
749
|
+
// ── Inputs (only when present)
|
|
750
|
+
if (r.inputs.params.length > 0 || r.inputs.secrets.length > 0) {
|
|
751
|
+
head("INPUTS", "Inputs");
|
|
752
|
+
if (r.inputs.params.length > 0)
|
|
753
|
+
put(bullet, `Parameters: ${r.inputs.params.map((p) => `${code(p.name)}${p.secret ? " (secret)" : ""}${p.description === undefined ? "" : ` — ${p.description}`}`).join("; ")}`);
|
|
754
|
+
for (const x of r.inputs.secrets)
|
|
755
|
+
put(bullet, `Secret ${code(x.field)} from ${x.manager} on ${x.origin}`);
|
|
756
|
+
gap();
|
|
530
757
|
}
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
758
|
+
// ── Steps
|
|
759
|
+
if (r.steps.length > 0) {
|
|
760
|
+
head("STEPS", "Steps");
|
|
761
|
+
const numW = String(r.steps.length).length;
|
|
762
|
+
for (const st of r.steps) {
|
|
763
|
+
const anchors = (st.anchors ?? []).map((a) => `⚓ ${a}`).join(" ");
|
|
764
|
+
const target = full && st.target !== undefined ? ` — target: ${[st.target.role, st.target.name === undefined ? undefined : `"${st.target.name}"`].filter((x) => x !== undefined).join(" ")}` : "";
|
|
765
|
+
const lead = md ? `${st.number}. ` : ` ${String(st.number).padStart(numW)} `;
|
|
766
|
+
const prefixLen = lead.length;
|
|
767
|
+
// M4: a labelled step's exact locator follows its label, so a spoofed label cannot hide it.
|
|
768
|
+
const locator = st.locator === undefined ? "" : `${md ? " " : " "}‹${code(st.locator)}›`;
|
|
769
|
+
put(lead, `${md ? `**${st.action}**` : st.action}${locator}${target}${anchors === "" ? "" : ` ${anchors}`}`, md ? 0 : prefixLen);
|
|
770
|
+
const sub = md ? " - " : " ".repeat(prefixLen);
|
|
771
|
+
const subHang = sub.length + 2;
|
|
772
|
+
if (st.checks !== undefined) {
|
|
773
|
+
const joined = st.checks.map((k) => (k.weak ? `⚠ ${k.text} (proves nothing)` : `✓ ${k.text}`));
|
|
774
|
+
if (md)
|
|
775
|
+
for (const j of joined)
|
|
776
|
+
put(sub, j);
|
|
777
|
+
else if (sub.length + joined.join(" · ").length <= width)
|
|
778
|
+
put(sub, joined.join(" · "), subHang);
|
|
779
|
+
else
|
|
780
|
+
for (const j of joined)
|
|
781
|
+
put(sub, j, subHang);
|
|
782
|
+
}
|
|
783
|
+
const writes = [...new Set(r.sideEffects.writeRequests.filter((w) => w.step === st.number && w.source !== "end-state").map((w) => `${w.method} ${w.endpoint}`))];
|
|
784
|
+
if (writes.length > 0)
|
|
785
|
+
put(sub, `writes ${writes.join(" · ")}`, subHang);
|
|
786
|
+
if (full) {
|
|
787
|
+
if (st.objective !== undefined)
|
|
788
|
+
put(sub, `Objective: ${st.objective}`, subHang);
|
|
789
|
+
if (st.expectedResult !== undefined)
|
|
790
|
+
put(sub, `Expected result: ${st.expectedResult}`, subHang);
|
|
791
|
+
if (st.file !== undefined)
|
|
792
|
+
put(sub, `File: ${code(st.file)}`, subHang);
|
|
793
|
+
if (st.params.length > 0)
|
|
794
|
+
put(sub, `Params: ${st.params.map(code).join(", ")}`, subHang);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
gap();
|
|
536
798
|
}
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
li(k.persona === undefined ? `Persona: ${em("none")}` : `Persona: ${code(k.persona.id)} (${status(k.persona.status)})`),
|
|
545
|
-
...(k.unvetted.length === 0 ? [] : [li(`Unvetted: ${k.unvetted.join(", ")} — promote needs them approved, or --accept-unvetted "<reason>"`)]),
|
|
546
|
-
...(k.needsReReview.length === 0 ? [] : [li(`NEEDS RE-REVIEW: ${k.needsReReview.join("; ")}`)]),
|
|
547
|
-
]
|
|
548
|
-
: [li(em("not linked to a job/persona"))]));
|
|
799
|
+
// ── Proves at the end
|
|
800
|
+
const endState = r.proof.endStateText ?? r.proof.endState;
|
|
801
|
+
if (endState.length > 0) {
|
|
802
|
+
head("PROVES AT THE END", "Proves at the end");
|
|
803
|
+
for (const e of endState)
|
|
804
|
+
put(`${bullet}✓ `, e, bullet.length + 2);
|
|
805
|
+
gap();
|
|
549
806
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
807
|
+
// ── Writes
|
|
808
|
+
const fx = r.sideEffects;
|
|
809
|
+
const byEndpoint = new Map();
|
|
810
|
+
for (const w of fx.writeRequests) {
|
|
811
|
+
const key = `${w.method} ${w.endpoint}`;
|
|
812
|
+
const e = byEndpoint.get(key) ?? { steps: [], end: false };
|
|
813
|
+
if (w.step !== undefined)
|
|
814
|
+
e.steps.push(w.step);
|
|
815
|
+
if (w.source === "end-state")
|
|
816
|
+
e.end = true;
|
|
817
|
+
byEndpoint.set(key, e);
|
|
554
818
|
}
|
|
819
|
+
if (byEndpoint.size > 0 || fx.riskyControls.length > 0 || fx.origins.length > 1) {
|
|
820
|
+
head("WRITES", "Writes");
|
|
821
|
+
for (const [key, e] of byEndpoint) {
|
|
822
|
+
const where = [...(e.steps.length === 0 ? [] : [`${e.steps.length > 1 ? "steps" : "step"} ${stepRanges(e.steps)}`]), ...(e.end ? ["end state"] : [])];
|
|
823
|
+
put(bullet, `${code(key)}${where.length === 0 ? "" : ` (${where.join(", ")})`}`);
|
|
824
|
+
}
|
|
825
|
+
for (const x of fx.riskyControls) {
|
|
826
|
+
put(bullet, `Risky control, step ${x.step}: ${x.role === undefined ? "" : `${x.role} `}"${x.control}" — ${x.risk} [${code(x.ruleId)}]${x.waivedBy === undefined ? "" : ` (exempted by allowControl ${code(x.waivedBy)})`}`);
|
|
827
|
+
}
|
|
828
|
+
if (fx.origins.length > 1)
|
|
829
|
+
put(bullet, `Origins: ${fx.origins.map(code).join(", ")}`);
|
|
830
|
+
gap();
|
|
831
|
+
}
|
|
832
|
+
// ── Clear
|
|
833
|
+
const clear = [];
|
|
834
|
+
const lint = r.proof.lint;
|
|
835
|
+
if (lint.errors === 0 && lint.warnings === 0)
|
|
836
|
+
clear.push("lint ✓");
|
|
837
|
+
if (r.locatorHealth !== undefined && r.locatorHealth.brittle === 0)
|
|
838
|
+
clear.push(`locators ${r.locatorHealth.stable}/${r.locatorHealth.stable} stable ✓`);
|
|
839
|
+
if (r.catalog !== undefined && !r.catalog.linked)
|
|
840
|
+
clear.push("not linked to a job/persona (info)");
|
|
841
|
+
else if (r.catalog !== undefined && r.catalog.unvetted.length === 0 && r.catalog.needsReReview.length === 0)
|
|
842
|
+
clear.push("catalog links ✓");
|
|
843
|
+
const related = new Set((r.findings ?? []).filter((f) => f.analyzer === "catalog-analysis" && f.code.startsWith("pair:")).flatMap((f) => (f.items ?? []).filter((i) => i !== `journey:${r.id}`)));
|
|
844
|
+
const relatedCount = related.size > 0 ? related.size : (r.findings ?? []).filter((f) => f.analyzer === "catalog-analysis" && f.code.startsWith("pair:")).length;
|
|
845
|
+
if (relatedCount > 0)
|
|
846
|
+
clear.push(`${plural(relatedCount, "related Journey", "related Journeys")} share writes${full ? "" : " (--full for detail)"}`);
|
|
847
|
+
const v = r.proof.verify;
|
|
848
|
+
if (v.status === "recorded" && !v.stale && v.verdict !== "insensitive")
|
|
849
|
+
clear.push(`mutation proof ${v.verdict} ✓`);
|
|
850
|
+
if (clear.length > 0) {
|
|
851
|
+
if (md)
|
|
852
|
+
out.push("## Clear", "", clear.join(" · "));
|
|
853
|
+
else
|
|
854
|
+
put("CLEAR ", clear.join(" · "), 9);
|
|
855
|
+
gap();
|
|
856
|
+
}
|
|
857
|
+
// ── Self-heal proposal
|
|
555
858
|
if (r.proposal !== undefined) {
|
|
556
859
|
const pr = r.proposal;
|
|
860
|
+
head("PROPOSED REVISION (SELF-HEAL)", "Proposed revision (self-heal)");
|
|
861
|
+
put(bullet, `${code(pr.proposalId)} from ${humanTime(pr.createdAt)}: accepting it makes the content hash ${nb(code(pr.proposedHash))} (made against ${nb(code(pr.baseHash))})`);
|
|
557
862
|
const scope = pr.changes.range ?? (pr.changes.notes.length > 0 ? "change notes" : "no change context");
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
863
|
+
put(bullet, `Change context: ${scope}${pr.changes.baseSha === undefined ? "" : ` (${pr.changes.baseSha.slice(0, 8)}..${(pr.changes.headSha ?? "").slice(0, 8)})`} · heal attempts ${pr.attempts.total} (${pr.attempts.rejected} rejected)`);
|
|
864
|
+
for (const n of pr.changes.notes)
|
|
865
|
+
put(bullet, `note: ${n}`);
|
|
866
|
+
if (pr.stale)
|
|
867
|
+
put(bullet, `STALE — the Journey changed after this proposal was made; it cannot be accepted. Re-run: ${cmd(`jevitate journey run ${r.id} --self-heal`)}`);
|
|
868
|
+
if (pr.problem !== undefined && !pr.stale)
|
|
869
|
+
put(bullet, `REFUSED — ${pr.problem}`);
|
|
870
|
+
for (const st of pr.steps) {
|
|
871
|
+
put(bullet, `Step ${st.number}: ${st.hypothesis}`);
|
|
872
|
+
put(`${bullet} `, `before: ${code(st.before)}`);
|
|
873
|
+
put(`${bullet} `, `after: ${code(st.after)}`);
|
|
874
|
+
for (const e of st.evidence)
|
|
875
|
+
put(`${bullet} `, `evidence: ${e}`);
|
|
876
|
+
for (const sh of st.screenshots)
|
|
877
|
+
put(`${bullet} `, `screenshot: ${sh}`);
|
|
878
|
+
}
|
|
879
|
+
if (pr.stale || pr.problem !== undefined)
|
|
880
|
+
put(bullet, em("not acceptable as it stands"));
|
|
881
|
+
else
|
|
882
|
+
put(bullet, `Accept exactly this revision: ${cmd(pr.acceptCommand)}`);
|
|
883
|
+
put(bullet, `Reject it: ${cmd(`jevitate journey promote ${r.id} --reject-proposal ${pr.proposalId} --reason "<why>"`)}`);
|
|
884
|
+
gap();
|
|
885
|
+
}
|
|
886
|
+
// ── Full detail
|
|
887
|
+
if (full) {
|
|
888
|
+
head("DETAIL", "Detail");
|
|
889
|
+
const h3 = (t) => {
|
|
890
|
+
out.push(md ? `### ${t}` : `${t}:`);
|
|
891
|
+
};
|
|
892
|
+
h3(`Lint: ${lint.errors} error(s), ${lint.warnings} warning(s)`);
|
|
893
|
+
if (lint.findings.length === 0)
|
|
894
|
+
put(bullet, em("no findings"));
|
|
895
|
+
for (const f of lint.findings)
|
|
896
|
+
put(bullet, `${f.level} ${f.rule}${f.step === undefined ? "" : ` (step ${f.step})`}: ${f.message}`);
|
|
897
|
+
if (r.proof.acceptedWeak !== undefined)
|
|
898
|
+
put(bullet, `Accepted weak: "${r.proof.acceptedWeak.reason}" (waived: ${r.proof.acceptedWeak.rules.join(", ")})`);
|
|
899
|
+
const verify = v.status === "not-verified" ? `not verified — ${v.hint}` : `${v.verdict} (${humanTime(v.at)})${v.stale ? " — STALE: the Journey changed after this proof ran" : ""}${v.reason === undefined ? "" : ` — ${v.reason}`}: ${Object.entries(v.summary).map(([k, n]) => `${n} ${k}`).join(", ")}`;
|
|
900
|
+
put("", `Mutation proof: ${verify}`);
|
|
901
|
+
gap();
|
|
902
|
+
h3("Change since last approval");
|
|
903
|
+
if (c.kind === "first-approval")
|
|
904
|
+
put(bullet, em("first approval — nothing approved before"));
|
|
905
|
+
else if (c.kind === "no-record")
|
|
906
|
+
put(bullet, em("promoted before approvals were recorded — no approved version to compare with"));
|
|
907
|
+
else {
|
|
908
|
+
put(bullet, `Approved${c.approvedAt === undefined ? "" : ` ${humanTime(c.approvedAt)}`} as ${nb(code(c.approvedHash))}${c.kind === "snapshot-missing" ? "; its snapshot is missing" : ""} — ${c.changed ? "CHANGED since" : "unchanged since"}`);
|
|
909
|
+
if (c.stepIdOnly === true)
|
|
910
|
+
put(bullet, STEP_ID_ONLY_LABEL);
|
|
911
|
+
if (c.kind === "diff") {
|
|
912
|
+
for (const [label, d] of [["Steps", c.steps], ["Assertions", c.assertions], ["Side effects", c.sideEffects]]) {
|
|
913
|
+
if (d.added.length === 0 && d.removed.length === 0)
|
|
914
|
+
continue;
|
|
915
|
+
put(bullet, `${label}:`);
|
|
916
|
+
for (const l of d.removed)
|
|
917
|
+
put(`${bullet} `, `− ${l}`, bullet.length + 4);
|
|
918
|
+
for (const l of d.added)
|
|
919
|
+
put(`${bullet} `, `+ ${l}`, bullet.length + 4);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
gap();
|
|
924
|
+
if (r.anchorWarnings !== undefined && r.anchorWarnings.length > 0) {
|
|
925
|
+
h3("Anchor rules");
|
|
926
|
+
for (const w of r.anchorWarnings)
|
|
927
|
+
put(bullet, `${w.severity} ${w.path}${w.step === undefined ? "" : ` (step ${w.step})`}: ${w.message}${w.fix === undefined ? "" : ` — fix: ${w.fix}`}`);
|
|
928
|
+
gap();
|
|
929
|
+
}
|
|
930
|
+
if (r.locatorHealth !== undefined) {
|
|
931
|
+
const lh = r.locatorHealth;
|
|
932
|
+
h3("Locator health (advisory)");
|
|
933
|
+
put("", lh.line);
|
|
934
|
+
if (lh.brittleSteps.length === 0)
|
|
935
|
+
put(bullet, em("no brittle steps"));
|
|
936
|
+
for (const b of lh.brittleSteps)
|
|
937
|
+
put(bullet, `step ${b.index + 1}${b.stepId === undefined ? "" : ` [${b.stepId}]`} ${b.kind} ${code(b.locator)}${b.route === undefined ? "" : ` on ${b.route}`} — ${b.reasons.join("; ")} — fix: ${b.fix}`);
|
|
938
|
+
gap();
|
|
939
|
+
}
|
|
940
|
+
if (r.catalog !== undefined) {
|
|
941
|
+
const k = r.catalog;
|
|
942
|
+
const st = (x) => (x === "stale" ? "STALE — needs re-review" : x === "unknown" ? "UNKNOWN — not declared in the catalog" : x);
|
|
943
|
+
h3("Catalog");
|
|
944
|
+
if (!k.linked)
|
|
945
|
+
put(bullet, em("not linked to a job/persona"));
|
|
946
|
+
else {
|
|
947
|
+
put(bullet, k.job === undefined ? "Job: none" : `Job: ${code(k.job.id)} (${st(k.job.status)})${k.job.story === undefined ? "" : ` — ${k.job.story}`}`);
|
|
948
|
+
put(bullet, k.persona === undefined ? "Persona: none" : `Persona: ${code(k.persona.id)} (${st(k.persona.status)})`);
|
|
949
|
+
}
|
|
950
|
+
gap();
|
|
951
|
+
}
|
|
952
|
+
if (r.findings !== undefined) {
|
|
953
|
+
for (const l of renderFindings(r.findings, style)) {
|
|
954
|
+
if (md || l === "" || !/^\s*- /.test(l))
|
|
955
|
+
out.push(l);
|
|
956
|
+
else {
|
|
957
|
+
const lead = /^(\s*- )/.exec(l)[1];
|
|
958
|
+
put(lead, l.slice(lead.length), lead.length + 2);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (r.approval !== undefined) {
|
|
963
|
+
h3("Approval record");
|
|
964
|
+
put(bullet, describeProvenance(prov));
|
|
965
|
+
for (const w of r.approval.waivers ?? [])
|
|
966
|
+
put(bullet, `waived (${w.kind}): "${w.reason}" — ${w.items.join(", ")}`);
|
|
967
|
+
if (r.approval.acceptedFindings !== undefined)
|
|
968
|
+
put(bullet, `acknowledged findings: "${r.approval.acceptedFindings.reason}" — ${r.approval.acceptedFindings.findings.join(", ")}`);
|
|
969
|
+
gap();
|
|
970
|
+
}
|
|
565
971
|
}
|
|
566
|
-
|
|
567
|
-
|
|
972
|
+
// ── Approve (the one place the content hash appears)
|
|
973
|
+
const approved = r.promoted && !changed && (c.kind === "diff" || c.kind === "snapshot-missing" || c.kind === "no-record");
|
|
974
|
+
head("APPROVE", "Approve");
|
|
975
|
+
const approveCmd = cmd(`jevitate journey promote ${r.id} --reviewed-hash ${r.contentHash}`);
|
|
976
|
+
put(bullet, `${approved ? "(already approved) " : ""}${approveCmd}`);
|
|
977
|
+
if (md)
|
|
978
|
+
out.push("", `Content hash: ${code(r.contentHash)}`);
|
|
979
|
+
else
|
|
980
|
+
out.push(`Content hash: ${r.contentHash}`);
|
|
981
|
+
return `${out.map((l) => paint(l, opts.color === true && !md)).join("\n").replace(/\n{3,}/g, "\n\n").trimEnd()}\n`;
|
|
568
982
|
}
|
|
569
983
|
/** #432: the review sheet as Markdown (`journey review --markdown`). */
|
|
570
|
-
export function renderReviewMarkdown(review) {
|
|
571
|
-
return renderSheet(review, "markdown");
|
|
984
|
+
export function renderReviewMarkdown(review, opts = {}) {
|
|
985
|
+
return renderSheet(review, "markdown", opts);
|
|
572
986
|
}
|
|
573
987
|
/** #432: the review sheet as plain text (`journey review`, and `journey promote` in human mode). */
|
|
574
|
-
export function renderReviewText(review) {
|
|
575
|
-
return renderSheet(review, "text");
|
|
988
|
+
export function renderReviewText(review, opts = {}) {
|
|
989
|
+
return renderSheet(review, "text", opts);
|
|
576
990
|
}
|
|
577
991
|
/** #432: a review sheet file's content hash refused (none, or two different ones). */
|
|
578
992
|
export class ReviewSheetError extends Error {
|