@nextcommerce/campaigns-os 1.52.0 → 1.53.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/CHANGELOG.md +48 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/effects.v1.json +201 -12
- package/contracts/release-ledger.json +327 -0
- package/contracts/supported-surface.json +2 -2
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +56 -15
- package/docs/campaign-build-brief.md +48 -17
- package/docs/effects.md +8 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +2 -0
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/cli.mjs +160 -112
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +129 -14
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +3 -1
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +15 -3
- package/src/qa-browser.mjs +131 -84
- package/src/qa-node.mjs +14 -3
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +119 -26
- package/src/tooling-setup.mjs +1 -1
package/src/qc-results.mjs
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Storage per leg:
|
|
6
6
|
// - doctor: derived.qc_results[], recomputed from built HTML on every run;
|
|
7
|
-
// - polish: stages.polish.evidence.visual_review
|
|
8
|
-
//
|
|
7
|
+
// - polish: one package-owned record per stages.polish.evidence.visual_review
|
|
8
|
+
// key (POLISH_RECORDS): media_weight, re-derived from its cells and
|
|
9
|
+
// cross-checked against the page_load capture, and readability, whose
|
|
10
|
+
// elements are re-derived through the shared contrast helper and whose
|
|
11
|
+
// route set is recomputed from the built output;
|
|
9
12
|
// - qa: stages.qa.evidence.qc_results[] beside qc_build_fingerprint,
|
|
10
13
|
// re-derived from the qc.* assertions of the full QA verdict.
|
|
11
14
|
//
|
|
@@ -20,12 +23,13 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
20
23
|
|
|
21
24
|
import { campaignSidecarPaths } from "./campaign-workspace.mjs";
|
|
22
25
|
import { checkpointStateFingerprint } from "./checkpoint-waiver.mjs";
|
|
26
|
+
import { contrastToolkit } from "./contrast.mjs";
|
|
23
27
|
import { sameFile } from "./fs-identity.mjs";
|
|
24
28
|
import { buildPolishCaptureIntegrity, canonicalJson, captureOrigin, mediaFetchedResources } from "./polish-capture.mjs";
|
|
25
|
-
import { currentBuildFingerprint } from "./polish-gate.mjs";
|
|
29
|
+
import { currentBuildFingerprint, currentSourcePackageMaterialFingerprint } from "./polish-gate.mjs";
|
|
26
30
|
import { sidecarPathForPacket } from "./qa-sidecar.mjs";
|
|
27
31
|
import { QA_ASSERTION_FAMILY_VOCABULARY, QA_SCHEMA_VERSION, SEVERITY, STATUS } from "./qa-verdict.mjs";
|
|
28
|
-
import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg } from "./qc-check-registry.mjs";
|
|
32
|
+
import { QC_CHECK_REGISTRY, loadedQcRederivers, qcChecksForLeg, qcChecksForRecord } from "./qc-check-registry.mjs";
|
|
29
33
|
import { STORE_PAGE_MATCHERS } from "./spec-derive-store.mjs";
|
|
30
34
|
|
|
31
35
|
export const QC_RESULT_SCHEMA = "campaigns-os-qc-result/v0";
|
|
@@ -184,10 +188,14 @@ function qaRederiver(check, { qcStandIns, rederivers }) {
|
|
|
184
188
|
return entry;
|
|
185
189
|
}
|
|
186
190
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
// The rules of the record a Polish check is read from. A stand-in is
|
|
192
|
+
// `qcStandIns.polish[check]`, or `qcStandIns.polish` itself for the
|
|
193
|
+
// media_weight checks.
|
|
194
|
+
export function resolvePolishRules({ check = "media.weight", qcStandIns, rederivers } = {}) {
|
|
195
|
+
const standIn = isPlainObject(qcStandIns?.polish?.[check]) ? qcStandIns.polish[check]
|
|
196
|
+
: QC_CHECK_REGISTRY[check]?.record === "media_weight" && isPlainObject(qcStandIns?.polish) ? qcStandIns.polish : null;
|
|
197
|
+
if (standIn) return { status: "loaded", rules: standIn };
|
|
198
|
+
const entry = (rederivers || loadedQcRederivers())?.[check];
|
|
191
199
|
if (!entry) return { status: "unresolved" };
|
|
192
200
|
if (entry.status !== "loaded") return entry;
|
|
193
201
|
const rules = entry.rederive;
|
|
@@ -307,12 +315,18 @@ export function readQaFullVerdict({ stage, targetRepo, packetPath = null, report
|
|
|
307
315
|
}
|
|
308
316
|
}
|
|
309
317
|
|
|
310
|
-
|
|
318
|
+
// `qaCurrency` is QA's read-time input currency
|
|
319
|
+
// (derived.input_currency.stages.qa). While QA is owed again or its inputs
|
|
320
|
+
// cannot be confirmed, every row that reproduces reads stale_binding, as a
|
|
321
|
+
// row bound to another build does; a row that fails reproduction still reads
|
|
322
|
+
// evidence_not_reproducible first.
|
|
323
|
+
export function readQaResults({ stageEvidence, stage = null, fullVerdict = null, currentBuild = null, qcStandIns = null, rederivers = null, qaCurrency = null } = {}) {
|
|
311
324
|
const stored = Array.isArray(stageEvidence?.qc_results) ? stageEvidence.qc_results : null;
|
|
312
325
|
if (!stored) return [];
|
|
313
326
|
const verdictOk = qaFullVerdictProblem(fullVerdict, stage) === null;
|
|
314
327
|
const measuredAt = verdictOk ? (validTime(fullVerdict.completed_at) ? fullVerdict.completed_at : fullVerdict.started_at) : null;
|
|
315
|
-
const
|
|
328
|
+
const inputsCurrent = qaCurrency !== "owed" && qaCurrency !== "unknown";
|
|
329
|
+
const bound = inputsCurrent && isNonEmptyString(currentBuild) && stageEvidence.qc_build_fingerprint === currentBuild;
|
|
316
330
|
|
|
317
331
|
// Rows pair with assertions by evidence.qc.result_id, never by assertion id.
|
|
318
332
|
// A passing verdict holds only well-formed QC evidence (qaFullVerdictProblem),
|
|
@@ -400,14 +414,17 @@ function readQaRow(row, { verdictOk, assertion, measuredAt, qcStandIns, rederive
|
|
|
400
414
|
}
|
|
401
415
|
|
|
402
416
|
// ---------------------------------------------------------------------------
|
|
403
|
-
// Polish reader
|
|
417
|
+
// Polish reader sites (media_weight against its page_load capture, and
|
|
418
|
+
// readability)
|
|
404
419
|
|
|
405
|
-
// Unkeyed tamper evidence
|
|
406
|
-
// + sha256 pattern of
|
|
407
|
-
|
|
420
|
+
// Unkeyed tamper evidence of a package-owned Polish record over every field
|
|
421
|
+
// but `integrity`, the canonical-JSON + sha256 pattern of
|
|
422
|
+
// buildPolishCaptureIntegrity.
|
|
423
|
+
export function polishRecordIntegrity(record) {
|
|
408
424
|
const { integrity: _ignored, ...rest } = record || {};
|
|
409
425
|
return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
|
|
410
426
|
}
|
|
427
|
+
export const mediaWeightIntegrity = polishRecordIntegrity;
|
|
411
428
|
|
|
412
429
|
const inVocabulary = (vocabulary, field, value) => Array.isArray(vocabulary?.[field]) && vocabulary[field].includes(value);
|
|
413
430
|
const isPair = (value) => Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
|
|
@@ -809,25 +826,34 @@ function captureBindingProblem(capture, subject, currentBuild) {
|
|
|
809
826
|
|
|
810
827
|
const PAGE_NOT_CAPTURED = "page_not_captured";
|
|
811
828
|
|
|
812
|
-
|
|
829
|
+
// Built output that no longer matches the recorded build: doctor's
|
|
830
|
+
// derived.build_output_fingerprint, whose status is pass only when the
|
|
831
|
+
// output under _site/<slug>/ is the one `record build` recorded. Any other
|
|
832
|
+
// value, null included, is drift. A caller that passes no value (undefined)
|
|
833
|
+
// reads without the output check.
|
|
834
|
+
const outputDrifted = (buildOutput) => buildOutput !== undefined && buildOutput?.status !== "pass";
|
|
835
|
+
|
|
836
|
+
export function readMediaWeight({ record, pageLoad, currentBuild = null, buildOutput = undefined, qcStandIns = null, rederivers = null } = {}) {
|
|
813
837
|
if (record === undefined || record === null) return [];
|
|
838
|
+
const checks = POLISH_RECORDS.media_weight.checks;
|
|
814
839
|
const measuredAt = validTime(record?.measured_at) ? record.measured_at : null;
|
|
815
840
|
const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
|
|
816
841
|
const unreproduced = (subject) => unreproducedRow({ subject, check: subject.check }, E, { leg: "polish", measuredAt });
|
|
817
842
|
const cellResult = (check, route, viewport) => unreproduced({ check, page: route, viewport, key: "cell" });
|
|
818
|
-
// A failed cell: one result per subject it lists, and one per
|
|
819
|
-
// it lists no subject for, so a failed cell, even one listing
|
|
820
|
-
// never silent.
|
|
843
|
+
// A failed cell: one result per subject it lists, and one per media_weight
|
|
844
|
+
// check it lists no subject for, so a failed cell, even one listing
|
|
845
|
+
// nothing, is never silent.
|
|
821
846
|
const failCell = (cell) => {
|
|
822
847
|
const subjects = cellSubjects(cell);
|
|
823
848
|
return [
|
|
824
849
|
...subjects.map(unreproduced),
|
|
825
|
-
...
|
|
850
|
+
...checks.filter((check) => !subjects.some((subject) => subject.check === check)).map((check) => cellResult(check, cell?.route ?? null, cell?.viewport ?? null)),
|
|
826
851
|
];
|
|
827
852
|
};
|
|
828
853
|
// A record-level failure: every listed cell fails, and every route ×
|
|
829
854
|
// viewport that the record or page_load declares or captured but no cell
|
|
830
|
-
// lists gets one result per
|
|
855
|
+
// lists gets one result per media_weight check, so a missing route is never
|
|
856
|
+
// silent.
|
|
831
857
|
const failAll = () => {
|
|
832
858
|
const listed = new Set(cells.map((cell) => cellKey(cell.route, cell.viewport)));
|
|
833
859
|
const unlisted = new Map();
|
|
@@ -840,11 +866,11 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
|
|
|
840
866
|
for (const entry of Array.isArray(pageLoad?.measurement?.missing) ? pageLoad.measurement.missing : []) note(entry?.route, entry?.viewport);
|
|
841
867
|
const rows = [
|
|
842
868
|
...cells.flatMap(failCell),
|
|
843
|
-
...[...unlisted.values()].flatMap(({ route, viewport }) =>
|
|
869
|
+
...[...unlisted.values()].flatMap(({ route, viewport }) => checks.map((check) => cellResult(check, route, viewport))),
|
|
844
870
|
];
|
|
845
871
|
return rows.length ? rows : [unreproduced({ check: "media.weight", page: null, key: "media_weight" })];
|
|
846
872
|
};
|
|
847
|
-
const resolved = resolvePolishRules({ qcStandIns, rederivers });
|
|
873
|
+
const resolved = resolvePolishRules({ check: checks[0], qcStandIns, rederivers });
|
|
848
874
|
if (resolved.status === "missing") return [];
|
|
849
875
|
if (resolved.status !== "loaded") return failAll();
|
|
850
876
|
const { rules } = resolved;
|
|
@@ -887,11 +913,13 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
|
|
|
887
913
|
|| captureByKey.size !== gridKeys.length
|
|
888
914
|
|| !gridKeys.every((key) => captureByKey.has(key) && cellKeys.includes(key))) return failAll();
|
|
889
915
|
|
|
890
|
-
// Both enclosing subjects are bound to the current build
|
|
891
|
-
// or other build on either
|
|
916
|
+
// Both enclosing subjects are bound to the current build, and the built
|
|
917
|
+
// output is still that build's; an absent, null or other build on either,
|
|
918
|
+
// or output drift, reads stale_binding.
|
|
892
919
|
const recordBound = isNonEmptyString(currentBuild)
|
|
893
920
|
&& record.subject.build_fingerprint === currentBuild
|
|
894
|
-
&& pageLoad.subject.build_fingerprint === currentBuild
|
|
921
|
+
&& pageLoad.subject.build_fingerprint === currentBuild
|
|
922
|
+
&& !outputDrifted(buildOutput);
|
|
895
923
|
const results = [];
|
|
896
924
|
// A spec page with no source mapping: one result per 1.3 check and viewport,
|
|
897
925
|
// unexercised / page_not_captured; keyed "cell" on its public route, or
|
|
@@ -902,7 +930,7 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
|
|
|
902
930
|
];
|
|
903
931
|
for (const { page, key } of uncaptured) {
|
|
904
932
|
for (const viewport of record.subject.viewports) {
|
|
905
|
-
for (const check of
|
|
933
|
+
for (const check of checks) {
|
|
906
934
|
const subject = { check, page, viewport, key };
|
|
907
935
|
const row = unreproducedRow({ subject, check }, PAGE_NOT_CAPTURED, { leg: "polish", measuredAt });
|
|
908
936
|
results.push(recordBound ? row : staleRow(row));
|
|
@@ -926,7 +954,7 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
|
|
|
926
954
|
// Every derived subject is one the cell lists (cellSubjects), exactly:
|
|
927
955
|
// its own page, viewport and key, and no other field.
|
|
928
956
|
const own = new Set(cellSubjects(cell).map((subject) => canonicalJson(subject)));
|
|
929
|
-
if (!Array.isArray(derived) || !derived.every((item) =>
|
|
957
|
+
if (!Array.isArray(derived) || !derived.every((item) => checks.includes(item?.check) && validDerived(item, item.check) && own.has(canonicalJson(item.subject)))) {
|
|
930
958
|
results.push(...failCell(cell));
|
|
931
959
|
continue;
|
|
932
960
|
}
|
|
@@ -954,6 +982,234 @@ export function readMediaWeight({ record, pageLoad, currentBuild = null, qcStand
|
|
|
954
982
|
return results;
|
|
955
983
|
}
|
|
956
984
|
|
|
985
|
+
// The readability record's subject fields besides build_fingerprint.
|
|
986
|
+
const READABILITY_SUBJECT_FIELDS = Object.freeze(["source_package_material_fingerprint", "campaign_slug", "route_source", "routes", "viewports"]);
|
|
987
|
+
const READABILITY_DERIVED_FIELDS = Object.freeze(["fg_srgb", "bg_srgb", "gamut_clipped", "ratio", "size_class", "required"]);
|
|
988
|
+
const contrast = contrastToolkit();
|
|
989
|
+
const canonicalTime = (value) => validTime(value) && new Date(value).toISOString() === value;
|
|
990
|
+
|
|
991
|
+
// Every derived field of a stored element re-derives exactly from its raw
|
|
992
|
+
// computed colours and font through the shared contrast helper.
|
|
993
|
+
function readabilityElementReproduces(element) {
|
|
994
|
+
let derived;
|
|
995
|
+
try {
|
|
996
|
+
derived = contrast.deriveElementMeasurement({
|
|
997
|
+
fg_raw: element.fg_raw,
|
|
998
|
+
fill_raw: element.fill_raw,
|
|
999
|
+
bg_layers_raw: element.bg_layers_raw,
|
|
1000
|
+
font_size_px: element.font_size_px,
|
|
1001
|
+
font_weight: element.font_weight,
|
|
1002
|
+
});
|
|
1003
|
+
} catch {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
return READABILITY_DERIVED_FIELDS.every((field) => Object.hasOwn(element, field) && sameJson(element[field], derived[field]));
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// A crop reference resolves when its file under the target repo holds bytes
|
|
1010
|
+
// whose sha256 is the one recorded.
|
|
1011
|
+
function readabilityCropResolves(ref, targetRepo) {
|
|
1012
|
+
if (!isNonEmptyString(targetRepo) || !isPlainObject(ref) || !isNonEmptyString(ref.path) || !isNonEmptyString(ref.sha256)) return false;
|
|
1013
|
+
try {
|
|
1014
|
+
const file = resolve(targetRepo, ref.path);
|
|
1015
|
+
if (!statSync(file).isFile()) return false;
|
|
1016
|
+
return `sha256:${createHash("sha256").update(readFileSync(file)).digest("hex")}` === ref.sha256;
|
|
1017
|
+
} catch {
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// The readability record (contract 2.4 Result rules), read in precedence
|
|
1023
|
+
// order:
|
|
1024
|
+
// 1. the record and cell reader checks (producer, schema, helper version,
|
|
1025
|
+
// integrity, canonical measured_at, the constant thresholds and limits,
|
|
1026
|
+
// the fixed producer values, the vocabulary, the exact routes × viewports
|
|
1027
|
+
// grid, every element re-derived through the shared helper, and each
|
|
1028
|
+
// shared cell's page_load integrity) → evidence_not_reproducible;
|
|
1029
|
+
// 2. the binding (build, source package, and doctor's output fingerprint)
|
|
1030
|
+
// → every row stale_binding, before any route-set result;
|
|
1031
|
+
// 3. the built route set, recomputed from _site/<slug>/: a captured route
|
|
1032
|
+
// the record lacks, or one over the route cap, reads page_not_captured;
|
|
1033
|
+
// a recorded route outside it, or different uncaptured routes or
|
|
1034
|
+
// enumeration cap, → evidence_not_reproducible;
|
|
1035
|
+
// 4. the rows READABILITY_QC_RULES.evaluate derives from each cell.
|
|
1036
|
+
// `campaignSlug` is the packet's slug; a missing one never matches.
|
|
1037
|
+
export function readReadability({ record, pageLoad = null, currentBuild = null, currentSource = null, buildOutput = null, campaignSlug = null, targetRepo = null, qcStandIns = null, rederivers = null } = {}) {
|
|
1038
|
+
if (record === undefined || record === null) return [];
|
|
1039
|
+
const [check] = POLISH_RECORDS.readability.checks;
|
|
1040
|
+
const measuredAt = canonicalTime(record?.measured_at) ? record.measured_at : null;
|
|
1041
|
+
const cells = Array.isArray(record?.cells) ? record.cells.filter(isPlainObject) : [];
|
|
1042
|
+
const subjectOf = (page, viewport, key) => ({ check, page, viewport, key });
|
|
1043
|
+
const rowOf = (subject, reasonCode) => unreproducedRow({ subject, check }, reasonCode, { leg: "polish", measuredAt });
|
|
1044
|
+
const unique = (rows) => [...new Map(rows.map((row) => [row.id, row])).values()];
|
|
1045
|
+
let rules = null;
|
|
1046
|
+
// A failed cell: one result per row the cell names and its `cell` row, so
|
|
1047
|
+
// a failed cell is never silent and an accept of one of its rows lapses
|
|
1048
|
+
// rather than orphans. The keys come from rules.rowKeys, which reads
|
|
1049
|
+
// neither the record's thresholds nor any field the checks reject.
|
|
1050
|
+
const failCell = (cell) => {
|
|
1051
|
+
let named = [];
|
|
1052
|
+
try {
|
|
1053
|
+
named = typeof rules?.rowKeys === "function" ? rules.rowKeys(cell) : [];
|
|
1054
|
+
} catch {
|
|
1055
|
+
named = [];
|
|
1056
|
+
}
|
|
1057
|
+
const keys = [...new Set([...(Array.isArray(named) ? named.filter(isNonEmptyString) : []), "cell"])];
|
|
1058
|
+
return keys.map((key) => rowOf(subjectOf(cell?.route ?? null, cell?.viewport ?? null, key), E));
|
|
1059
|
+
};
|
|
1060
|
+
// A record-level failure: every listed cell fails; every route the subject
|
|
1061
|
+
// or a cell names, at every recorded or fixed viewport, that no cell lists
|
|
1062
|
+
// gets its `cell` row; and the uncaptured routes and the enumeration cap
|
|
1063
|
+
// keep the rows they would read.
|
|
1064
|
+
const failAll = () => {
|
|
1065
|
+
const listed = new Set(cells.map((cell) => cellKey(cell.route, cell.viewport)));
|
|
1066
|
+
const routes = [...(Array.isArray(record?.subject?.routes) ? record.subject.routes : []), ...cells.map((cell) => cell.route)].filter(isNonEmptyString);
|
|
1067
|
+
const uncaptured = (Array.isArray(record?.uncaptured_routes) ? record.uncaptured_routes : []).filter(isNonEmptyString);
|
|
1068
|
+
let fixed = [];
|
|
1069
|
+
try {
|
|
1070
|
+
fixed = Array.isArray(rules?.viewports) ? rules.viewports : [];
|
|
1071
|
+
} catch {
|
|
1072
|
+
fixed = [];
|
|
1073
|
+
}
|
|
1074
|
+
const viewports = [...(Array.isArray(record?.subject?.viewports) ? record.subject.viewports : []), ...fixed].filter(isNonEmptyString);
|
|
1075
|
+
const grid = [...new Set([...routes, ...uncaptured])].flatMap((route) => [...new Set(viewports)].map((viewport) => ({ route, viewport })));
|
|
1076
|
+
const rows = [
|
|
1077
|
+
...cells.flatMap(failCell),
|
|
1078
|
+
...grid.filter(({ route, viewport }) => !listed.has(cellKey(route, viewport))).map(({ route, viewport }) => rowOf(subjectOf(route, viewport, "cell"), E)),
|
|
1079
|
+
...(record?.route_enumeration_capped === true ? [rowOf(subjectOf(null, null, "scope"), E)] : []),
|
|
1080
|
+
];
|
|
1081
|
+
return unique(rows.length ? rows : [rowOf(subjectOf(null, null, "readability"), E)]);
|
|
1082
|
+
};
|
|
1083
|
+
const evaluate = (cell, cropAvailable) => {
|
|
1084
|
+
try {
|
|
1085
|
+
return rules.evaluate(cell, record.thresholds, { cropAvailable });
|
|
1086
|
+
} catch {
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
const resolved = resolvePolishRules({ check, qcStandIns, rederivers });
|
|
1091
|
+
if (resolved.status === "missing") return [];
|
|
1092
|
+
if (resolved.status !== "loaded") return failAll();
|
|
1093
|
+
({ rules } = resolved);
|
|
1094
|
+
if (typeof rules.routes !== "function" || typeof rules.cellShapeOk !== "function") return failAll();
|
|
1095
|
+
|
|
1096
|
+
// 1. Record level.
|
|
1097
|
+
const subject = record?.subject;
|
|
1098
|
+
const viewports = rules.viewports;
|
|
1099
|
+
if (!isPlainObject(record)
|
|
1100
|
+
|| record.schema_version !== rules.schema_version
|
|
1101
|
+
|| record.performed_by !== QC_PRODUCERS.polish
|
|
1102
|
+
|| record.helper_version !== rules.helper_version
|
|
1103
|
+
|| record.helper_version !== contrast.version
|
|
1104
|
+
|| !measuredAt
|
|
1105
|
+
|| record.integrity !== polishRecordIntegrity(record)
|
|
1106
|
+
|| !sameJson(record.thresholds, rules.thresholds)
|
|
1107
|
+
|| !sameJson(record.limits, rules.limits)
|
|
1108
|
+
|| !hasExactly(subject, READABILITY_SUBJECT_FIELDS)
|
|
1109
|
+
|| !(subject.source_package_material_fingerprint === null || isNonEmptyString(subject.source_package_material_fingerprint))
|
|
1110
|
+
|| !sameJson(subject.viewports, viewports)
|
|
1111
|
+
|| subject.route_source !== rules.route_source
|
|
1112
|
+
|| !isNonEmptyString(campaignSlug)
|
|
1113
|
+
|| subject.campaign_slug !== campaignSlug
|
|
1114
|
+
|| !uniqueStrings(subject.routes)
|
|
1115
|
+
|| !Array.isArray(record.uncaptured_routes)
|
|
1116
|
+
|| !(record.uncaptured_routes.length === 0 || uniqueStrings(record.uncaptured_routes))
|
|
1117
|
+
|| record.uncaptured_routes.some((route) => subject.routes.includes(route))
|
|
1118
|
+
|| typeof record.route_enumeration_capped !== "boolean"
|
|
1119
|
+
|| !Array.isArray(record.cells)
|
|
1120
|
+
|| cells.length !== record.cells.length
|
|
1121
|
+
|| !cells.every((cell) => rules.cellShapeOk(cell))) return failAll();
|
|
1122
|
+
// The cell set is exactly routes × viewports: none missing, none added,
|
|
1123
|
+
// none twice, every cell's route and viewport in the subject.
|
|
1124
|
+
const gridKeys = declaredGrid(subject).map(({ route, viewport }) => cellKey(route, viewport));
|
|
1125
|
+
const cellKeys = cells.map((cell) => cellKey(cell.route, cell.viewport));
|
|
1126
|
+
if (new Set(cellKeys).size !== cellKeys.length
|
|
1127
|
+
|| cellKeys.length !== gridKeys.length
|
|
1128
|
+
|| !cells.every((cell) => subject.routes.includes(cell.route) && subject.viewports.includes(cell.viewport))) return failAll();
|
|
1129
|
+
|
|
1130
|
+
// Cell level: every element re-derives, and a shared cell's page_load
|
|
1131
|
+
// integrity is that route × viewport's capture integrity.
|
|
1132
|
+
const captures = Array.isArray(pageLoad?.captures) ? pageLoad.captures : [];
|
|
1133
|
+
const cellReadable = (cell) => {
|
|
1134
|
+
if (!cell.elements.every(readabilityElementReproduces)) return false;
|
|
1135
|
+
if (cell.page_load_integrity === null) return true;
|
|
1136
|
+
const matching = captures.filter((capture) => capture?.subject?.requested_route === cell.route && capture?.subject?.viewport === cell.viewport);
|
|
1137
|
+
if (matching.length !== 1) return false;
|
|
1138
|
+
const [capture] = matching;
|
|
1139
|
+
return isPlainObject(capture.integrity)
|
|
1140
|
+
&& sameJson(buildPolishCaptureIntegrity(capture), capture.integrity)
|
|
1141
|
+
&& capture.integrity.projection_fingerprint === cell.page_load_integrity;
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
// 2. Binding.
|
|
1145
|
+
const bound = isNonEmptyString(currentBuild)
|
|
1146
|
+
&& subject.build_fingerprint === currentBuild
|
|
1147
|
+
&& subject.source_package_material_fingerprint === (isNonEmptyString(currentSource) ? currentSource : null)
|
|
1148
|
+
&& !outputDrifted(buildOutput ?? null);
|
|
1149
|
+
|
|
1150
|
+
// 3. The recomputed route set, read only when the output is the build's.
|
|
1151
|
+
const results = [];
|
|
1152
|
+
const notCaptured = (route) => subject.viewports.map((viewport) => rowOf(subjectOf(route, viewport, "cell"), PAGE_NOT_CAPTURED));
|
|
1153
|
+
const scopeRow = () => rowOf(subjectOf(null, null, "scope"), "route_enumeration_capped");
|
|
1154
|
+
if (bound) {
|
|
1155
|
+
let built;
|
|
1156
|
+
try {
|
|
1157
|
+
built = rules.routes(targetRepo, campaignSlug);
|
|
1158
|
+
} catch {
|
|
1159
|
+
built = null;
|
|
1160
|
+
}
|
|
1161
|
+
if (!isPlainObject(built) || built.ok !== true
|
|
1162
|
+
|| !subject.routes.every((route) => built.routes.includes(route))
|
|
1163
|
+
|| !sameJson(record.uncaptured_routes, built.uncaptured_routes)
|
|
1164
|
+
|| record.route_enumeration_capped !== built.route_enumeration_capped) return failAll();
|
|
1165
|
+
for (const route of built.routes.filter((route) => !subject.routes.includes(route))) results.push(...notCaptured(route));
|
|
1166
|
+
}
|
|
1167
|
+
for (const route of record.uncaptured_routes) results.push(...notCaptured(route).map((row) => (bound ? row : staleRow(row))));
|
|
1168
|
+
if (record.route_enumeration_capped) results.push(bound ? scopeRow() : staleRow(scopeRow()));
|
|
1169
|
+
|
|
1170
|
+
// 4. The rows.
|
|
1171
|
+
const cropAvailable = (ref) => rules.cropRefShapeOk?.(ref) === true && readabilityCropResolves(ref, targetRepo);
|
|
1172
|
+
for (const cell of cells) {
|
|
1173
|
+
if (!cellReadable(cell)) {
|
|
1174
|
+
results.push(...failCell(cell));
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const derived = evaluate(cell, cropAvailable);
|
|
1178
|
+
// Every derived subject is the cell's own page and viewport, each key once.
|
|
1179
|
+
const keys = Array.isArray(derived) ? derived.map((item) => item?.subject?.key) : [];
|
|
1180
|
+
if (!Array.isArray(derived) || new Set(keys).size !== keys.length || !derived.every((item) => item?.check === check
|
|
1181
|
+
&& validDerived(item, check)
|
|
1182
|
+
&& hasExactly(item.subject, ["check", "page", "viewport", "key"])
|
|
1183
|
+
&& item.subject.page === cell.route
|
|
1184
|
+
&& item.subject.viewport === cell.viewport
|
|
1185
|
+
&& isNonEmptyString(item.subject.key))) {
|
|
1186
|
+
results.push(...failCell(cell));
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
for (const item of derived) {
|
|
1190
|
+
const members = item.members ?? [];
|
|
1191
|
+
const row = {
|
|
1192
|
+
schema: QC_RESULT_SCHEMA,
|
|
1193
|
+
id: qcResultId(item.subject),
|
|
1194
|
+
check,
|
|
1195
|
+
leg: "polish",
|
|
1196
|
+
result: item.result,
|
|
1197
|
+
reason_code: item.result === "pass" ? null : item.reason_code,
|
|
1198
|
+
subject: item.subject,
|
|
1199
|
+
state_fingerprint: qcStateFingerprint({ subject: item.subject, state: item.state }),
|
|
1200
|
+
observation: isPlainObject(item.observation) ? item.observation : {},
|
|
1201
|
+
members,
|
|
1202
|
+
accept_eligible: item.accept_eligible && qcAcceptEligible(item.result, members),
|
|
1203
|
+
coverage: item.coverage ?? { observed: 1, expected: 1, limits: [] },
|
|
1204
|
+
measured_at: measuredAt,
|
|
1205
|
+
producer: QC_PRODUCERS.polish,
|
|
1206
|
+
};
|
|
1207
|
+
results.push(bound ? row : staleRow(row));
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return results;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
957
1213
|
// ---------------------------------------------------------------------------
|
|
958
1214
|
// Every current result, per leg, and the silence rule
|
|
959
1215
|
|
|
@@ -961,6 +1217,13 @@ const QA_ALWAYS = Object.freeze(["tracking.url", "tracking.order", "tracking.tag
|
|
|
961
1217
|
const QA_CONTENT = Object.freeze(["content_param"]);
|
|
962
1218
|
const QA_POLICY = Object.freeze(["policy.presence", "policy.availability"]);
|
|
963
1219
|
const POLISH_CHECKS = Object.freeze(qcChecksForLeg("polish"));
|
|
1220
|
+
// The Polish dispatch: each package-owned visual_review record, the checks
|
|
1221
|
+
// read from it, and its reader. Each record is read, and its silence judged,
|
|
1222
|
+
// on its own.
|
|
1223
|
+
const POLISH_RECORDS = Object.freeze({
|
|
1224
|
+
media_weight: Object.freeze({ checks: Object.freeze(qcChecksForRecord("media_weight")), read: readMediaWeight }),
|
|
1225
|
+
readability: Object.freeze({ checks: Object.freeze(qcChecksForRecord("readability")), read: readReadability }),
|
|
1226
|
+
});
|
|
964
1227
|
const STORE_POLICY_FIELDS = Object.freeze(Object.keys(STORE_PAGE_MATCHERS));
|
|
965
1228
|
|
|
966
1229
|
// Applicability is decided without the leg running: the tracking checks
|
|
@@ -1014,15 +1277,34 @@ export function handoffCoverage({ report, spec, results = [], qcStandIns = null,
|
|
|
1014
1277
|
for (const check of POLISH_CHECKS) entries.push(silence("polish", check, QC_REASON.LEG_NOT_RUN));
|
|
1015
1278
|
} else {
|
|
1016
1279
|
const visual = polishStage.evidence?.visual_review;
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1280
|
+
for (const [name, { checks }] of Object.entries(POLISH_RECORDS)) {
|
|
1281
|
+
const captured = name === "media_weight" ? isPlainObject(visual?.page_load) && visual.media_weight != null : visual?.[name] != null;
|
|
1282
|
+
for (const check of checks) {
|
|
1283
|
+
if (hasRow("polish", check)) continue;
|
|
1284
|
+
const failed = captured && resolvePolishRules({ check, qcStandIns, rederivers }).status === "failed";
|
|
1285
|
+
entries.push(silence("polish", check, silentReason(failed)));
|
|
1286
|
+
}
|
|
1021
1287
|
}
|
|
1022
1288
|
}
|
|
1023
1289
|
return entries;
|
|
1024
1290
|
}
|
|
1025
1291
|
|
|
1292
|
+
// The campaign slug the Polish records bind to: the packet's, read from
|
|
1293
|
+
// packetPath; with no readable packet, the Assembly Report's (which capture
|
|
1294
|
+
// requires to equal the packet's). Null when neither names one.
|
|
1295
|
+
function packetCampaignSlug({ packetPath, report }) {
|
|
1296
|
+
if (isNonEmptyString(packetPath)) {
|
|
1297
|
+
try {
|
|
1298
|
+
const slug = JSON.parse(readFileSync(packetPath, "utf8"))?.campaign?.public_route_slug;
|
|
1299
|
+
return isNonEmptyString(slug) ? slug : null;
|
|
1300
|
+
} catch {
|
|
1301
|
+
// No readable packet: fall through to the report.
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const slug = report?.identity?.public_route_slug;
|
|
1305
|
+
return isNonEmptyString(slug) ? slug : null;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1026
1308
|
// Every current QC result `next` and `checkpoint accept` judge, read from
|
|
1027
1309
|
// data already on disk: the doctor run, the Assembly Report, and the full QA
|
|
1028
1310
|
// verdict the QA stage names. No request is made.
|
|
@@ -1030,9 +1312,18 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
|
|
|
1030
1312
|
const currentBuild = currentBuildFingerprint(report);
|
|
1031
1313
|
const doctorResults = (Array.isArray(doctor?.derived?.qc_results) ? doctor.derived.qc_results : []).filter((row) => isPlainObject(row) && row.leg === "doctor");
|
|
1032
1314
|
const visual = report?.stages?.polish?.evidence?.visual_review;
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1035
|
-
:
|
|
1315
|
+
const recorded = isPlainObject(visual) ? Object.entries(POLISH_RECORDS).filter(([name]) => visual[name] != null) : [];
|
|
1316
|
+
const polishContext = recorded.length ? {
|
|
1317
|
+
pageLoad: visual.page_load,
|
|
1318
|
+
currentBuild,
|
|
1319
|
+
currentSource: currentSourcePackageMaterialFingerprint(report),
|
|
1320
|
+
buildOutput: doctor?.derived?.build_output_fingerprint ?? null,
|
|
1321
|
+
campaignSlug: packetCampaignSlug({ packetPath, report }),
|
|
1322
|
+
targetRepo,
|
|
1323
|
+
qcStandIns,
|
|
1324
|
+
rederivers,
|
|
1325
|
+
} : null;
|
|
1326
|
+
const polishResults = recorded.flatMap(([name, { read }]) => read({ ...polishContext, record: visual[name] }));
|
|
1036
1327
|
const qaStage = report?.stages?.qa;
|
|
1037
1328
|
const qaResults = Array.isArray(qaStage?.evidence?.qc_results)
|
|
1038
1329
|
? readQaResults({
|
|
@@ -1040,6 +1331,7 @@ export function readCurrentQcResults({ report, doctor, spec = null, targetRepo,
|
|
|
1040
1331
|
stage: qaStage,
|
|
1041
1332
|
fullVerdict: readQaFullVerdict({ stage: qaStage, targetRepo, packetPath, reportPath }),
|
|
1042
1333
|
currentBuild,
|
|
1334
|
+
qaCurrency: doctor?.derived?.input_currency?.stages?.qa,
|
|
1043
1335
|
qcStandIns,
|
|
1044
1336
|
rederivers,
|
|
1045
1337
|
})
|
package/src/readback.mjs
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* most once, plus two fixed Git metadata files for the staleness comparison
|
|
9
9
|
* (the nearest `.git` entry at the target or one of its ancestors — funnels are
|
|
10
10
|
* usually subdirectories of their enclosing campaign repository — to locate the
|
|
11
|
-
* Git directory, and that directory's `logs/HEAD` reflog)
|
|
11
|
+
* Git directory, and that directory's `logs/HEAD` reflog), plus the CampaignSpec
|
|
12
|
+
* and normalized Campaign Build Brief the Build Packet names, whose current
|
|
13
|
+
* content decides each stage's effective status. It writes nothing,
|
|
12
14
|
* starts no process, and touches no network. That contract is why the CLI
|
|
13
15
|
* exempts `readback` from lifecycle-journal capture the way it exempts doctor
|
|
14
16
|
* inspection: a command declared read-only must not append a journal entry.
|
|
@@ -49,6 +51,9 @@ import { closeSync, fstatSync, openSync, readdirSync, readSync, statSync } from
|
|
|
49
51
|
import { isAbsolute, join, resolve } from "node:path";
|
|
50
52
|
import { fileURLToPath } from "node:url";
|
|
51
53
|
|
|
54
|
+
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus } from "./input-currency.mjs";
|
|
55
|
+
import { deriveAssemblyReportSummary } from "./stage-ledger.mjs";
|
|
56
|
+
|
|
52
57
|
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
53
58
|
export const MAX_GIT_METADATA_BYTES = 64 * 1024;
|
|
54
59
|
|
|
@@ -1268,22 +1273,52 @@ export function renderBlocker(blocker) {
|
|
|
1268
1273
|
return `(non-string entry, shown as written) ${rendered}`;
|
|
1269
1274
|
}
|
|
1270
1275
|
|
|
1271
|
-
|
|
1276
|
+
/**
|
|
1277
|
+
* The campaign's current inputs, read through the projected Build Packet: the
|
|
1278
|
+
* material of the CampaignSpec and of the normalized brief it names. A value
|
|
1279
|
+
* that cannot be read is null, and the stages that depend on it read unknown.
|
|
1280
|
+
*/
|
|
1281
|
+
export function readbackInputs(views) {
|
|
1282
|
+
const packet = artifactData(views, "packet");
|
|
1283
|
+
if (packet === null) return { briefMaterial: null, specMaterial: null };
|
|
1284
|
+
return currentPacketInputs({ packet, packetPath: resolve(views.packet.path) });
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Superseded records shown per stage, newest last (the report keeps five).
|
|
1288
|
+
const HISTORY_RENDER_CAP = 5;
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Each stage at its effective status against `inputs` (readbackInputs): a
|
|
1292
|
+
* stage owed again by a brief or CampaignSpec change reads `required`, one
|
|
1293
|
+
* whose inputs cannot be confirmed reads `unknown`, and where that differs
|
|
1294
|
+
* from the record the line adds the recorded status and the reason. The
|
|
1295
|
+
* header carries the report's recorded status beside the one recomputed from
|
|
1296
|
+
* the effective statuses. Superseded records are listed only under the
|
|
1297
|
+
* "history (not current proof)" label.
|
|
1298
|
+
*/
|
|
1299
|
+
function renderStages(views, lines, inputs = null) {
|
|
1272
1300
|
const report = artifactData(views, "report");
|
|
1273
1301
|
if (report === null) return;
|
|
1274
|
-
|
|
1302
|
+
const current = { briefMaterial: inputs?.briefMaterial ?? null, specMaterial: inputs?.specMaterial ?? null };
|
|
1303
|
+
const currency = assessInputCurrency({ report, ...current });
|
|
1304
|
+
const effectiveReport = deriveAssemblyReportSummary(report, current).status;
|
|
1305
|
+
lines.push(`STAGES [assembly report; report status: ${recorded(report.status)}; effective: ${effectiveReport}]`);
|
|
1275
1306
|
const stages = report.stages;
|
|
1276
1307
|
if (isPlainObject(stages)) {
|
|
1277
1308
|
for (const [name, stage] of Object.entries(stages)) {
|
|
1278
1309
|
if (!isPlainObject(stage)) continue;
|
|
1279
1310
|
const status = stage.status ?? "(no status recorded)";
|
|
1311
|
+
const effective = effectiveStageStatus(name, stage, currency);
|
|
1312
|
+
const shown = effective === stage.status
|
|
1313
|
+
? status
|
|
1314
|
+
: `${effective.padEnd(10)} (recorded: ${status}; ${currency.reasons?.[name] ?? "status_unrecognized"})`;
|
|
1280
1315
|
const counters = [];
|
|
1281
1316
|
const blockers = stage.blockers;
|
|
1282
1317
|
const warnings = stage.warnings;
|
|
1283
1318
|
if (Array.isArray(blockers) && blockers.length) counters.push(`${blockers.length} blocker(s)`);
|
|
1284
1319
|
if (Array.isArray(warnings) && warnings.length) counters.push(`${warnings.length} warning(s)`);
|
|
1285
1320
|
const suffix = counters.length ? ` (${counters.join(", ")})` : "";
|
|
1286
|
-
lines.push(` ${name.padEnd(14)} ${
|
|
1321
|
+
lines.push(` ${name.padEnd(14)} ${shown}${suffix}`);
|
|
1287
1322
|
if (Array.isArray(blockers) && blockers.length) {
|
|
1288
1323
|
for (const blocker of blockers.slice(0, BLOCKER_RENDER_CAP)) {
|
|
1289
1324
|
lines.push(` blocker: ${renderBlocker(blocker)}`);
|
|
@@ -1291,6 +1326,16 @@ function renderStages(views, lines) {
|
|
|
1291
1326
|
const hidden = blockers.length - BLOCKER_RENDER_CAP;
|
|
1292
1327
|
if (hidden > 0) lines.push(` ... and ${hidden} more blocker(s) recorded in the assembly report`);
|
|
1293
1328
|
}
|
|
1329
|
+
const history = Array.isArray(stage.history) ? stage.history.filter(isPlainObject) : [];
|
|
1330
|
+
if (history.length) {
|
|
1331
|
+
lines.push(` history (not current proof): ${history.length} superseded record(s)`);
|
|
1332
|
+
for (const entry of history.slice(-HISTORY_RENDER_CAP)) {
|
|
1333
|
+
lines.push(
|
|
1334
|
+
` ${oneLine(String(recorded(entry.archived_at)))} ${oneLine(String(recorded(entry.reason_code)))}: ` +
|
|
1335
|
+
`was ${oneLine(String(recorded(entry.status)))}${entry.archived_by ? ` (archived by ${oneLine(String(entry.archived_by))})` : ""}`,
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1294
1339
|
}
|
|
1295
1340
|
}
|
|
1296
1341
|
lines.push("");
|
|
@@ -1496,8 +1541,10 @@ function renderFindings(views, lines) {
|
|
|
1496
1541
|
* artifact table says nothing about how the packet was chosen. The
|
|
1497
1542
|
* one-argument form keeps working: the artifact table shows each loaded
|
|
1498
1543
|
* artifact's generated_at regardless of whether an assessment was supplied.
|
|
1544
|
+
* `inputs` is readbackInputs(views); when omitted, every input reads as not
|
|
1545
|
+
* readable, so a build, Polish or QA record reads unknown.
|
|
1499
1546
|
*/
|
|
1500
|
-
export function projectReadback(views, staleness = null, packetSelection = null) {
|
|
1547
|
+
export function projectReadback(views, staleness = null, packetSelection = null, inputs = null) {
|
|
1501
1548
|
const lines = [
|
|
1502
1549
|
"CAMPAIGNS OS RUN-ARTIFACT READBACK",
|
|
1503
1550
|
"A read-only projection of this run's emitted artifacts. Campaigns OS",
|
|
@@ -1509,7 +1556,7 @@ export function projectReadback(views, staleness = null, packetSelection = null)
|
|
|
1509
1556
|
renderStaleness(staleness, lines);
|
|
1510
1557
|
renderArtifactTable(views, lines, packetSelection);
|
|
1511
1558
|
renderIdentity(views, lines);
|
|
1512
|
-
renderStages(views, lines);
|
|
1559
|
+
renderStages(views, lines, inputs);
|
|
1513
1560
|
renderContext(views, lines);
|
|
1514
1561
|
renderDoctor(views, lines);
|
|
1515
1562
|
renderVerdict(views, lines);
|
|
@@ -1806,7 +1853,7 @@ export function resolvePaths(root, overrides = {}) {
|
|
|
1806
1853
|
export function projectTarget(root, overrides = {}) {
|
|
1807
1854
|
const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
|
|
1808
1855
|
const views = loadArtifacts(paths, { packet: packetView });
|
|
1809
|
-
return { views, staleness: assessStaleness(root, views), packetSelection };
|
|
1856
|
+
return { views, staleness: assessStaleness(root, views), packetSelection, inputs: readbackInputs(views) };
|
|
1810
1857
|
}
|
|
1811
1858
|
|
|
1812
1859
|
// The bundled synthetic sample `--example` projects. It is already on the
|
|
@@ -1837,6 +1884,7 @@ export const EXAMPLE_HEAD_DETAIL =
|
|
|
1837
1884
|
export function projectExample() {
|
|
1838
1885
|
const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
|
|
1839
1886
|
const views = loadArtifacts(paths, { packet: packetView });
|
|
1887
|
+
const inputs = readbackInputs(views);
|
|
1840
1888
|
for (const view of Object.values(views)) {
|
|
1841
1889
|
const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1842
1890
|
view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
|
|
@@ -1844,7 +1892,7 @@ export function projectExample() {
|
|
|
1844
1892
|
const staleness = assessStaleness(EXAMPLE_ROOT, views, {
|
|
1845
1893
|
headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
|
|
1846
1894
|
});
|
|
1847
|
-
return { views, staleness, packetSelection };
|
|
1895
|
+
return { views, staleness, packetSelection, inputs };
|
|
1848
1896
|
}
|
|
1849
1897
|
|
|
1850
1898
|
const OVERRIDE_FLAGS = {
|
|
@@ -1938,9 +1986,9 @@ export function runReadbackCommand(args) {
|
|
|
1938
1986
|
if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
|
|
1939
1987
|
throw error;
|
|
1940
1988
|
}
|
|
1941
|
-
const { views, staleness, packetSelection } = projection;
|
|
1989
|
+
const { views, staleness, packetSelection, inputs } = projection;
|
|
1942
1990
|
const text = request.json
|
|
1943
1991
|
? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
|
|
1944
|
-
: projectReadback(views, staleness, packetSelection);
|
|
1992
|
+
: projectReadback(views, staleness, packetSelection, inputs);
|
|
1945
1993
|
return { exitCode: 0, text };
|
|
1946
1994
|
}
|