@nextcommerce/campaigns-os 1.61.0 → 1.63.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/AGENTS.md +2 -1
- package/CHANGELOG.md +127 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +30 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +3 -0
- package/campaign-spec/dist/rules/index.js +9 -0
- package/campaign-spec/dist/rules/package-currency-prices.d.ts +29 -0
- package/campaign-spec/dist/rules/package-currency-prices.js +137 -0
- package/campaign-spec/dist/types.d.ts +59 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +4 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +4 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +4 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +4 -0
- package/contracts/release-ledger.json +1260 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +4 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +59 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- 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 +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +7 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +34 -3
- package/schemas/campaign-spec.v5.schema.json +34 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -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 +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
package/src/doctor/ci.mjs
CHANGED
|
@@ -1,16 +1,47 @@
|
|
|
1
|
+
import { worseChangeKind } from "../change-scope.mjs";
|
|
1
2
|
import { assessEvidenceDebt } from "../evidence-debt.mjs";
|
|
3
|
+
import { REFUSED_INVOCATION } from "../lifecycle.mjs";
|
|
2
4
|
|
|
3
5
|
export const DOCTOR_CI_EXIT_CODES = Object.freeze({ current: 0, owed: 3, unknown: 4, usage: 2 });
|
|
4
6
|
|
|
5
7
|
export function assessDoctorCi(inputs) {
|
|
6
8
|
const debt = assessEvidenceDebt(inputs);
|
|
7
|
-
|
|
9
|
+
// Advisory Polish (a page-only change) is reported, never required.
|
|
10
|
+
const stages = [debt.polish, debt.qa].filter((stage) => !stage.advisory);
|
|
8
11
|
// Any known debt is owed; otherwise incomplete evidence remains unknown.
|
|
9
12
|
const status = stages.some((stage) => stage.status === "owed") ? "owed"
|
|
10
13
|
: stages.some((stage) => stage.status === "unknown") ? "unknown" : "current";
|
|
11
14
|
return { status, exit_code: DOCTOR_CI_EXIT_CODES[status], ...debt };
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
// What the change owes, from the stages it left owed: "pages" when the only
|
|
18
|
+
// required stage is QA on the changed pages (Polish advisory or current),
|
|
19
|
+
// "full" when a full stage is owed, null when nothing is owed. `kind` is the
|
|
20
|
+
// worst change kind among the owed stages.
|
|
21
|
+
export function ciChangeScope(ci) {
|
|
22
|
+
const owed = ["polish", "qa"].filter((stage) => ci[stage]?.status === "owed");
|
|
23
|
+
if (!owed.length) return { scope: null, kind: null };
|
|
24
|
+
const kind = owed.map((stage) => ci[stage].change?.kind || "unknown").reduce(worseChangeKind, "none");
|
|
25
|
+
const required = owed.filter((stage) => !ci[stage].advisory);
|
|
26
|
+
const scope = required.every((stage) => stage === "qa" && ci.qa.scope === "pages") ? "pages" : "full";
|
|
27
|
+
return { scope, kind };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Page ids come from built routes ([A-Za-z0-9_-]); anything else is encoded.
|
|
31
|
+
const pageArg = (id) => (/^[A-Za-z0-9_-]+$/.test(String(id)) ? String(id) : text(id));
|
|
32
|
+
|
|
33
|
+
const commerceReasonText = new Map([
|
|
34
|
+
["unmapped_file", "a file outside any page changed"],
|
|
35
|
+
["page_added", "a page was added"],
|
|
36
|
+
["page_removed", "a page was removed"],
|
|
37
|
+
["checkout_page", "a checkout page changed"],
|
|
38
|
+
["commerce_markup_changed", "package, offer, shipping, upsell, route or script markup changed"],
|
|
39
|
+
["commerce_signature_unavailable", "the recorded build has no commerce signature to compare against"],
|
|
40
|
+
["campaign_config_changed", "the campaign's Page Kit config (SDK pin, analytics ids, store fields) changed"],
|
|
41
|
+
["campaign_config_unavailable", "the campaign's Page Kit config entry could not be read"],
|
|
42
|
+
["change_history_unavailable", "the rebuilds since the reviewed build were not kept"],
|
|
43
|
+
]);
|
|
44
|
+
|
|
14
45
|
// Values can come from campaign files and will be rendered in a PR comment.
|
|
15
46
|
// Encode punctuation as well as HTML: diagnostics are plain text, and may
|
|
16
47
|
// contain Markdown, autolinks, code fences or line breaks from campaign files.
|
|
@@ -38,10 +69,10 @@ const requiredStageReasons = new Map([
|
|
|
38
69
|
["stage_required", "the lifecycle requires this stage again"],
|
|
39
70
|
]);
|
|
40
71
|
|
|
41
|
-
export function doctorCiFailure(error, { usage = false } = {}) {
|
|
72
|
+
export function doctorCiFailure(error, { usage = false, code = usage ? "doctor.ci.packet" : "doctor.ci.failed" } = {}) {
|
|
42
73
|
return {
|
|
43
74
|
ok: false, status: "blocked",
|
|
44
|
-
errors: [{ code
|
|
75
|
+
errors: [{ code, message: String(error?.message ?? error) }],
|
|
45
76
|
warnings: [], exit_code: usage ? DOCTOR_CI_EXIT_CODES.usage : DOCTOR_CI_EXIT_CODES.unknown,
|
|
46
77
|
};
|
|
47
78
|
}
|
|
@@ -54,7 +85,8 @@ export async function withDoctorCiReport(args, invoke) {
|
|
|
54
85
|
return await invoke(args);
|
|
55
86
|
} catch (error) {
|
|
56
87
|
if (command !== "doctor" || args.ci !== true) throw error;
|
|
57
|
-
|
|
88
|
+
// A flag refused up front (such as --packet=<path>) is a usage error.
|
|
89
|
+
const result = error?.code === REFUSED_INVOCATION ? doctorCiFailure(error, { usage: true, code: "doctor.ci.usage" }) : doctorCiFailure(error);
|
|
58
90
|
console.log(args.json ? JSON.stringify(result, null, 2) : formatDoctorCi(result));
|
|
59
91
|
process.exitCode = result.exit_code;
|
|
60
92
|
}
|
|
@@ -66,22 +98,51 @@ export function formatDoctorCi(result) {
|
|
|
66
98
|
return [
|
|
67
99
|
`## Campaign evidence: could not evaluate (exit ${result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage})`, "",
|
|
68
100
|
...(result.errors || []).map((issue) => `- ${code(issue.code)}: ${text(issue.message)}`), "",
|
|
69
|
-
|
|
101
|
+
result.errors?.[0]?.code === "doctor.ci.usage"
|
|
102
|
+
? "Remedy: correct the command's flags (each value as the next argument, `--flag value`) and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`."
|
|
103
|
+
: "Remedy: correct the packet or inspection failure and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`.",
|
|
70
104
|
].join("\n");
|
|
71
105
|
}
|
|
72
106
|
const lines = [`## Campaign evidence: ${text(ci.status)} (exit ${ci.exit_code})`, ""];
|
|
73
|
-
const
|
|
74
|
-
|
|
107
|
+
const advisory = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && ci[stage].advisory);
|
|
108
|
+
const owed = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && !ci[stage].advisory);
|
|
109
|
+
const change = ciChangeScope(ci);
|
|
110
|
+
const pageScoped = change.scope === "pages";
|
|
111
|
+
if (pageScoped) {
|
|
112
|
+
lines.push(`Change found: page-only. ${owed.length ? `It owes QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : "QA is current"}; Polish is advisory.`);
|
|
113
|
+
} else if (change.scope === "full") {
|
|
114
|
+
const reasons = [...new Set((ci.polish.change?.reasons || []).concat(ci.qa.change?.reasons || [])
|
|
115
|
+
.map((reason) => commerceReasonText.get(reason.code)).filter(Boolean))];
|
|
116
|
+
const found = change.kind === "commerce_affecting" ? "commerce-affecting"
|
|
117
|
+
: change.kind === "page_only" ? "page-only, but an input change or a Polish or QA that did not complete requires the full stages" : "not classified";
|
|
118
|
+
// Name only the stages that count, each at the scope it is owed: an
|
|
119
|
+
// advisory Polish is not owed, and QA can still be owed on pages only.
|
|
120
|
+
const qaPages = ci.qa.scope === "pages" ? `QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : null;
|
|
121
|
+
const owes = owed.length === 2 ? (qaPages ? `the full Polish, and ${qaPages}` : "the full Polish and QA")
|
|
122
|
+
: owed[0] === "polish" ? "the full Polish" : qaPages || "a full QA run";
|
|
123
|
+
lines.push(`Change found: ${found}${reasons.length ? ` (${reasons.join("; ")})` : ""}. It owes ${owes}${advisory.includes("polish") ? "; Polish is advisory" : ""}.`);
|
|
124
|
+
}
|
|
125
|
+
lines.push(`Owed stages: ${owed.length ? owed.map((stage) => stage === "qa" && ci.qa.scope === "pages" ? "qa (changed pages only)" : stage).join(", ") : "none"}.`);
|
|
75
126
|
for (const stage of owed) {
|
|
76
127
|
const reason = ci[stage].reason === "build_fingerprint_changed"
|
|
77
128
|
? "the recorded build fingerprint differs from the current build"
|
|
78
129
|
: requiredStageReasons.get(ci[stage].reason) ?? "the recorded stage evidence is owed";
|
|
79
130
|
lines.push(`- Owed ${stage}: ${reason}.`);
|
|
80
131
|
}
|
|
81
|
-
for (const
|
|
132
|
+
for (const stage of advisory) {
|
|
133
|
+
lines.push(`- Advisory ${stage}: the change since its last review is page-only, so it is reported and not required.`);
|
|
134
|
+
}
|
|
135
|
+
// The pages changed since the builds the owed stages reviewed, which
|
|
136
|
+
// record build keeps; without them, the drift from the assembly manifest.
|
|
137
|
+
const sincePages = new Map();
|
|
138
|
+
for (const stage of [...owed, ...advisory]) {
|
|
139
|
+
for (const page of ci[stage].change?.changed_pages || []) sincePages.set(page.built_path, page);
|
|
140
|
+
}
|
|
141
|
+
const changedPages = sincePages.size ? [...sincePages.values()] : ci.changes.changed_pages;
|
|
142
|
+
for (const page of changedPages) {
|
|
82
143
|
lines.push(`- Changed page: ${code(page.page_id)} (${code(page.built_path)})`);
|
|
83
144
|
}
|
|
84
|
-
if (!
|
|
145
|
+
if (!changedPages.length && (owed.length || ci.changes.status === "unknown")) {
|
|
85
146
|
lines.push(ci.changes.status === "unknown"
|
|
86
147
|
? `Changed pages unavailable: ${unavailablePageChanges.get(ci.changes.reason) ?? "the page-change reason is unrecognized."}`
|
|
87
148
|
: "Changed page ids unavailable: the retained assembly manifest may already describe the rebuilt output.");
|
|
@@ -90,7 +151,11 @@ export function formatDoctorCi(result) {
|
|
|
90
151
|
if (ci[stage].status === "unknown") lines.push(`- Unknown ${stage}: ${code(ci[stage].reason)}`);
|
|
91
152
|
}
|
|
92
153
|
if (ci.changes.status === "unknown") lines.push(`- Unknown page changes: ${code(ci.changes.reason)}`);
|
|
93
|
-
if (ci.status === "owed"
|
|
154
|
+
if (ci.status === "owed" && pageScoped && owed.length) {
|
|
155
|
+
const pages = ci.qa.owed_pages.map((page) => ` --page ${pageArg(page.page_id)}`).join("");
|
|
156
|
+
const recorded = ci.changes.recorded_build_fingerprint && ci.changes.recorded_build_fingerprint === ci.changes.current_build_fingerprint;
|
|
157
|
+
lines.push("", `Remedy: ${recorded ? "" : "run `campaigns-os record build --packet <packet>` for the rebuilt output, then "}run \`campaigns-os qa run --packet <packet>${pages}\` against that build. Polish capture is optional for a page-only change.`);
|
|
158
|
+
} else if (ci.status === "owed" || ci.status === "unknown") {
|
|
94
159
|
const matchingRequired = owed.length > 0
|
|
95
160
|
&& owed.every((stage) => requiredStageReasons.has(ci[stage].reason) && ci[stage].matches_current_build === true)
|
|
96
161
|
&& ["polish", "qa"].every((stage) => ci[stage].status !== "unknown");
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Doctor entry points: the doctor command, packet inspection and built-output inspection.
|
|
2
2
|
import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
|
|
3
3
|
import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
|
|
4
|
-
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
|
|
4
|
+
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, applyPageOnlyChangeToCheckpoint, applyPageOnlyChangeToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
|
|
5
|
+
import { assessEvidenceDebt } from "../evidence-debt.mjs";
|
|
5
6
|
import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
6
7
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -11,7 +12,7 @@ import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledge
|
|
|
11
12
|
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
12
13
|
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
13
14
|
import { recordQcResults } from "../qc-results.mjs";
|
|
14
|
-
import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
15
|
+
import { packetRuntimeRelPath, resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
15
16
|
import { normalizePublicRouteSlug } from "../route-identity.mjs";
|
|
16
17
|
import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
|
|
17
18
|
import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "../live-campaign-refs.mjs";
|
|
@@ -458,6 +459,16 @@ export function doctorBuiltOutput(args) {
|
|
|
458
459
|
};
|
|
459
460
|
}
|
|
460
461
|
|
|
462
|
+
// Inspection already refused a packet whose runtime directory is unreadable;
|
|
463
|
+
// the history lookup then falls back to the project-level directory.
|
|
464
|
+
function packetRuntimeRelPathOrDefault(packetPath) {
|
|
465
|
+
try {
|
|
466
|
+
return packetRuntimeRelPath(JSON.parse(readFileSync(resolve(packetPath), "utf8")));
|
|
467
|
+
} catch {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
461
472
|
export function doctorPacket(packetPath, options = {}) {
|
|
462
473
|
const result = withHtmlScanSnapshot(() => inspectDoctorPacket(packetPath, options));
|
|
463
474
|
// Per-finding cause classification lives HERE, at the single production
|
|
@@ -487,6 +498,7 @@ export function doctorPacket(packetPath, options = {}) {
|
|
|
487
498
|
errors: result.errors,
|
|
488
499
|
warnings: result.warnings,
|
|
489
500
|
baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
|
|
501
|
+
runtimeRelPath: comparableIdentity ? packetRuntimeRelPathOrDefault(packetPath) : undefined,
|
|
490
502
|
mapId: result.derived?.map_id || null,
|
|
491
503
|
localSpecId: result.derived?.local_spec_id || null,
|
|
492
504
|
});
|
|
@@ -587,16 +599,21 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
587
599
|
|
|
588
600
|
// The local preview policy (local-preview-policy.mjs) carries missing
|
|
589
601
|
// polish and page-load evidence forward as warnings on a local-serve packet.
|
|
590
|
-
|
|
602
|
+
// A page-only change since Polish last reviewed the campaign carries its
|
|
603
|
+
// stale evidence on any target: Polish is advisory for it (#720). The
|
|
604
|
+
// evidence debt is the one readback and doctor --ci assess.
|
|
605
|
+
const evidenceDebt = assessEvidenceDebt({ packet, packetPath, report });
|
|
606
|
+
derived.evidence_debt = evidenceDebt;
|
|
607
|
+
const polishCheckpointGate = applyPageOnlyChangeToCheckpoint(applyLocalPreviewToCheckpoint(
|
|
591
608
|
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
592
609
|
{ packet, report },
|
|
593
|
-
);
|
|
610
|
+
), evidenceDebt);
|
|
594
611
|
derived.polish_checkpoint_gate = polishCheckpointGate;
|
|
595
|
-
const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
612
|
+
const polishGate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
596
613
|
report,
|
|
597
614
|
hiddenEagerMediaGate: polishCheckpointGate,
|
|
598
615
|
currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
|
|
599
|
-
}), { packet, checkpointGate: polishCheckpointGate });
|
|
616
|
+
}), { packet, checkpointGate: polishCheckpointGate }), { debt: evidenceDebt, checkpointGate: polishCheckpointGate });
|
|
600
617
|
derived.polish_gate = polishGate;
|
|
601
618
|
if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
|
|
602
619
|
pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -503,6 +503,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
503
503
|
}
|
|
504
504
|
if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
|
|
505
505
|
&& !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
|
|
506
|
+
// A page-only change since the build QA reviewed owes QA on the
|
|
507
|
+
// changed pages only (#720); qa run --page pays it.
|
|
508
|
+
const qaDebt = derived?.evidence_debt?.qa;
|
|
509
|
+
if (qaDebt?.scope === "pages" && qaDebt.owed_pages?.length) {
|
|
510
|
+
const pages = qaDebt.owed_pages.map((page) => `--page ${page.page_id}`).join(" ");
|
|
511
|
+
return { stage: "qa", reason: `QA was recorded for a different build, and the change since then is page-only: run QA on the changed pages with ${cmd("qa")} run --packet <packet> ${pages}.` };
|
|
512
|
+
}
|
|
506
513
|
return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
|
|
507
514
|
}
|
|
508
515
|
// A terminal QA status is not the same claim as purchase proof. QA finalizes
|
package/src/doctor-sidecar.mjs
CHANGED
|
@@ -73,8 +73,9 @@ export function writeDoctorSidecar(path, doctor, { command, targetRepo = null }
|
|
|
73
73
|
// qa policy set) stamp the retained snapshot stale instead of leaving a green
|
|
74
74
|
// lie on disk, while commands that DO recompute (doctor, prepare-build/start,
|
|
75
75
|
// next) rewrite the sidecar wholesale — which clears any stale stamp.
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// `path` is the funnel's own sidecar (campaign-workspace.mjs doctorOutPath);
|
|
77
|
+
// without it, the project-level one under `targetBaseDir`.
|
|
78
|
+
export function markDoctorSidecarStale(targetBaseDir, { command = null, reason = null, path = doctorSidecarPath(targetBaseDir) } = {}) {
|
|
78
79
|
if (!existsSync(path)) return null;
|
|
79
80
|
let sidecar;
|
|
80
81
|
try {
|
package/src/evidence-debt.mjs
CHANGED
|
@@ -9,9 +9,10 @@ import {
|
|
|
9
9
|
resolveBuiltSiteScope,
|
|
10
10
|
routeForFile,
|
|
11
11
|
} from "./built-site-scope.mjs";
|
|
12
|
+
import { changeSince, classifyDrift } from "./change-scope.mjs";
|
|
12
13
|
import { normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
13
14
|
import { isFileReadFailure } from "./cart-placeholders.mjs";
|
|
14
|
-
import { qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
|
|
15
|
+
import { polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, qaRecordedForCurrentBuild } from "./stage-ledger.mjs";
|
|
15
16
|
|
|
16
17
|
export const EVIDENCE_DEBT_STATUSES = Object.freeze(["current", "owed", "unknown"]);
|
|
17
18
|
|
|
@@ -24,6 +25,12 @@ const portable = (value) => value.split(sep).join("/");
|
|
|
24
25
|
* the build an older stage reviewed. We cannot reconstruct an older manifest.
|
|
25
26
|
* Missing build inputs remain unknown; lifecycle-required stages are owed
|
|
26
27
|
* even without a stage binding. A matching binding alone is not a verdict.
|
|
28
|
+
*
|
|
29
|
+
* Each owed stage also carries `change`: what changed since the build that
|
|
30
|
+
* stage reviewed (change-scope.mjs), read from the rebuilds `record build`
|
|
31
|
+
* kept plus the drift on disk. A page-only change makes owed Polish
|
|
32
|
+
* `advisory` and owed QA `scope: "pages"` on `owed_pages`; anything else
|
|
33
|
+
* owes the full stage.
|
|
27
34
|
*/
|
|
28
35
|
export function assessEvidenceDebt({ packet = null, packetPath = null, report = null } = {}) {
|
|
29
36
|
const assembly = report?.stages?.assembly;
|
|
@@ -38,12 +45,13 @@ export function assessEvidenceDebt({ packet = null, packetPath = null, report =
|
|
|
38
45
|
let outputRoot = null;
|
|
39
46
|
let reason = null;
|
|
40
47
|
let scopeReason = null;
|
|
48
|
+
let targetRepo = null;
|
|
41
49
|
const target = fingerprint(packet?.assembly?.target_repo);
|
|
42
50
|
const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
43
51
|
if (!packet || !packetPath) reason = "packet_unavailable";
|
|
44
52
|
else if (!target || !slug) reason = "build_output_location_unknown";
|
|
45
53
|
else {
|
|
46
|
-
|
|
54
|
+
targetRepo = resolve(dirname(resolve(packetPath)), target);
|
|
47
55
|
outputRoot = join(targetRepo, "_site", slug);
|
|
48
56
|
try {
|
|
49
57
|
current = computeBuildFingerprint(outputRoot);
|
|
@@ -111,9 +119,41 @@ export function assessEvidenceDebt({ packet = null, packetPath = null, report =
|
|
|
111
119
|
else changes.unmapped_files.push(path);
|
|
112
120
|
}
|
|
113
121
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
const driftClassification = classifyDrift({ changes, baseline: assembly, outputRoot, targetRepo, publicRouteSlug: slug });
|
|
123
|
+
changes.kind = driftClassification.kind;
|
|
124
|
+
changes.reasons = driftClassification.reasons;
|
|
125
|
+
const changeFor = (anchor) => changeSince(anchor, { assembly, drift: changes, driftClassification });
|
|
126
|
+
|
|
127
|
+
// Polish reviewed the build a terminal record is bound to; once `record
|
|
128
|
+
// build` has made it required, the build it reviewed is kept as
|
|
129
|
+
// owed_since_build_fingerprint (polishReviewedBuild). Only a Polish that
|
|
130
|
+
// reviewed a build can be advisory: a blocked or pending one, or one a
|
|
131
|
+
// brief or spec change made required, owes the full stage, before and
|
|
132
|
+
// after record build alike.
|
|
133
|
+
const polishRecord = report?.stages?.polish;
|
|
134
|
+
const polish = stage(polishRecord, polishBuild, Boolean(polishBuild && currentBuild && polishBuild === currentBuild));
|
|
135
|
+
if (polish.status === "owed") {
|
|
136
|
+
const reviewed = polishReviewedBuild(polishRecord);
|
|
137
|
+
polish.change = changeFor(reviewed || polishBuild || fingerprint(polishRecord?.owed_since_build_fingerprint));
|
|
138
|
+
polish.advisory = Boolean(reviewed) && polish.change.kind === "page_only";
|
|
139
|
+
} else {
|
|
140
|
+
polish.change = null;
|
|
141
|
+
polish.advisory = false;
|
|
142
|
+
}
|
|
143
|
+
// QA on the changed pages only extends a completed QA: the change is read
|
|
144
|
+
// from the build that QA checked (qaExtendedBuildFingerprint). A blocked,
|
|
145
|
+
// pending or skipped full run extends nothing and owes the full stage.
|
|
146
|
+
const qaRecord = report?.stages?.qa;
|
|
147
|
+
const qa = stage(qaRecord, qaBuild, qaRecordedForCurrentBuild(report, currentBuild));
|
|
148
|
+
if (qa.status === "owed") {
|
|
149
|
+
const extended = qaExtendedBuildFingerprint(report);
|
|
150
|
+
qa.change = changeFor(extended || qaBuild);
|
|
151
|
+
qa.scope = extended && qaRecord?.status !== "required" && qa.change.kind === "page_only" ? "pages" : "full";
|
|
152
|
+
qa.owed_pages = qa.scope === "pages" ? qa.change.changed_pages : [];
|
|
153
|
+
} else {
|
|
154
|
+
qa.change = null;
|
|
155
|
+
qa.scope = null;
|
|
156
|
+
qa.owed_pages = [];
|
|
157
|
+
}
|
|
158
|
+
return { polish, qa, changes };
|
|
119
159
|
}
|
package/src/finding-cause.mjs
CHANGED
|
@@ -330,7 +330,8 @@ import { iterateQaVerdicts } from "./qa-verdict-discovery.mjs";
|
|
|
330
330
|
import { readRunRecordsForTarget } from "./run-record.mjs";
|
|
331
331
|
|
|
332
332
|
/**
|
|
333
|
-
* The most recent Run Record for the same campaign identity under `baseDir
|
|
333
|
+
* The most recent Run Record for the same campaign identity under `baseDir`
|
|
334
|
+
* (in the packet's runtime directory, `runtimeRelPath`, when one is given),
|
|
334
335
|
* or null. Records are discovered newest-first, and the current run's record
|
|
335
336
|
* does not exist yet at classification time (both `qa run` and `doctor`
|
|
336
337
|
* complete before `run-record` assembles anything), so the first identity
|
|
@@ -341,9 +342,9 @@ import { readRunRecordsForTarget } from "./run-record.mjs";
|
|
|
341
342
|
* against a non-adjacent one and report findings introduced in between as
|
|
342
343
|
* pre-existing.
|
|
343
344
|
*/
|
|
344
|
-
export function findPriorRunRecord({ baseDir, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
345
|
+
export function findPriorRunRecord({ baseDir, runtimeRelPath = undefined, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
345
346
|
if (!text(baseDir)) return null;
|
|
346
|
-
for (const entry of readRunRecordsForTarget(baseDir)) {
|
|
347
|
+
for (const entry of readRunRecordsForTarget(baseDir, runtimeRelPath)) {
|
|
347
348
|
const record = entry?.record;
|
|
348
349
|
if (!record || typeof record !== "object" || Array.isArray(record)) continue;
|
|
349
350
|
if (currentRunId && record.run_id === currentRunId) continue;
|
|
@@ -448,8 +449,8 @@ function locateExternalPriorVerdict({ targetRepo, record, ref }) {
|
|
|
448
449
|
* The single-record boundary is unchanged: this still reads the final attempt
|
|
449
450
|
* of exactly one earlier run, never a merged view across runs.
|
|
450
451
|
*/
|
|
451
|
-
export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
452
|
-
const record = findPriorRunRecord({ baseDir, mapId, localSpecId, currentRunId });
|
|
452
|
+
export function loadPriorQaVerdict({ baseDir, runtimeRelPath = undefined, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
453
|
+
const record = findPriorRunRecord({ baseDir, runtimeRelPath, mapId, localSpecId, currentRunId });
|
|
453
454
|
if (!record) return { verdict: null, record: null, path: null, reason: "no_prior_run" };
|
|
454
455
|
const ref = (Array.isArray(record.artifacts) ? record.artifacts : []).findLast((artifact) => artifact?.kind === "qa_verdict");
|
|
455
456
|
const refPath = text(ref?.path);
|
|
@@ -472,8 +473,8 @@ export function loadPriorQaVerdict({ baseDir, targetRepo = null, mapId = null, l
|
|
|
472
473
|
* carries these, so doctor cause labels work against existing history with no
|
|
473
474
|
* upgrade window. Returns `{ prior, record, reason }`.
|
|
474
475
|
*/
|
|
475
|
-
function loadPriorDoctorFindings({ baseDir, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
476
|
-
const record = findPriorRunRecord({ baseDir, mapId, localSpecId, currentRunId });
|
|
476
|
+
function loadPriorDoctorFindings({ baseDir, runtimeRelPath = undefined, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
477
|
+
const record = findPriorRunRecord({ baseDir, runtimeRelPath, mapId, localSpecId, currentRunId });
|
|
477
478
|
if (!record) return { prior: null, record: null, reason: "no_prior_run" };
|
|
478
479
|
const prior = priorSetFromRunRecordDoctor(record);
|
|
479
480
|
if (!prior) return { prior: null, record, reason: "prior_run_without_doctor_observations" };
|
|
@@ -496,9 +497,9 @@ function loadPriorDoctorFindings({ baseDir, mapId = null, localSpecId = null, cu
|
|
|
496
497
|
* raw map id) have no Run Record home, so they get `unknown` / `no_prior_run`
|
|
497
498
|
* throughout — which is the truth, not a silence.
|
|
498
499
|
*/
|
|
499
|
-
export function annotateQaAssertionCauses(assertions, { baseDir = null, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null, isFinding } = {}) {
|
|
500
|
+
export function annotateQaAssertionCauses(assertions, { baseDir = null, runtimeRelPath = undefined, targetRepo = null, mapId = null, localSpecId = null, currentRunId = null, isFinding } = {}) {
|
|
500
501
|
const lookup = baseDir
|
|
501
|
-
? loadPriorQaVerdict({ baseDir, targetRepo, mapId, localSpecId, currentRunId })
|
|
502
|
+
? loadPriorQaVerdict({ baseDir, runtimeRelPath, targetRepo, mapId, localSpecId, currentRunId })
|
|
502
503
|
: { verdict: null, record: null, path: null, reason: "no_prior_run" };
|
|
503
504
|
const prior = lookup.verdict ? priorSetFromVerdict(lookup.verdict, { isFinding }) : null;
|
|
504
505
|
const noPriorReason = lookup.reason || "no_prior_run";
|
|
@@ -532,9 +533,9 @@ const CAUSE_SUMMARY_SCHEMA = "campaigns-os-finding-cause/v0";
|
|
|
532
533
|
* The doctor twin. `errors` and `warnings` are the doctor output's own arrays;
|
|
533
534
|
* both are stamped in place. Returns the same summary shape.
|
|
534
535
|
*/
|
|
535
|
-
export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
536
|
+
export function annotateDoctorIssueCauses({ errors = [], warnings = [], baseDir = null, runtimeRelPath = undefined, mapId = null, localSpecId = null, currentRunId = null } = {}) {
|
|
536
537
|
const lookup = baseDir
|
|
537
|
-
? loadPriorDoctorFindings({ baseDir, mapId, localSpecId, currentRunId })
|
|
538
|
+
? loadPriorDoctorFindings({ baseDir, runtimeRelPath, mapId, localSpecId, currentRunId })
|
|
538
539
|
: { prior: null, record: null, reason: "no_prior_run" };
|
|
539
540
|
const noPriorReason = lookup.reason || "no_prior_run";
|
|
540
541
|
const findings = [];
|