@nextcommerce/campaigns-os 1.52.0 → 1.54.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 +109 -0
- package/README.md +1 -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/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +295 -47
- package/contracts/release-ledger.json +660 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +67 -19
- package/docs/campaign-build-brief.md +48 -17
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +10 -1
- 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 +5 -5
- package/docs/qa-and-test-orders.md +30 -3
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -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/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +182 -115
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +141 -16
- package/src/doctor/inspect.mjs +13 -0
- 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 +6 -2
- 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 +18 -5
- package/src/qa-browser.mjs +211 -93
- package/src/qa-node.mjs +47 -7
- 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/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +154 -30
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
- package/src/tooling-setup.mjs +1 -1
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Read-time input currency: whether a recorded build, Polish or QA stage was
|
|
2
|
+
// made against the brief and CampaignSpec content the campaign holds now.
|
|
3
|
+
//
|
|
4
|
+
// A stage stamps the inputs it was recorded against (source_brief_material,
|
|
5
|
+
// source_spec_material_hash). At read time each stamp is compared with the
|
|
6
|
+
// current value; a stage is current only when its raw status is a recognized
|
|
7
|
+
// terminal status, every applicable stamp equals the current value, and no
|
|
8
|
+
// replay rule holds. A missing or malformed stamp reads unknown, never
|
|
9
|
+
// current: nothing else on the report stands in for a stamp. The stage's
|
|
10
|
+
// superseded records are display only and are never read here.
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { accessSync, constants as fsConstants, existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
import { BRIEF_ABSENT, briefMaterialFingerprint } from "./build-brief.mjs";
|
|
15
|
+
import { isObject, optionalString, resolveFromFile } from "./cli-helpers.mjs";
|
|
16
|
+
import { specMaterialHash } from "./spec-identity.mjs";
|
|
17
|
+
|
|
18
|
+
// Every raw stage status the lifecycle writes. Any other value reads unknown.
|
|
19
|
+
export const RECOGNIZED_STAGE_STATUSES = Object.freeze(["pending", "blocked", "required", "completed", "completed_with_warnings", "completed_partial", "skipped"]);
|
|
20
|
+
const COMPLETED_STATUSES = Object.freeze(["completed", "completed_with_warnings", "completed_partial"]);
|
|
21
|
+
const NOT_APPLICABLE_STATUSES = Object.freeze(["pending", "blocked", "required", "skipped"]);
|
|
22
|
+
const TERMINAL_EFFECTIVE_STATUSES = Object.freeze([...COMPLETED_STATUSES, "skipped"]);
|
|
23
|
+
|
|
24
|
+
// The stages that carry input stamps, and the inputs each one depends on.
|
|
25
|
+
export const STAMPED_STAGES = Object.freeze(["assembly", "polish", "qa"]);
|
|
26
|
+
const APPLICABLE_BRIEF_PARTITIONS = Object.freeze({ assembly: ["presentation"], polish: ["presentation"], qa: ["presentation", "qa_policy"] });
|
|
27
|
+
|
|
28
|
+
export const INPUT_CHANGE_REASONS = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed"]);
|
|
29
|
+
|
|
30
|
+
// The fixed stage dependency map: for each changed input, the stages it makes
|
|
31
|
+
// owed and what each owed stage is required by and for. prepare_build is
|
|
32
|
+
// re-derived by the brief save itself, doctor is recomputed on every read, and
|
|
33
|
+
// setup and deploy are kept.
|
|
34
|
+
export const INPUT_DEPENDENCY_MAP = Object.freeze({
|
|
35
|
+
presentation: Object.freeze({
|
|
36
|
+
reason: "brief_presentation_changed",
|
|
37
|
+
stages: Object.freeze({
|
|
38
|
+
assembly: Object.freeze({ required_by: "brief", required_for: Object.freeze(["polish", "qa"]) }),
|
|
39
|
+
polish: Object.freeze({ required_by: "brief", required_for: Object.freeze(["qa"]) }),
|
|
40
|
+
qa: Object.freeze({ required_by: "brief", required_for: Object.freeze([]) }),
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
qa_policy: Object.freeze({
|
|
44
|
+
reason: "brief_qa_policy_changed",
|
|
45
|
+
stages: Object.freeze({
|
|
46
|
+
qa: Object.freeze({ required_by: "brief", required_for: Object.freeze([]) }),
|
|
47
|
+
}),
|
|
48
|
+
}),
|
|
49
|
+
spec: Object.freeze({
|
|
50
|
+
reason: "spec_material_changed",
|
|
51
|
+
stages: Object.freeze({
|
|
52
|
+
assembly: Object.freeze({ required_by: "spec", required_for: Object.freeze(["polish", "qa"]) }),
|
|
53
|
+
polish: Object.freeze({ required_by: "spec", required_for: Object.freeze(["qa"]) }),
|
|
54
|
+
qa: Object.freeze({ required_by: "spec", required_for: Object.freeze([]) }),
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
// When several inputs changed at once, the reason recorded is the first here.
|
|
59
|
+
const CAUSE_PRECEDENCE = Object.freeze(["spec", "presentation", "qa_policy"]);
|
|
60
|
+
|
|
61
|
+
const SHA256_STAMP = /^sha256:[0-9a-f]{64}$/;
|
|
62
|
+
|
|
63
|
+
export const isWellFormedStamp = (value) => typeof value === "string" && (SHA256_STAMP.test(value) || value === BRIEF_ABSENT);
|
|
64
|
+
const isFingerprint = (value) => typeof value === "string" && SHA256_STAMP.test(value);
|
|
65
|
+
const isCanonicalIso = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
66
|
+
|
|
67
|
+
export const absentBriefMaterial = () => ({ presentation: BRIEF_ABSENT, qa_policy: BRIEF_ABSENT });
|
|
68
|
+
|
|
69
|
+
// A closed shape: an object with exactly these keys, no more and no fewer.
|
|
70
|
+
const hasExactKeys = (value, keys) => isObject(value)
|
|
71
|
+
&& Object.keys(value).length === keys.length
|
|
72
|
+
&& keys.every((key) => Object.hasOwn(value, key));
|
|
73
|
+
|
|
74
|
+
// Brief material, as bound, stamped or recorded on a verdict: exactly
|
|
75
|
+
// {presentation, qa_policy}, each "sha256:<64 hex>" or the absent value.
|
|
76
|
+
export function wellFormedBriefMaterial(value) {
|
|
77
|
+
return hasExactKeys(value, ["presentation", "qa_policy"]) && isWellFormedStamp(value.presentation) && isWellFormedStamp(value.qa_policy);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The spec material as a stamp ("sha256:<64 hex>"), or null.
|
|
81
|
+
export function specMaterialStamp(value) {
|
|
82
|
+
if (typeof value !== "string") return null;
|
|
83
|
+
const hex = value.trim().toLowerCase().replace(/^sha256:/, "");
|
|
84
|
+
return /^[0-9a-f]{64}$/.test(hex) ? `sha256:${hex}` : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The brief material the packet's inputs hold now: {presentation, qa_policy}
|
|
89
|
+
* fingerprinted from the normalized brief, the absent value for both when the
|
|
90
|
+
* packet records no Campaign Build Brief, or null when the normalized brief
|
|
91
|
+
* cannot be read.
|
|
92
|
+
*/
|
|
93
|
+
export function currentBriefMaterial({ packet, packetPath }) {
|
|
94
|
+
if (!isObject(packet?.build_brief)) return absentBriefMaterial();
|
|
95
|
+
const normalizedPath = optionalString(packet.build_brief.normalized_path);
|
|
96
|
+
const resolved = normalizedPath && packetPath ? resolveFromFile(packetPath, normalizedPath) : null;
|
|
97
|
+
if (!resolved || !existsSync(resolved)) return null;
|
|
98
|
+
try {
|
|
99
|
+
const brief = JSON.parse(readFileSync(resolved, "utf8"));
|
|
100
|
+
return isObject(brief) ? briefMaterialFingerprint(brief) : null;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Why `path` is not a brief file this run can read, or null when it is one.
|
|
107
|
+
// A brief file is a regular file, reached through any symlink, with read
|
|
108
|
+
// permission. Nothing here opens the path, so a pipe or socket never blocks.
|
|
109
|
+
export function briefFileUnusable(path) {
|
|
110
|
+
let stats;
|
|
111
|
+
try {
|
|
112
|
+
stats = statSync(path);
|
|
113
|
+
} catch {
|
|
114
|
+
try {
|
|
115
|
+
lstatSync(path);
|
|
116
|
+
return "is a symlink to nothing";
|
|
117
|
+
} catch {
|
|
118
|
+
return "does not exist";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (stats.isDirectory()) return "is a directory, not a file";
|
|
122
|
+
if (!stats.isFile()) return "is not a regular file";
|
|
123
|
+
try {
|
|
124
|
+
accessSync(path, fsConstants.R_OK);
|
|
125
|
+
} catch {
|
|
126
|
+
return "cannot be read";
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The current spec material of a parsed CampaignSpec, or null.
|
|
132
|
+
export function currentSpecMaterial(spec) {
|
|
133
|
+
return isObject(spec) ? specMaterialHash(spec) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The current inputs a record stamps, in stamp shape.
|
|
137
|
+
export function inputStamps({ briefMaterial, specMaterial }) {
|
|
138
|
+
return {
|
|
139
|
+
source_brief_material: wellFormedBriefMaterial(briefMaterial) ? { presentation: briefMaterial.presentation, qa_policy: briefMaterial.qa_policy } : null,
|
|
140
|
+
source_spec_material_hash: specMaterialStamp(specMaterial),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// input_change: exactly {at, reason, superseded_build_fingerprint,
|
|
145
|
+
// superseded_inputs: {brief_material, spec_material_hash}}.
|
|
146
|
+
export function inputChangeWellFormed(change) {
|
|
147
|
+
if (!hasExactKeys(change, ["at", "reason", "superseded_build_fingerprint", "superseded_inputs"])) return false;
|
|
148
|
+
if (!isCanonicalIso(change.at) || !INPUT_CHANGE_REASONS.includes(change.reason)) return false;
|
|
149
|
+
if (change.superseded_build_fingerprint !== null && !isFingerprint(change.superseded_build_fingerprint)) return false;
|
|
150
|
+
const inputs = change.superseded_inputs;
|
|
151
|
+
if (!hasExactKeys(inputs, ["brief_material", "spec_material_hash"])) return false;
|
|
152
|
+
if (inputs.brief_material !== null && !wellFormedBriefMaterial(inputs.brief_material)) return false;
|
|
153
|
+
return inputs.spec_material_hash === null || isFingerprint(inputs.spec_material_hash);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// unchanged_output_reason: exactly {text, for_inputs: {brief_material,
|
|
157
|
+
// spec_material_hash}}, with non-empty text and the stamps the record wrote.
|
|
158
|
+
export function unchangedOutputReasonWellFormed(reason) {
|
|
159
|
+
if (!hasExactKeys(reason, ["text", "for_inputs"]) || typeof reason.text !== "string" || !reason.text.trim()) return false;
|
|
160
|
+
const inputs = reason.for_inputs;
|
|
161
|
+
return hasExactKeys(inputs, ["brief_material", "spec_material_hash"]) && wellFormedBriefMaterial(inputs.brief_material) && isFingerprint(inputs.spec_material_hash);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The partitions and spec a stage depends on, compared stamp by stamp. Returns
|
|
165
|
+
// "unknown" when a stamp or a current value is missing or malformed, the name
|
|
166
|
+
// of the first changed input ("spec" before the brief), or null when equal.
|
|
167
|
+
function compareStamps(key, stage, current) {
|
|
168
|
+
const partitions = APPLICABLE_BRIEF_PARTITIONS[key];
|
|
169
|
+
const stamp = stage.source_brief_material;
|
|
170
|
+
const specStamp = stage.source_spec_material_hash;
|
|
171
|
+
if (!wellFormedBriefMaterial(stamp)) return "unknown";
|
|
172
|
+
if (!isFingerprint(specStamp)) return "unknown";
|
|
173
|
+
if (!wellFormedBriefMaterial(current.brief)) return "unknown";
|
|
174
|
+
if (!isFingerprint(current.spec)) return "unknown";
|
|
175
|
+
if (specStamp !== current.spec) return "spec";
|
|
176
|
+
return partitions.find((partition) => stamp[partition] !== current.brief[partition]) || null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Assembly's unchanged_output_reason holds for the current inputs when its
|
|
180
|
+
// for_inputs equal them on every input assembly depends on.
|
|
181
|
+
function operatorKeptOutput(stage, current) {
|
|
182
|
+
const inputs = stage.unchanged_output_reason?.for_inputs;
|
|
183
|
+
return isObject(inputs)
|
|
184
|
+
&& inputs.spec_material_hash === current.spec
|
|
185
|
+
&& APPLICABLE_BRIEF_PARTITIONS.assembly.every((partition) => inputs.brief_material?.[partition] === current.brief?.[partition]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The capture time of the package page_load attached to Polish, or null.
|
|
189
|
+
function polishCaptureTime(stage) {
|
|
190
|
+
const capturedAt = stage.evidence?.visual_review?.page_load?.captured_at;
|
|
191
|
+
return typeof capturedAt === "string" && Number.isFinite(Date.parse(capturedAt)) ? capturedAt : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* One stamped stage's currency at read time, by the first rule that holds:
|
|
196
|
+
* an unrecognized status reads unknown; a non-completed status is not
|
|
197
|
+
* applicable; a completed stage with a missing or malformed stamp, an input
|
|
198
|
+
* that cannot be computed, or a malformed input_change or
|
|
199
|
+
* unchanged_output_reason reads unknown; a stamp unequal to the current value
|
|
200
|
+
* reads owed; an assembly whose output is still the build the input change
|
|
201
|
+
* superseded (and no operator decision for these inputs), or a Polish whose
|
|
202
|
+
* capture does not postdate the input change, reads owed; otherwise current.
|
|
203
|
+
*/
|
|
204
|
+
export function assessStageCurrency(key, stage, current) {
|
|
205
|
+
const record = isObject(stage) ? stage : {};
|
|
206
|
+
const status = typeof record.status === "string" ? record.status : "";
|
|
207
|
+
if (!RECOGNIZED_STAGE_STATUSES.includes(status)) return { currency: "unknown", reason: "status_unrecognized" };
|
|
208
|
+
if (NOT_APPLICABLE_STATUSES.includes(status)) return { currency: "not_applicable", reason: null };
|
|
209
|
+
const compared = compareStamps(key, record, current);
|
|
210
|
+
const malformedChange = Object.hasOwn(record, "input_change") && !inputChangeWellFormed(record.input_change);
|
|
211
|
+
const malformedReason = key === "assembly" && Object.hasOwn(record, "unchanged_output_reason") && !unchangedOutputReasonWellFormed(record.unchanged_output_reason);
|
|
212
|
+
if (compared === "unknown" || malformedChange || malformedReason) return { currency: "unknown", reason: "input_binding_unknown" };
|
|
213
|
+
if (compared) return { currency: "owed", reason: compared === "spec" ? "spec_material_changed" : "brief_material_changed" };
|
|
214
|
+
const change = record.input_change;
|
|
215
|
+
if (key === "assembly" && change
|
|
216
|
+
&& optionalString(record.build_fingerprint) === change.superseded_build_fingerprint
|
|
217
|
+
&& !operatorKeptOutput(record, current)) {
|
|
218
|
+
return { currency: "owed", reason: "output_unchanged_after_input_change" };
|
|
219
|
+
}
|
|
220
|
+
if (key === "polish" && change) {
|
|
221
|
+
const capturedAt = polishCaptureTime(record);
|
|
222
|
+
if (!capturedAt || Date.parse(capturedAt) <= Date.parse(change.at)) return { currency: "owed", reason: "polish_capture_predates_input_change" };
|
|
223
|
+
}
|
|
224
|
+
return { currency: "current", reason: null };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function briefStatus(bound, current) {
|
|
228
|
+
if (isObject(current) && current.presentation === BRIEF_ABSENT && current.qa_policy === BRIEF_ABSENT) return "absent";
|
|
229
|
+
if (!wellFormedBriefMaterial(bound) || !wellFormedBriefMaterial(current)) return "unknown";
|
|
230
|
+
return bound.presentation === current.presentation && bound.qa_policy === current.qa_policy ? "current" : "owed";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* derived.input_currency for a report, given the current inputs:
|
|
235
|
+
* `briefMaterial` = {presentation, qa_policy} (each "sha256:<64 hex>", or the
|
|
236
|
+
* absent value for a packet with no brief; null when it cannot be computed)
|
|
237
|
+
* and `specMaterial` = "sha256:<64 hex>" (null when it cannot be computed).
|
|
238
|
+
*/
|
|
239
|
+
export function assessInputCurrency({ report, briefMaterial = null, specMaterial = null } = {}) {
|
|
240
|
+
const current = {
|
|
241
|
+
brief: wellFormedBriefMaterial(briefMaterial) ? { presentation: briefMaterial.presentation, qa_policy: briefMaterial.qa_policy } : null,
|
|
242
|
+
spec: specMaterialStamp(specMaterial),
|
|
243
|
+
};
|
|
244
|
+
const boundBrief = isObject(report?.build_brief?.material) ? report.build_brief.material : null;
|
|
245
|
+
const stages = {};
|
|
246
|
+
const reasons = {};
|
|
247
|
+
for (const key of STAMPED_STAGES) {
|
|
248
|
+
const assessed = assessStageCurrency(key, report?.stages?.[key], current);
|
|
249
|
+
stages[key] = assessed.currency;
|
|
250
|
+
reasons[key] = assessed.reason;
|
|
251
|
+
}
|
|
252
|
+
const boundSpec = optionalString(report?.identity?.spec_material_hash) || null;
|
|
253
|
+
return {
|
|
254
|
+
brief: {
|
|
255
|
+
bound: boundBrief ? JSON.parse(JSON.stringify(boundBrief)) : null,
|
|
256
|
+
current: current.brief,
|
|
257
|
+
status: briefStatus(boundBrief, current.brief),
|
|
258
|
+
},
|
|
259
|
+
spec: {
|
|
260
|
+
bound: boundSpec,
|
|
261
|
+
current: current.spec,
|
|
262
|
+
snapshot_material: snapshotMaterialStatus(boundSpec, current.spec),
|
|
263
|
+
},
|
|
264
|
+
stages,
|
|
265
|
+
reasons,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The selected local spec file against the material the report binds: the
|
|
270
|
+
// copy on disk only, never a remote revision.
|
|
271
|
+
function snapshotMaterialStatus(bound, current) {
|
|
272
|
+
const boundStamp = specMaterialStamp(bound);
|
|
273
|
+
if (!boundStamp || !current) return "unknown";
|
|
274
|
+
return boundStamp === current ? "current" : "owed";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The status every effective-status reader uses for a stage. For assembly,
|
|
279
|
+
* polish and qa: the recorded status when the stage is current or not
|
|
280
|
+
* applicable, `required` when owed, `unknown` when unknown. For the other
|
|
281
|
+
* stages, which carry no stamps: the recorded status when it is recognized,
|
|
282
|
+
* otherwise `unknown`.
|
|
283
|
+
*/
|
|
284
|
+
export function effectiveStageStatus(key, stage, inputCurrency = null) {
|
|
285
|
+
const status = typeof stage?.status === "string" ? stage.status : "";
|
|
286
|
+
if (!STAMPED_STAGES.includes(key)) return RECOGNIZED_STAGE_STATUSES.includes(status) ? status : "unknown";
|
|
287
|
+
const currency = inputCurrency?.stages?.[key];
|
|
288
|
+
if (currency === "current" || currency === "not_applicable") return status;
|
|
289
|
+
if (currency === "owed") return "required";
|
|
290
|
+
return "unknown";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export const effectiveStatusIsTerminal = (status) => TERMINAL_EFFECTIVE_STATUSES.includes(status);
|
|
294
|
+
|
|
295
|
+
// The change a stage's stamps show against the current inputs, as the
|
|
296
|
+
// dependency-map cause that wins ("spec", "presentation", "qa_policy"), or
|
|
297
|
+
// null. A missing or malformed stamp is not a detected change.
|
|
298
|
+
export function detectedStageChange(key, stage, { briefMaterial, specMaterial }) {
|
|
299
|
+
const stamp = stage?.source_brief_material;
|
|
300
|
+
const changed = [];
|
|
301
|
+
const spec = specMaterialStamp(specMaterial);
|
|
302
|
+
if (isFingerprint(stage?.source_spec_material_hash) && spec && stage.source_spec_material_hash !== spec) changed.push("spec");
|
|
303
|
+
if (wellFormedBriefMaterial(stamp) && wellFormedBriefMaterial(briefMaterial)) {
|
|
304
|
+
for (const partition of APPLICABLE_BRIEF_PARTITIONS[key] || []) {
|
|
305
|
+
if (stamp[partition] !== briefMaterial[partition]) changed.push(partition);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return CAUSE_PRECEDENCE.find((cause) => changed.includes(cause)) || null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The reason recorded for a set of changed inputs (spec first, then presentation).
|
|
312
|
+
export function changeReason(causes) {
|
|
313
|
+
const cause = CAUSE_PRECEDENCE.find((candidate) => causes.includes(candidate));
|
|
314
|
+
return cause ? INPUT_DEPENDENCY_MAP[cause].reason : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The current inputs of a packet, read from disk: the normalized brief's
|
|
319
|
+
* material and the material of the CampaignSpec at spec.local_path (null when
|
|
320
|
+
* it cannot be read).
|
|
321
|
+
*/
|
|
322
|
+
export function currentPacketInputs({ packet, packetPath }) {
|
|
323
|
+
const localPath = optionalString(packet?.spec?.local_path);
|
|
324
|
+
const specPath = localPath && packetPath ? resolveFromFile(packetPath, localPath) : null;
|
|
325
|
+
let spec = null;
|
|
326
|
+
try {
|
|
327
|
+
spec = specPath && existsSync(specPath) ? JSON.parse(readFileSync(specPath, "utf8")) : null;
|
|
328
|
+
} catch {
|
|
329
|
+
spec = null;
|
|
330
|
+
}
|
|
331
|
+
return { briefMaterial: currentBriefMaterial({ packet, packetPath }), specMaterial: currentSpecMaterial(spec) };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// True when every stamp `key` depends on equals the current inputs.
|
|
335
|
+
export function stampsMatchInputs(key, record, { briefMaterial, specMaterial }) {
|
|
336
|
+
const current = { brief: wellFormedBriefMaterial(briefMaterial) ? briefMaterial : null, spec: specMaterialStamp(specMaterial) };
|
|
337
|
+
return compareStamps(key, isObject(record) ? record : {}, current) === null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// What a stage write needs to detect, carry or clear input_change against
|
|
341
|
+
// the current inputs: the reason a replaced record's stamps show a change
|
|
342
|
+
// (or null), and whether a record's stamps equal the current inputs.
|
|
343
|
+
export function stageWriteInputs(key, inputs) {
|
|
344
|
+
return {
|
|
345
|
+
detectChange: (record) => {
|
|
346
|
+
const cause = detectedStageChange(key, record, inputs);
|
|
347
|
+
return cause ? INPUT_DEPENDENCY_MAP[cause].reason : null;
|
|
348
|
+
},
|
|
349
|
+
stampsCurrent: (record) => stampsMatchInputs(key, record, inputs),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The bindings intake and record brief write beside a brief result on the
|
|
355
|
+
* packet, the Build Context and the Assembly Report: the material
|
|
356
|
+
* fingerprints, and the sha256 of the brief file's bytes (null for a
|
|
357
|
+
* generated draft).
|
|
358
|
+
*/
|
|
359
|
+
export function briefIntakeBindings(buildBrief) {
|
|
360
|
+
return {
|
|
361
|
+
material: briefMaterialFingerprint(buildBrief.artifact),
|
|
362
|
+
input_sha256: buildBrief.inputPath ? `sha256:${createHash("sha256").update(readFileSync(buildBrief.inputPath)).digest("hex")}` : null,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The refresh `next` offers first when a stage reads owed on a stamp:
|
|
368
|
+
* `record brief` for a brief stamp, `record spec` for a spec stamp. Returns
|
|
369
|
+
* the commands (subcommand names) in that order, or [].
|
|
370
|
+
*/
|
|
371
|
+
export function inputRefreshCommands(inputCurrency) {
|
|
372
|
+
const reasons = Object.values(isObject(inputCurrency?.reasons) ? inputCurrency.reasons : {});
|
|
373
|
+
return [
|
|
374
|
+
...(reasons.includes("brief_material_changed") ? ["brief"] : []),
|
|
375
|
+
...(reasons.includes("spec_material_changed") ? ["spec"] : []),
|
|
376
|
+
];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* derived.input_currency for a doctor read: the report's stamps against the
|
|
381
|
+
* normalized brief and the CampaignSpec on disk now. A saved-Map or gateway
|
|
382
|
+
* packet compares the copy intake fetched; no offline read can confirm that
|
|
383
|
+
* copy is the latest remote revision, so it also reports the remote as
|
|
384
|
+
* unconfirmed.
|
|
385
|
+
*/
|
|
386
|
+
export function deriveInputCurrency({ packet, packetPath, report, spec }) {
|
|
387
|
+
const currency = assessInputCurrency({
|
|
388
|
+
report,
|
|
389
|
+
briefMaterial: currentBriefMaterial({ packet, packetPath }),
|
|
390
|
+
specMaterial: currentSpecMaterial(spec),
|
|
391
|
+
});
|
|
392
|
+
if (optionalString(packet?.spec?.map_id)) currency.spec.remote_currency = "unconfirmed";
|
|
393
|
+
return currency;
|
|
394
|
+
}
|