@nextcommerce/campaigns-os 1.50.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 +474 -0
- package/agents/claude/CLAUDE.md +4 -2
- package/agents/codex/AGENTS.md +3 -1
- package/agents/copilot/copilot-instructions.md +3 -1
- package/agents/cursor/campaigns-os.mdc +3 -1
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
- package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
- package/campaign-spec/dist/rules/design-source-shape.js +13 -3
- package/campaign-spec/dist/rules/sdk-version.js +2 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +26 -46
- package/contracts/effects.v1.json +282 -14
- package/contracts/release-ledger.json +1233 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
- package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +148 -23
- package/docs/campaign-build-brief.md +57 -2
- package/docs/effects.md +14 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +12 -0
- package/docs/progress-snapshots.md +2 -4
- package/docs/qa-and-test-orders.md +47 -4
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.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 +11 -20
- package/skills/next-campaigns-os/references/session-intake.md +17 -9
- 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 +8 -5
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/brand-theme.mjs +33 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +229 -30
- package/src/built-site-scope.mjs +39 -6
- package/src/built-smoke-qc.mjs +1117 -0
- package/src/campaign-identity.mjs +36 -2
- package/src/cart-placeholders.mjs +730 -0
- package/src/cli.mjs +467 -143
- package/src/commercial-journey.mjs +65 -4
- package/src/commercial-parity.mjs +6 -1
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/doctor/checks.mjs +415 -33
- package/src/doctor/inspect.mjs +53 -2
- package/src/doctor/next-step.mjs +16 -7
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +5 -2
- package/src/local-preview-policy.mjs +1 -1
- package/src/local-proof.mjs +4 -1
- package/src/polish-browser.mjs +660 -141
- package/src/polish-capture.mjs +1 -1
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-media-weight.mjs +492 -0
- package/src/polish-node.mjs +400 -28
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +19 -3
- package/src/qa-browser.mjs +439 -180
- package/src/qa-content-params.mjs +889 -0
- package/src/qa-node.mjs +116 -13
- package/src/qa-order-bump.mjs +22 -1
- package/src/qa-policy-links.mjs +1019 -0
- package/src/qa-tracking-params.mjs +1389 -0
- package/src/qa-url-privacy.mjs +168 -0
- package/src/qc-accept.mjs +482 -0
- package/src/qc-check-registry.mjs +91 -0
- package/src/qc-results.mjs +1341 -0
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +71 -0
- package/src/sdk-markup.mjs +2 -2
- package/src/sdk-storage-compatibility.mjs +63 -3
- package/src/source-prep.mjs +37 -7
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +165 -33
- package/src/tooling-setup.mjs +1 -1
package/src/doctor/inspect.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { shellToken } from "../shell-token.mjs";
|
|
|
8
8
|
import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
|
|
9
9
|
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
10
10
|
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
11
|
+
import { recordQcResults } from "../qc-results.mjs";
|
|
11
12
|
import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
12
13
|
import { normalizePublicRouteSlug } from "../route-identity.mjs";
|
|
13
14
|
import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
|
|
@@ -19,6 +20,8 @@ import { UPSELL_SELECTOR_SCOPE, builtPageTypeOverRouteGuess } from "../upsell-se
|
|
|
19
20
|
import { CAMPAIGN_IDENTITY } from "../campaign-identity.mjs";
|
|
20
21
|
import { SDK_MARKUP } from "../sdk-markup.mjs";
|
|
21
22
|
import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs } from "../built-script-syntax.mjs";
|
|
23
|
+
import { CART_PLACEHOLDERS } from "../cart-placeholders.mjs";
|
|
24
|
+
import { SMOKE_QC } from "../built-smoke-qc.mjs";
|
|
22
25
|
import { stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
23
26
|
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
24
27
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "../polish-node.mjs";
|
|
@@ -42,6 +45,9 @@ import {
|
|
|
42
45
|
recordCampaignIdentityGate,
|
|
43
46
|
recordScriptSyntaxGate,
|
|
44
47
|
recordSdkMarkupGate,
|
|
48
|
+
recordCartPlaceholders,
|
|
49
|
+
collectCartPlaceholderPages,
|
|
50
|
+
recordSmokeQc,
|
|
45
51
|
summarizeCopyMatches,
|
|
46
52
|
resolveBrandContractOnce,
|
|
47
53
|
reportBrandContractDefectOnce,
|
|
@@ -123,7 +129,7 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
|
|
|
123
129
|
});
|
|
124
130
|
}
|
|
125
131
|
|
|
126
|
-
export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined } = {}) {
|
|
132
|
+
export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
|
|
127
133
|
// Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
|
|
128
134
|
// campaign that has only a built _site/ and no full Build Packet. Resolves
|
|
129
135
|
// scope from the built output and runs the built-output residue/text/
|
|
@@ -141,6 +147,8 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
|
|
|
141
147
|
// A live campaign read (live-campaign-refs.mjs) the caller already made;
|
|
142
148
|
// absent, the live ref check is recorded not_run.
|
|
143
149
|
...(liveCampaign !== undefined ? { liveCampaign } : {}),
|
|
150
|
+
// In-process QC stand-in checks (tests only; see qc-results.mjs).
|
|
151
|
+
...(qcStandIns !== undefined ? { qcStandIns } : {}),
|
|
144
152
|
};
|
|
145
153
|
const result = runDoctor(packetPath, doctorOptions);
|
|
146
154
|
// Inspection and recording are separate operations. A laptop's untracked
|
|
@@ -232,6 +240,8 @@ export function doctorBuiltOutput(args) {
|
|
|
232
240
|
built_pages: scope.pages.map((page) => ({ page_id: page.page_id, type: page.page_type, route: page.route })),
|
|
233
241
|
doctor_checks: [],
|
|
234
242
|
checkpoint_gates: [],
|
|
243
|
+
// QC results: every result a QC check recomputed this run.
|
|
244
|
+
qc_results: [],
|
|
235
245
|
};
|
|
236
246
|
ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
|
|
237
247
|
|
|
@@ -344,6 +354,39 @@ export function doctorBuiltOutput(args) {
|
|
|
344
354
|
});
|
|
345
355
|
derived.doctor_checks.push(SCRIPT_SYNTAX);
|
|
346
356
|
|
|
357
|
+
// Raw cart placeholders. Same placement, same reasons; a QC
|
|
358
|
+
// check whose warnings no blocker above withholds.
|
|
359
|
+
recordCartPlaceholders({
|
|
360
|
+
subject: {
|
|
361
|
+
public_route_slug: scope.slug || null,
|
|
362
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
363
|
+
},
|
|
364
|
+
pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
|
|
365
|
+
warnings,
|
|
366
|
+
ready,
|
|
367
|
+
derived,
|
|
368
|
+
});
|
|
369
|
+
derived.doctor_checks.push(CART_PLACEHOLDERS);
|
|
370
|
+
|
|
371
|
+
// Built-output smoke checks. Same placement, same reasons. This path has
|
|
372
|
+
// no Assembly Report and no deploy URL, so the build environment and the
|
|
373
|
+
// deploy base are unknown: the production-only rules and an absolute
|
|
374
|
+
// og:image read unexercised here.
|
|
375
|
+
recordSmokeQc({
|
|
376
|
+
subject: {
|
|
377
|
+
public_route_slug: scope.slug || null,
|
|
378
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
379
|
+
},
|
|
380
|
+
targetRepo,
|
|
381
|
+
pages: collectCartPlaceholderPages(targetRepo, optionalString(args.slug)),
|
|
382
|
+
environment: "unknown",
|
|
383
|
+
deployBase: null,
|
|
384
|
+
warnings,
|
|
385
|
+
ready,
|
|
386
|
+
derived,
|
|
387
|
+
});
|
|
388
|
+
derived.doctor_checks.push(SMOKE_QC);
|
|
389
|
+
|
|
347
390
|
const synthesized = synthesizeMinimalBuildPacket({
|
|
348
391
|
schemaVersion: PACKET_SCHEMA,
|
|
349
392
|
targetRepo,
|
|
@@ -417,7 +460,7 @@ export function doctorPacket(packetPath, options = {}) {
|
|
|
417
460
|
return result;
|
|
418
461
|
}
|
|
419
462
|
|
|
420
|
-
function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined } = {}) {
|
|
463
|
+
function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null, liveCampaign = undefined, qcStandIns = null } = {}) {
|
|
421
464
|
// The Build Context records where prepare-build wrote the report
|
|
422
465
|
// (--report-out). `next` follows that pointer when no --report is given;
|
|
423
466
|
// doctor reads the same report so its gates and its next block cannot
|
|
@@ -449,6 +492,8 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
449
492
|
spec_path: null,
|
|
450
493
|
doctor_checks: [],
|
|
451
494
|
checkpoint_gates: [],
|
|
495
|
+
// QC results: every result a QC check recomputed this run.
|
|
496
|
+
qc_results: [],
|
|
452
497
|
polish_checkpoint_gate: null,
|
|
453
498
|
// The prepare-build gate `next` acts on, stored like every other gate so
|
|
454
499
|
// the ladder consumes doctor's evaluation instead of computing its own.
|
|
@@ -548,6 +593,12 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
548
593
|
ready.push("Hidden eager-media checkpoint not applicable before completed assembly.");
|
|
549
594
|
}
|
|
550
595
|
|
|
596
|
+
// QC stand-in checks reach doctor only through this in-process option
|
|
597
|
+
// (tests); the shipped checks record through recordQcResults themselves.
|
|
598
|
+
for (const check of Array.isArray(qcStandIns?.doctor) ? qcStandIns.doctor : []) {
|
|
599
|
+
recordQcResults({ derived, warnings, results: check() });
|
|
600
|
+
}
|
|
601
|
+
|
|
551
602
|
// The same fully resolved prepare-build gate the `next` command evaluates:
|
|
552
603
|
// DSP-required packets, the recorded report path and the context/report
|
|
553
604
|
// binding checks. A weaker gate here would let doctor name setup or build
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
NEXT_STAGE_ORDER,
|
|
14
14
|
NEXT_STAGE_OWNERS,
|
|
15
|
-
STAGE_TERMINAL_STATUS_PREFIXES,
|
|
16
15
|
reportKeyForCliStage,
|
|
17
16
|
stageIsBlocked,
|
|
18
17
|
stageIsTerminal,
|
|
19
18
|
} from "../orchestration-stage-contract.mjs";
|
|
20
19
|
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
20
|
+
import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
|
|
21
21
|
import { cmd } from "../install-invocation.mjs";
|
|
22
22
|
import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
|
|
23
23
|
import { orderPathDepthDrift } from "./checks.mjs";
|
|
@@ -440,6 +440,16 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
440
440
|
};
|
|
441
441
|
}
|
|
442
442
|
|
|
443
|
+
// A build made against earlier brief or CampaignSpec content, or one whose
|
|
444
|
+
// inputs cannot be confirmed, is owed before anything downstream of it.
|
|
445
|
+
const inputCurrency = derived?.input_currency || null;
|
|
446
|
+
if (["owed", "unknown"].includes(inputCurrency?.stages?.assembly)) {
|
|
447
|
+
return {
|
|
448
|
+
stage: "build",
|
|
449
|
+
reason: inputCurrency.reasons?.assembly || "input_binding_unknown",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
443
453
|
if (polishGate.status === "blocked") {
|
|
444
454
|
if (polishGateRequiresBuild(polishGate)) {
|
|
445
455
|
return {
|
|
@@ -474,7 +484,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
474
484
|
reason: `Stage "${reportKey}" is not recorded in the assembly report; run "${cliStage}" next.`,
|
|
475
485
|
};
|
|
476
486
|
}
|
|
477
|
-
|
|
487
|
+
// The effective status: a stage owed again by an input change reads
|
|
488
|
+
// required, and one whose inputs or raw status cannot be read, unknown.
|
|
489
|
+
const status = effectiveStageStatus(reportKey, stage, inputCurrency);
|
|
478
490
|
if (stageIsBlocked(status)) {
|
|
479
491
|
return {
|
|
480
492
|
stage: cliStage,
|
|
@@ -482,10 +494,7 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
482
494
|
blocked: true,
|
|
483
495
|
};
|
|
484
496
|
}
|
|
485
|
-
|
|
486
|
-
// (e.g. "completed_partial") count as terminal.
|
|
487
|
-
const isTerminal = STAGE_TERMINAL_STATUS_PREFIXES.some((t) => status.startsWith(t));
|
|
488
|
-
if (!isTerminal) {
|
|
497
|
+
if (!effectiveStatusIsTerminal(status)) {
|
|
489
498
|
return {
|
|
490
499
|
stage: cliStage,
|
|
491
500
|
reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
|
|
@@ -591,7 +600,7 @@ function doctorNextActions(errors, warnings, derived, { polishBlocked, polishGat
|
|
|
591
600
|
actions.push("Confirm any rendered promo discount percentage claims against the build request, merchant notes, or CampaignSpec before launch.");
|
|
592
601
|
}
|
|
593
602
|
if (codes.has("template_contract.placeholder_text_residue")) {
|
|
594
|
-
actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
|
|
603
|
+
actions.push("Replace literal template placeholder text (Lorem/Placeholder/TODO/Product Name/Benefit one…four) with CampaignSpec/design copy before QA; the browser residue gate blocks on these terms.");
|
|
595
604
|
}
|
|
596
605
|
if (codes.has("template_contract.demo_asset_residue")) {
|
|
597
606
|
actions.push("Re-skin template demo placeholder assets (spacer SVGs, repeated benefit icons, starter imagery) to the campaign's real assets before launch.");
|
|
@@ -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
|
+
}
|