@nextcommerce/campaigns-os 1.48.0 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/agents/claude/CLAUDE.md +6 -5
  3. package/agents/codex/AGENTS.md +6 -5
  4. package/agents/copilot/copilot-instructions.md +3 -3
  5. package/agents/cursor/campaigns-os.mdc +3 -3
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +254 -2
  13. package/contracts/release-ledger.json +1239 -0
  14. package/contracts/supported-surface.json +4 -4
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +12 -6
  18. package/docs/build-packet.md +101 -12
  19. package/docs/campaign-build-brief.md +25 -1
  20. package/docs/effects.md +6 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +10 -0
  24. package/docs/qa-and-test-orders.md +66 -11
  25. package/docs/runtime-readiness.md +1 -1
  26. package/docs/sdk-storage-compatibility.md +1 -1
  27. package/docs/skills-revision.md +10 -10
  28. package/package.json +1 -1
  29. package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -0
  30. package/schemas/campaigns-os-qa-verdict.v0.schema.json +8 -3
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +4 -4
  36. package/skills/next-campaigns-os/SKILL.md +4 -4
  37. package/skills/next-campaigns-os/references/session-intake.md +7 -3
  38. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  39. package/skills/next-campaigns-polish/SKILL.md +5 -4
  40. package/skills/next-campaigns-qa/SKILL.md +6 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +25 -2
  44. package/src/build-brief.mjs +68 -21
  45. package/src/built-site-scope.mjs +39 -6
  46. package/src/built-smoke-qc.mjs +1117 -0
  47. package/src/campaign-identity.mjs +36 -2
  48. package/src/cart-placeholders.mjs +730 -0
  49. package/src/cli.mjs +320 -42
  50. package/src/commercial-journey.mjs +65 -4
  51. package/src/commercial-parity.mjs +6 -1
  52. package/src/doctor/checks.mjs +291 -24
  53. package/src/doctor/inspect.mjs +53 -2
  54. package/src/doctor/next-step.mjs +1 -1
  55. package/src/install-mode.mjs +0 -8
  56. package/src/invocation.mjs +5 -2
  57. package/src/local-preview-policy.mjs +1 -1
  58. package/src/local-proof.mjs +4 -1
  59. package/src/polish-browser.mjs +218 -1
  60. package/src/polish-capture.mjs +1 -1
  61. package/src/polish-media-weight.mjs +492 -0
  62. package/src/polish-node.mjs +96 -4
  63. package/src/progress-node.mjs +5 -1
  64. package/src/qa-binding-evidence.mjs +21 -0
  65. package/src/qa-browser.mjs +338 -97
  66. package/src/qa-content-params.mjs +889 -0
  67. package/src/qa-node.mjs +114 -14
  68. package/src/qa-order-bump.mjs +22 -1
  69. package/src/qa-policy-links.mjs +1019 -0
  70. package/src/qa-tracking-params.mjs +1389 -0
  71. package/src/qa-url-privacy.mjs +168 -0
  72. package/src/qc-accept.mjs +446 -0
  73. package/src/qc-check-registry.mjs +83 -0
  74. package/src/qc-results.mjs +1049 -0
  75. package/src/sdk-attribute-index.mjs +71 -0
  76. package/src/sdk-markup.mjs +2 -2
  77. package/src/sdk-storage-compatibility.mjs +63 -3
  78. package/src/source-prep.mjs +37 -7
  79. package/src/stage-record.mjs +356 -36
  80. package/src/theme-gate.mjs +3 -3
@@ -11,3 +11,171 @@ export function redactUrlQuery(value) {
11
11
  return text.split(/[?#]/)[0] || null;
12
12
  }
13
13
  }
14
+
15
+ // Every persisted string keeps nothing from its first query on: from the
16
+ // first "?" it holds, written literally or reached by percent-decoding it
17
+ // (any case, any depth: "%3F", "%3f", "%253F", "%%33F"), to the end of the
18
+ // string, whatever follows, is replaced by this marker. It holds no "?" or
19
+ // "%", and its "<" ends a URL quoted before it, so that URL keeps its own
20
+ // text up to the cut and nothing after it.
21
+ export const REDACTED_QUERY = "<query-redacted>";
22
+ // A longer string is cut to this many characters, ending with TRUNCATED,
23
+ // before it is projected, so the projection's cost has a fixed bound.
24
+ const MAX_TEXT_LENGTH = 16 * 1024;
25
+ export const TRUNCATED = "[truncated]";
26
+ // Percent-decoding stops after this many rounds; a string still decoding
27
+ // then has no query start that can be located, and is replaced whole.
28
+ const MAX_DECODE_ROUNDS = 8;
29
+ const HEX_DIGIT = /^[0-9a-f]$/i;
30
+ const SCHEME_CHAR = /^[a-z0-9+.-]$/i;
31
+ const SCHEME_START = /^[a-z]$/i;
32
+ const URL_END = /^[\s"'<>]$/;
33
+ const SCHEME_RELATIVE_AFTER = /^[\s"'`(=<>]$/;
34
+ const AUTHORITY_END = /[/?#]/;
35
+
36
+ // The index in `text` where its first "?" begins, literally or once
37
+ // percent-decoded repeatedly; -1 when it has none; null when the text is
38
+ // still decoding after MAX_DECODE_ROUNDS rounds. Each decoded character keeps
39
+ // the index of the first original character it came from.
40
+ function queryStart(text) {
41
+ let chars = text.split("");
42
+ let starts = chars.map((_, index) => index);
43
+ for (let round = 0; ; round += 1) {
44
+ const nextChars = [];
45
+ const nextStarts = [];
46
+ for (let index = 0; index < chars.length; index += 1) {
47
+ if (chars[index] === "%" && index + 2 < chars.length && HEX_DIGIT.test(chars[index + 1]) && HEX_DIGIT.test(chars[index + 2])) {
48
+ nextChars.push(String.fromCharCode(Number.parseInt(`${chars[index + 1]}${chars[index + 2]}`, 16)));
49
+ nextStarts.push(starts[index]);
50
+ index += 2;
51
+ } else {
52
+ nextChars.push(chars[index]);
53
+ nextStarts.push(starts[index]);
54
+ }
55
+ }
56
+ if (nextChars.length === chars.length) break;
57
+ if (round === MAX_DECODE_ROUNDS) return null;
58
+ chars = nextChars;
59
+ starts = nextStarts;
60
+ }
61
+ const found = chars.indexOf("?");
62
+ return found < 0 ? -1 : starts[found];
63
+ }
64
+
65
+ // `url` without its userinfo: from `authorityStart` (just after its "//") up
66
+ // to and including the last "@" before the next "/", "?" or "#".
67
+ function withoutUserinfo(url, authorityStart) {
68
+ const length = url.slice(authorityStart).search(AUTHORITY_END);
69
+ const at = url.lastIndexOf("@", (length < 0 ? url.length : authorityStart + length) - 1);
70
+ return at < authorityStart ? url : `${url.slice(0, authorityStart)}${url.slice(at + 1)}`;
71
+ }
72
+
73
+ // Every scheme-relative URL quoted in `text` ("//" at its start or after a
74
+ // space, quote, "(" or "=", up to the next space, quote or angle bracket)
75
+ // stripped of its userinfo; the rest of its text, fragment included, is kept
76
+ // as written. A "//" inside a path is not one.
77
+ function withSchemeRelativeUserinfoStripped(text) {
78
+ let projected = "";
79
+ let from = 0;
80
+ let slashes = text.indexOf("//");
81
+ while (slashes >= 0) {
82
+ let end = slashes + 2;
83
+ if (slashes === 0 || SCHEME_RELATIVE_AFTER.test(text[slashes - 1])) {
84
+ while (end < text.length && !URL_END.test(text[end])) end += 1;
85
+ projected += text.slice(from, slashes) + withoutUserinfo(text.slice(slashes, end), 2);
86
+ from = end;
87
+ }
88
+ slashes = text.indexOf("//", end);
89
+ }
90
+ return projected + text.slice(from);
91
+ }
92
+
93
+ // Every absolute URL quoted in `text` ("<scheme>://" up to the next space,
94
+ // quote or angle bracket) cut at its first "?" or "#" and stripped of its
95
+ // userinfo, and every scheme-relative URL between them stripped of its
96
+ // userinfo, in one pass over the text. What it keeps is otherwise its own
97
+ // text: the host's case, its port and its scheme are left as written.
98
+ function withUrlsProjected(text) {
99
+ let projected = "";
100
+ let from = 0;
101
+ let separator = text.indexOf("://");
102
+ while (separator >= 0) {
103
+ let start = separator;
104
+ while (start > from && SCHEME_CHAR.test(text[start - 1])) start -= 1;
105
+ while (start < separator && !SCHEME_START.test(text[start])) start += 1;
106
+ let end = separator + 3;
107
+ if (start < separator) while (end < text.length && !URL_END.test(text[end])) end += 1;
108
+ if (end > separator + 3) {
109
+ projected += withSchemeRelativeUserinfoStripped(text.slice(from, start)) + withoutUserinfo(text.slice(start, end).split(/[?#]/)[0], separator - start + 3);
110
+ from = end;
111
+ }
112
+ separator = text.indexOf("://", end);
113
+ }
114
+ return projected + withSchemeRelativeUserinfoStripped(text.slice(from));
115
+ }
116
+
117
+ // One projection: the string capped at MAX_TEXT_LENGTH, cut at its first
118
+ // query (or replaced whole when that cannot be located), and every absolute
119
+ // URL left before the cut also cut at its fragment.
120
+ function projectText(value) {
121
+ const text = value.length > MAX_TEXT_LENGTH ? `${value.slice(0, MAX_TEXT_LENGTH - TRUNCATED.length)}${TRUNCATED}` : value;
122
+ const start = queryStart(text);
123
+ if (start === null) return REDACTED_QUERY;
124
+ if (start < 0) return withUrlsProjected(text);
125
+ return `${withUrlsProjected(text.slice(0, start))}${REDACTED_QUERY}`;
126
+ }
127
+
128
+ // The projection of free text that may quote URLs (an error message, a
129
+ // console line, a tag name, an object key). Its own output projects to
130
+ // itself: a string whose projection would change again is replaced whole by
131
+ // the marker.
132
+ export function redactUrlQueriesInText(value) {
133
+ if (typeof value !== "string") return value;
134
+ const projected = projectText(value);
135
+ return projectText(projected) === projected ? projected : REDACTED_QUERY;
136
+ }
137
+
138
+ // A value inside itself, and a value nested deeper than MAX_DEPTH, are
139
+ // persisted as these markers instead of being projected.
140
+ export const CIRCULAR = "[circular]";
141
+ export const TOO_DEEP = "[too-deep]";
142
+ const MAX_DEPTH = 256;
143
+
144
+ // The one projection of a persisted value: every string, and every object
145
+ // key, at any depth goes through redactUrlQueriesInText. A key that a
146
+ // projection makes equal to one already written keeps its value under the
147
+ // first free "<key> [n]" (n from 2), in the object's own key order. A value
148
+ // with toJSON is projected as it would serialize: its toJSON is called once,
149
+ // and what it returns is projected without calling toJSON on it again.
150
+ export function redactPersisted(value) {
151
+ return projectPersisted(value, new Set(), 0);
152
+ }
153
+
154
+ // `ancestors` holds every object being projected above this one (each value
155
+ // with toJSON and what its toJSON returned), so a value that holds itself is
156
+ // the CIRCULAR marker rather than an endless recursion.
157
+ function projectPersisted(input, ancestors, depth) {
158
+ if (typeof input === "string") return redactUrlQueriesInText(input);
159
+ if (!input || typeof input !== "object") return input;
160
+ if (ancestors.has(input)) return CIRCULAR;
161
+ if (depth >= MAX_DEPTH) return TOO_DEEP;
162
+ const value = typeof input.toJSON === "function" ? input.toJSON() : input;
163
+ if (typeof value === "string") return redactUrlQueriesInText(value);
164
+ if (!value || typeof value !== "object") return value;
165
+ if (ancestors.has(value)) return CIRCULAR;
166
+ const added = value === input ? [input] : [input, value];
167
+ for (const item of added) ancestors.add(item);
168
+ try {
169
+ if (Array.isArray(value)) return value.map((item) => projectPersisted(item, ancestors, depth + 1));
170
+ const entries = new Map();
171
+ for (const [key, item] of Object.entries(value)) {
172
+ const projected = redactUrlQueriesInText(key);
173
+ let name = projected;
174
+ for (let n = 2; entries.has(name); n += 1) name = `${projected} [${n}]`;
175
+ entries.set(name, projectPersisted(item, ancestors, depth + 1));
176
+ }
177
+ return Object.fromEntries(entries);
178
+ } finally {
179
+ for (const item of added) ancestors.delete(item);
180
+ }
181
+ }
@@ -0,0 +1,446 @@
1
+ // Operator accepts of QC warnings. An accept is stored
2
+ // apart from the measurement, in report.qc_accepts[], and changes only a
3
+ // result's disposition (open → operator_accepted). It never changes the
4
+ // result, doctor status, `next` status, QA disposition, or a stage status.
5
+ //
6
+ // Records are checked on every read (assessQcAccepts). The integrity checksum
7
+ // is unkeyed tamper evidence, not proof of authorship: a record a process with
8
+ // the same file access fully reconstructs reads as active, and that is the
9
+ // documented outcome.
10
+ import { createHash } from "node:crypto";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+
13
+ import { isNamedHuman, validateWaiverAttribution } from "./checkpoint-waiver.mjs";
14
+ import { DOCTOR_SIDECAR_SCHEMA } from "./doctor-sidecar.mjs";
15
+ import { cmd } from "./install-invocation.mjs";
16
+ import { canonicalJson } from "./polish-capture.mjs";
17
+ import { QC_LEGS, QC_REASON, fingerprint12, qcResultRef } from "./qc-results.mjs";
18
+ import { shellToken } from "./shell-token.mjs";
19
+
20
+ export const QC_ACCEPT_SCHEMA = "campaigns-os-qc-accept/v0";
21
+ export const QC_ACCEPT_SCOPE = "qc_accept";
22
+ export const QC_ACCEPT_RECORDER = "campaigns-os checkpoint accept";
23
+ export const QC_ACCEPT_STATUSES = Object.freeze(["active", "lapsed", "orphaned", "expired", "inert"]);
24
+ export const QC_DISPOSITION = Object.freeze({ OPEN: "open", OPERATOR_ACCEPTED: "operator_accepted" });
25
+ export const QC_MEASURED_SOURCES = Object.freeze({ doctor: "doctor_sidecar", polish: "polish_media_weight", qa: "qa_full_verdict" });
26
+
27
+ // The command's closed refusal list.
28
+ export const QC_ACCEPT_REFUSALS = Object.freeze({
29
+ ACCEPTED_BY_REQUIRED: "accepted_by_required",
30
+ ATTRIBUTION_INVALID: "attribution_invalid",
31
+ NO_PERSISTED_FINDING: "no_persisted_finding",
32
+ CHANGED_SINCE_HANDOFF: "changed_since_handoff",
33
+ TARGET_NOT_WARNING: "target_not_warning",
34
+ MEMBERS_UNRESOLVED: "members_unresolved",
35
+ });
36
+
37
+ // The producers that stamp the retained doctor sidecar (generated_by).
38
+ export const DOCTOR_SIDECAR_PRODUCERS = Object.freeze(["doctor", "next", "qa run", "start", "build"]);
39
+
40
+ const FINGERPRINT = /^sha256:[a-f0-9]{64}$/;
41
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
42
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
43
+ const validTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
44
+
45
+ // A typed refusal: `code` is one of QC_ACCEPT_REFUSALS, and `refused` names
46
+ // each offending ref with its own code.
47
+ export class QcAcceptRefusal extends Error {
48
+ constructor(code, message, refused = []) {
49
+ super(message);
50
+ this.name = "QcAcceptRefusal";
51
+ this.code = code;
52
+ this.refused = refused;
53
+ }
54
+ }
55
+
56
+ // Unkeyed sha256 over the canonical projection of every field but integrity
57
+ // (the buildPolishCaptureIntegrity pattern).
58
+ export function qcAcceptIntegrity(record) {
59
+ const { integrity: _ignored, ...rest } = isPlainObject(record) ? record : {};
60
+ return `sha256:${createHash("sha256").update(canonicalJson(rest)).digest("hex")}`;
61
+ }
62
+
63
+ // --accepted-by is checked here first so the refusal names the flag the
64
+ // operator typed; the shared rule (isNamedHuman) is the waiver lanes'.
65
+ export function checkAcceptedBy(acceptedBy) {
66
+ if (!isNonEmptyString(acceptedBy)) {
67
+ throw new QcAcceptRefusal(QC_ACCEPT_REFUSALS.ACCEPTED_BY_REQUIRED, "checkpoint accept requires --accepted-by \"<operator's name>\": the named person who decided this accept. There is no default.");
68
+ }
69
+ if (!isNamedHuman(acceptedBy)) {
70
+ throw new QcAcceptRefusal(QC_ACCEPT_REFUSALS.ATTRIBUTION_INVALID, `checkpoint accept refuses --accepted-by ${JSON.stringify(acceptedBy)}: it must name the operator who decided, not a placeholder or automation identity.`);
71
+ }
72
+ }
73
+
74
+ // Attribution through the shared waiver rule (label "accept", no bound
75
+ // required). Returns the normalized fields; throws attribution_invalid.
76
+ export function qcAcceptAttribution({ reason, acceptedBy, now, expiresAt = null, reviewCondition = null }) {
77
+ checkAcceptedBy(acceptedBy);
78
+ try {
79
+ const attribution = validateWaiverAttribution({ reason, waivedBy: acceptedBy, now, expiresAt, reviewCondition, requireBound: false, label: "accept" });
80
+ return {
81
+ reason: attribution.reason,
82
+ accepted_by: attribution.waived_by,
83
+ accepted_at: attribution.waived_at,
84
+ ...(attribution.expires_at ? { expires_at: attribution.expires_at } : {}),
85
+ ...(attribution.review_condition ? { review_condition: attribution.review_condition } : {}),
86
+ };
87
+ } catch (error) {
88
+ throw new QcAcceptRefusal(QC_ACCEPT_REFUSALS.ATTRIBUTION_INVALID, `checkpoint accept refused: ${error.message}`);
89
+ }
90
+ }
91
+
92
+ export function createQcAccept(result, { measuredAt, attribution }) {
93
+ const record = {
94
+ schema: QC_ACCEPT_SCHEMA,
95
+ scope: QC_ACCEPT_SCOPE,
96
+ result_id: result.id,
97
+ check: result.check,
98
+ leg: result.leg,
99
+ subject: result.subject,
100
+ state_fingerprint: result.state_fingerprint,
101
+ result_at_accept: "warning",
102
+ measured_at: measuredAt,
103
+ measured_source: QC_MEASURED_SOURCES[result.leg],
104
+ ...attribution,
105
+ recorded_by: QC_ACCEPT_RECORDER,
106
+ };
107
+ return { ...record, integrity: qcAcceptIntegrity(record) };
108
+ }
109
+
110
+ function malformed(record) {
111
+ return !(isPlainObject(record)
112
+ && record.schema === QC_ACCEPT_SCHEMA
113
+ && record.scope === QC_ACCEPT_SCOPE
114
+ && record.recorded_by === QC_ACCEPT_RECORDER
115
+ && isNonEmptyString(record.result_id)
116
+ && isNonEmptyString(record.check)
117
+ && QC_LEGS.includes(record.leg)
118
+ && isPlainObject(record.subject)
119
+ && FINGERPRINT.test(record.state_fingerprint || "")
120
+ && record.result_at_accept === "warning"
121
+ && validTime(record.measured_at)
122
+ && record.measured_source === QC_MEASURED_SOURCES[record.leg]
123
+ && typeof record.reason === "string"
124
+ && typeof record.accepted_by === "string"
125
+ && typeof record.accepted_at === "string"
126
+ && FINGERPRINT.test(record.integrity || ""));
127
+ }
128
+
129
+ function attributionValid(record) {
130
+ if (!isNamedHuman(record.accepted_by)) return false;
131
+ try {
132
+ validateWaiverAttribution({
133
+ reason: record.reason,
134
+ waivedBy: record.accepted_by,
135
+ now: record.accepted_at,
136
+ expiresAt: record.expires_at ?? null,
137
+ reviewCondition: record.review_condition ?? null,
138
+ requireBound: false,
139
+ label: "accept",
140
+ });
141
+ return true;
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+
147
+ // Classifies one record by the first matching rule of the ordered table.
148
+ function assessOne(record, currentById, nowMs) {
149
+ if (malformed(record)) return { status: "inert", why: "malformed" };
150
+ if (record.integrity !== qcAcceptIntegrity(record)) return { status: "inert", why: "integrity_mismatch" };
151
+ if (!attributionValid(record)) return { status: "inert", why: "attribution_invalid" };
152
+ if (!(Date.parse(record.accepted_at) > Date.parse(record.measured_at))) return { status: "inert", why: "accepted_not_after_measurement" };
153
+ const current = currentById.get(record.result_id);
154
+ if (!current) return { status: "orphaned", why: "no_current_result" };
155
+ if (current.result === "unexercised" && [QC_REASON.STALE_BINDING, QC_REASON.EVIDENCE_NOT_REPRODUCIBLE].includes(current.reason_code)) {
156
+ return { status: "lapsed", why: current.reason_code, current };
157
+ }
158
+ if (current.state_fingerprint !== record.state_fingerprint
159
+ || current.check !== record.check
160
+ || current.leg !== record.leg
161
+ || canonicalJson(current.subject) !== canonicalJson(record.subject)) {
162
+ return { status: "lapsed", why: "state_changed", current };
163
+ }
164
+ if (current.result !== "warning") return { status: "inert", why: "target_not_warning", current };
165
+ if (current.accept_eligible !== true) return { status: "inert", why: "members_unresolved", current };
166
+ if (record.expires_at != null && Date.parse(record.expires_at) <= nowMs) return { status: "expired", why: "expired", current };
167
+ return { status: "active", why: null, current };
168
+ }
169
+
170
+ // One assessment per record, in input order: {status, why, applied}. When
171
+ // several records are active for one result the newest accepted_at is the
172
+ // applied one, as in assessCheckpointWaivers.
173
+ export function assessQcAccepts(records, currentResults, { now = new Date().toISOString() } = {}) {
174
+ const list = Array.isArray(records) ? records : [];
175
+ const currentById = new Map((Array.isArray(currentResults) ? currentResults : []).filter((row) => isPlainObject(row) && isNonEmptyString(row.id)).map((row) => [row.id, row]));
176
+ const nowMs = Date.parse(now);
177
+ const assessed = list.map((record) => ({ ...assessOne(record, currentById, nowMs), applied: false }));
178
+ const newest = new Map();
179
+ assessed.forEach((assessment, index) => {
180
+ if (assessment.status !== "active") return;
181
+ const id = list[index].result_id;
182
+ const time = Date.parse(list[index].accepted_at);
183
+ if (!newest.has(id) || time >= newest.get(id).time) newest.set(id, { index, time });
184
+ });
185
+ for (const { index } of newest.values()) assessed[index].applied = true;
186
+ return assessed.map(({ current: _current, ...rest }) => rest);
187
+ }
188
+
189
+ // A bounded projection of a valid record, like projectCheckpointWaiver: the
190
+ // raw record never reaches an output.
191
+ export function projectQcAccept(record) {
192
+ if (malformed(record) || record.integrity !== qcAcceptIntegrity(record)) return null;
193
+ return {
194
+ result_ref: `${record.result_id}@${fingerprint12(record.state_fingerprint)}`,
195
+ result_id: record.result_id,
196
+ check: record.check,
197
+ leg: record.leg,
198
+ state_fingerprint: record.state_fingerprint,
199
+ measured_at: record.measured_at,
200
+ measured_source: record.measured_source,
201
+ reason: record.reason.trim(),
202
+ accepted_by: record.accepted_by.trim().replace(/\s+/g, " "),
203
+ accepted_at: record.accepted_at,
204
+ ...(record.expires_at == null ? {} : { expires_at: record.expires_at }),
205
+ ...(record.review_condition == null ? {} : { review_condition: String(record.review_condition).trim() }),
206
+ };
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // "The finding must have existed first."
211
+
212
+ export function parseQcResultRef(value) {
213
+ const text = typeof value === "string" ? value.trim() : "";
214
+ const at = text.lastIndexOf("@");
215
+ if (at <= 0) return null;
216
+ const id = text.slice(0, at);
217
+ const prefix = text.slice(at + 1);
218
+ return /^[a-f0-9]{12}$/.test(prefix) ? { ref: text, id, prefix } : null;
219
+ }
220
+
221
+ // Doctor precondition: the persisted sidecar was written by a package
222
+ // producer and holds the row with this id and fingerprint. A stale stamp does
223
+ // not matter; the row is compared with the fresh recomputation anyway.
224
+ export function doctorSidecarRow(sidecarPath, result) {
225
+ if (!isNonEmptyString(sidecarPath) || !existsSync(sidecarPath)) return null;
226
+ let sidecar;
227
+ try {
228
+ sidecar = JSON.parse(readFileSync(sidecarPath, "utf8"));
229
+ } catch {
230
+ return null;
231
+ }
232
+ if (!isPlainObject(sidecar) || sidecar.schema_version !== DOCTOR_SIDECAR_SCHEMA || !DOCTOR_SIDECAR_PRODUCERS.includes(sidecar.generated_by)) return null;
233
+ const rows = Array.isArray(sidecar.derived?.qc_results) ? sidecar.derived.qc_results : [];
234
+ return rows.find((row) => isPlainObject(row) && row.id === result.id && row.state_fingerprint === result.state_fingerprint && row.leg === "doctor") || null;
235
+ }
236
+
237
+ // Checks every ref against the current results, all or nothing. Returns the
238
+ // accept records to write, or throws a QcAcceptRefusal naming each offending
239
+ // ref. `results` are the reader-site results; `sidecarPath` the persisted
240
+ // doctor sidecar; `now` the command clock.
241
+ export function planQcAccepts({ refs, results, sidecarPath, now, attribution }) {
242
+ const byId = new Map(results.map((row) => [row.id, row]));
243
+ const refused = [];
244
+ const records = [];
245
+ for (const { ref, id, prefix } of refs) {
246
+ const current = byId.get(id);
247
+ const refuse = (code, detail) => refused.push({ result_ref: ref, refusal_code: code, detail });
248
+ if (!current) {
249
+ refuse(QC_ACCEPT_REFUSALS.CHANGED_SINCE_HANDOFF, `"${id}" has no current result; it changed since the handoff; re-run \`next\``);
250
+ continue;
251
+ }
252
+ // The prefix the operator saw is compared first, so a result whose state
253
+ // changed since the handoff is named as changed, whatever it reads now.
254
+ if (fingerprint12(current.state_fingerprint) !== prefix) {
255
+ refuse(QC_ACCEPT_REFUSALS.CHANGED_SINCE_HANDOFF, `"${id}" changed since the handoff; re-run \`next\``);
256
+ continue;
257
+ }
258
+ if (current.result !== "warning") {
259
+ refuse(QC_ACCEPT_REFUSALS.TARGET_NOT_WARNING, `"${id}" reads ${current.result}${current.reason_code ? ` (${current.reason_code})` : ""}; only a warning can be accepted`);
260
+ continue;
261
+ }
262
+ if (current.accept_eligible !== true) {
263
+ refuse(QC_ACCEPT_REFUSALS.MEMBERS_UNRESOLVED, `"${id}" is a warning with members still in review or unexercised; resolve them first`);
264
+ continue;
265
+ }
266
+ let measuredAt = current.measured_at;
267
+ if (current.leg === "doctor") {
268
+ const persisted = doctorSidecarRow(sidecarPath, current);
269
+ if (!persisted) {
270
+ refuse(QC_ACCEPT_REFUSALS.NO_PERSISTED_FINDING, `"${id}" is not on record in the doctor snapshot that \`next\` writes; run \`next\`, show the handoff, then accept`);
271
+ continue;
272
+ }
273
+ measuredAt = persisted.measured_at;
274
+ }
275
+ if (!validTime(measuredAt) || !(Date.parse(measuredAt) < Date.parse(now))) {
276
+ refuse(QC_ACCEPT_REFUSALS.NO_PERSISTED_FINDING, `"${id}" has no measurement recorded before this command`);
277
+ continue;
278
+ }
279
+ records.push(createQcAccept(current, { measuredAt, attribution }));
280
+ }
281
+ if (refused.length) {
282
+ const lead = refused[0].refusal_code;
283
+ throw new QcAcceptRefusal(lead, `checkpoint accept refused; nothing was written: ${refused.map((entry) => entry.detail).join("; ")}.`, refused);
284
+ }
285
+ return records;
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // The QC handoff `next` prints
290
+
291
+ const LEG_ORDER = Object.freeze({ doctor: 0, polish: 1, qa: 2 });
292
+ const compareText = (a, b) => String(a ?? "").localeCompare(String(b ?? ""));
293
+
294
+ function handoffEntry(row) {
295
+ return {
296
+ result_ref: qcResultRef(row),
297
+ check: row.check,
298
+ leg: row.leg,
299
+ page: row.subject?.page ?? null,
300
+ ...(row.subject?.viewport == null ? {} : { viewport: row.subject.viewport }),
301
+ key: row.subject?.key ?? null,
302
+ result: row.result,
303
+ reason_code: row.reason_code,
304
+ accept_eligible: row.accept_eligible === true,
305
+ members: Array.isArray(row.members) ? row.members : [],
306
+ summary: `${row.check} ${row.result}${row.reason_code ? ` (${row.reason_code})` : ""} on ${row.subject?.page ?? "(no page)"}${row.subject?.key == null ? "" : `: ${row.subject.key}`}`,
307
+ };
308
+ }
309
+
310
+ // Sorted by check, then key, then page.
311
+ const byCheckKeyPage = (a, b) => compareText(a.check, b.check) || compareText(a.key, b.key) || compareText(a.page, b.page) || compareText(a.viewport, b.viewport) || compareText(a.result_ref, b.result_ref);
312
+
313
+ // Open warnings grouped by check, then by key across pages (and viewports):
314
+ // a layout-shared defect is one entry listing its pages. The entry carries its
315
+ // first page's fields, `pages`, and `results`, which keeps every page's result
316
+ // with its own ref, accept eligibility and members, so every member stays
317
+ // visible. The entry is accept-eligible only when every result in it is.
318
+ function groupOpenWarnings(entries) {
319
+ const groups = new Map();
320
+ for (const entry of [...entries].sort(byCheckKeyPage)) {
321
+ const key = JSON.stringify([entry.check, entry.key]);
322
+ if (!groups.has(key)) groups.set(key, []);
323
+ groups.get(key).push(entry);
324
+ }
325
+ return [...groups.values()].map((results) => {
326
+ const [lead] = results;
327
+ const pages = [...new Set(results.map((entry) => entry.page).filter((page) => page != null))];
328
+ return {
329
+ ...lead,
330
+ pages,
331
+ accept_eligible: results.every((entry) => entry.accept_eligible),
332
+ summary: results.length > 1
333
+ ? `${lead.check} ${lead.result}${lead.reason_code ? ` (${lead.reason_code})` : ""} on ${pages.join(", ")}${lead.key == null ? "" : `: ${lead.key}`}`
334
+ : lead.summary,
335
+ results,
336
+ };
337
+ });
338
+ }
339
+
340
+ // The accept command for the invocation that produced the handoff: this
341
+ // install's spelling of the command, the packet `next` read and, when `next`
342
+ // read a report other than the packet's default one, `--report` naming it
343
+ // (checkpoint accept never follows the Build Context pointer). Only the
344
+ // operator's reason and name are left as placeholders.
345
+ function acceptCommand(refs, { packetPath, reportPath }) {
346
+ const packet = packetPath == null ? "<packet>" : shellToken(packetPath);
347
+ const report = reportPath == null ? "" : ` --report ${shellToken(reportPath)}`;
348
+ return cmd("checkpoint", `accept --packet ${packet}${report} ${refs.map((ref) => `--result ${shellToken(ref)}`).join(" ")} --reason "<operator's reason>" --accepted-by "<operator's name>"`);
349
+ }
350
+
351
+ // `reportPath` is given only when it is not the packet's default report
352
+ // (explicitReportPath).
353
+ export function buildQcHandoff({ results, coverage = [], accepts, now = new Date().toISOString(), packetPath = null, reportPath = null }) {
354
+ const records = Array.isArray(accepts) ? accepts : [];
355
+ const assessed = assessQcAccepts(records, results, { now });
356
+ const applied = new Map();
357
+ const lapsed = [];
358
+ const inert = [];
359
+ assessed.forEach((assessment, index) => {
360
+ const record = records[index];
361
+ if (assessment.applied) applied.set(record.result_id, record);
362
+ if (assessment.status === "inert") inert.push({ result_id: isPlainObject(record) && typeof record.result_id === "string" ? record.result_id : null, why: assessment.why });
363
+ if (assessment.status === "lapsed") {
364
+ const current = results.find((row) => row.id === record.result_id);
365
+ lapsed.push({
366
+ ...handoffEntry(current),
367
+ result_id: record.result_id,
368
+ accepted_by: record.accepted_by,
369
+ accepted_at: record.accepted_at,
370
+ why: assessment.why,
371
+ });
372
+ }
373
+ });
374
+ const open = [];
375
+ const review = [];
376
+ const accepted = [];
377
+ for (const row of results) {
378
+ const record = applied.get(row.id);
379
+ if (record) {
380
+ accepted.push({
381
+ ...handoffEntry(row),
382
+ disposition: QC_DISPOSITION.OPERATOR_ACCEPTED,
383
+ accepted_by: record.accepted_by,
384
+ accepted_at: record.accepted_at,
385
+ reason: record.reason,
386
+ });
387
+ continue;
388
+ }
389
+ if (row.result === "warning") open.push({ ...handoffEntry(row), disposition: QC_DISPOSITION.OPEN });
390
+ else if (row.result === "review") review.push({ ...handoffEntry(row), disposition: QC_DISPOSITION.OPEN });
391
+ }
392
+ const grouped = new Map();
393
+ for (const row of results) {
394
+ if (row.result !== "unexercised" && row.result !== "excluded") continue;
395
+ const key = JSON.stringify([row.leg, row.check, row.result, row.reason_code]);
396
+ const entry = grouped.get(key) || { check: row.check, leg: row.leg, result: row.result, reason_code: row.reason_code, count: 0, pages: [] };
397
+ entry.count += 1;
398
+ if (row.subject?.page != null && !entry.pages.includes(row.subject.page)) entry.pages.push(row.subject.page);
399
+ grouped.set(key, entry);
400
+ }
401
+ const coverageEntries = [...grouped.values(), ...coverage]
402
+ .map((entry) => ({ ...entry, pages: [...entry.pages].sort(compareText) }))
403
+ .sort((a, b) => (LEG_ORDER[a.leg] ?? 9) - (LEG_ORDER[b.leg] ?? 9) || compareText(a.check, b.check) || compareText(a.reason_code, b.reason_code) || compareText(a.result, b.result));
404
+ const openGroups = groupOpenWarnings(open);
405
+ review.sort(byCheckKeyPage);
406
+ lapsed.sort(byCheckKeyPage);
407
+ accepted.sort(byCheckKeyPage);
408
+ inert.sort((a, b) => compareText(a.result_id, b.result_id) || compareText(a.why, b.why));
409
+ const eligible = openGroups.flatMap((group) => group.results).filter((entry) => entry.accept_eligible).map((entry) => entry.result_ref);
410
+ return {
411
+ open: openGroups,
412
+ review,
413
+ lapsed,
414
+ coverage: coverageEntries,
415
+ accepted,
416
+ inert_accepts: inert,
417
+ accept_command: eligible.length ? acceptCommand(eligible, { packetPath, reportPath }) : null,
418
+ };
419
+ }
420
+
421
+ // The "QC handoff" text section of `next`.
422
+ export function qcHandoffTextLines(handoff) {
423
+ if (!isPlainObject(handoff)) return [];
424
+ const lines = ["QC handoff:"];
425
+ const section = (title, entries, format) => {
426
+ if (!entries?.length) return;
427
+ lines.push(` ${title}:`);
428
+ for (const entry of entries) {
429
+ const [first, ...rest] = [format(entry)].flat();
430
+ lines.push(` - ${first}`, ...rest);
431
+ }
432
+ };
433
+ const members = (entry) => (entry.members?.length ? ` [members: ${entry.members.map((member) => `${member.key} ${member.result}${member.reason_code ? ` (${member.reason_code})` : ""}`).join(", ")}]` : "");
434
+ const openLine = (entry) => `${entry.result_ref} ${entry.summary}${entry.accept_eligible ? "" : " (not accept-eligible)"}${members(entry)}`;
435
+ section("Open warnings", handoff.open, (entry) => (entry.results?.length > 1
436
+ ? [entry.summary, ...entry.results.map((result) => ` ${openLine(result)}`)]
437
+ : openLine(entry)));
438
+ section("Review", handoff.review, (entry) => `${entry.result_ref} ${entry.summary}${members(entry)}`);
439
+ section("Lapsed accepts", handoff.lapsed, (entry) => `${entry.result_ref} ${entry.summary}; accepted by ${entry.accepted_by} at ${entry.accepted_at}; lapsed: ${entry.why}`);
440
+ section("Coverage", handoff.coverage, (entry) => `${entry.leg} ${entry.check}: ${entry.result} (${entry.reason_code})${entry.count ? ` x${entry.count}` : ""}${entry.pages.length ? ` on ${entry.pages.join(", ")}` : ""}`);
441
+ section("Accepted", handoff.accepted, (entry) => `${entry.result_ref} accepted by ${entry.accepted_by} at ${entry.accepted_at}: ${entry.reason}${members(entry)}`);
442
+ section("Inert accepts", handoff.inert_accepts, (entry) => `${entry.result_id ?? "(no result id)"}: ${entry.why}`);
443
+ if (lines.length === 1) lines.push(" (no QC results)");
444
+ if (handoff.accept_command) lines.push(` Accept only with the operator's decision: ${handoff.accept_command}`);
445
+ return lines;
446
+ }