@nextcommerce/campaigns-os 1.41.2 → 1.43.2
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 +4 -2
- package/CHANGELOG.md +629 -0
- package/README.md +8 -6
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +5 -0
- package/contracts/effects.v1.json +118 -25
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1424 -0
- package/contracts/supported-surface.json +12 -11
- package/docs/build-packet.md +120 -8
- package/docs/campaigns-os-build-flow.md +3 -2
- package/docs/design-source-package.md +89 -15
- package/docs/effects.md +83 -2
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +16 -6
- package/docs/qa-and-test-orders.md +157 -17
- package/docs/release-ledger-authoring-guide.md +6 -4
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +3 -2
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +8 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +4 -4
- package/skills/next-campaigns-os/SKILL.md +17 -4
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +10 -9
- package/skills.json +11 -11
- package/src/build-brief.mjs +6 -4
- package/src/built-script-syntax.mjs +480 -0
- package/src/campaigns-api-key.mjs +99 -0
- package/src/cli-helpers.mjs +118 -0
- package/src/cli.mjs +796 -6963
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +898 -0
- package/src/diagnostic.mjs +2 -1
- package/src/directory-lock.mjs +270 -0
- package/src/doctor/checks.mjs +4415 -0
- package/src/doctor/inspect.mjs +636 -0
- package/src/doctor/next-step.mjs +731 -0
- package/src/finding-cause.mjs +14 -10
- package/src/install-invocation.mjs +29 -0
- package/src/invocation.mjs +179 -0
- package/src/lifecycle.mjs +5 -4
- package/src/polish-node.mjs +5 -2
- package/src/private-template-source.mjs +1 -1
- package/src/progress-node.mjs +9 -37
- package/src/progress.mjs +5 -3
- package/src/proof-policy.mjs +1 -1
- package/src/qa-analytics-correctness.mjs +3 -0
- package/src/qa-binding-evidence.mjs +76 -11
- package/src/qa-browser.mjs +778 -77
- package/src/qa-build-scope.mjs +47 -0
- package/src/qa-node.mjs +276 -39
- package/src/qa-publish.mjs +4 -0
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +2 -1
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/source-html-intake.mjs +1 -1
- package/src/source-html-manifest.mjs +9 -2
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +32 -1
- package/src/target-lock.mjs +54 -0
- package/src/template-brand-contract.mjs +17 -1
- package/src/tooling-setup.mjs +160 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// Doctor entry points: the doctor command, packet inspection and built-output inspection.
|
|
2
|
+
import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
|
|
3
|
+
import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { shellToken } from "../shell-token.mjs";
|
|
7
|
+
import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
|
|
8
|
+
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
9
|
+
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
10
|
+
import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
11
|
+
import { evaluateThemeGate } from "../theme-gate.mjs";
|
|
12
|
+
import { findForbiddenPriceHides } from "../template-brand-contract.mjs";
|
|
13
|
+
import { resolveBuiltSiteScope, synthesizeMinimalBuildPacket } from "../built-site-scope.mjs";
|
|
14
|
+
import { UPSELL_SELECTOR_SCOPE } from "../upsell-selector-scope.mjs";
|
|
15
|
+
import { CAMPAIGN_IDENTITY } from "../campaign-identity.mjs";
|
|
16
|
+
import { SDK_MARKUP } from "../sdk-markup.mjs";
|
|
17
|
+
import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs } from "../built-script-syntax.mjs";
|
|
18
|
+
import { stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
19
|
+
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
20
|
+
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "../polish-node.mjs";
|
|
21
|
+
import {
|
|
22
|
+
requireArg,
|
|
23
|
+
isObject,
|
|
24
|
+
isNonEmptyString,
|
|
25
|
+
optionalString,
|
|
26
|
+
readJsonIfExists,
|
|
27
|
+
relFromDir,
|
|
28
|
+
isLocalAbsolutePath,
|
|
29
|
+
addIssue,
|
|
30
|
+
} from "../cli-helpers.mjs";
|
|
31
|
+
import {
|
|
32
|
+
PACKET_SCHEMA,
|
|
33
|
+
runDoctorChecks,
|
|
34
|
+
ARTIFACT_DOCTOR_CHECKS,
|
|
35
|
+
validatePacket,
|
|
36
|
+
recordUpsellSelectorScopeGate,
|
|
37
|
+
collectBuiltPageIdentityInputs,
|
|
38
|
+
recordCampaignIdentityGate,
|
|
39
|
+
recordScriptSyntaxGate,
|
|
40
|
+
recordSdkMarkupGate,
|
|
41
|
+
summarizeCopyMatches,
|
|
42
|
+
resolveBrandContractOnce,
|
|
43
|
+
reportBrandContractDefectOnce,
|
|
44
|
+
validateBuiltPlaceholderTextResidue,
|
|
45
|
+
validateBuiltDemoAssetFidelity,
|
|
46
|
+
collectGenericTemplateResidueMatches,
|
|
47
|
+
} from "./checks.mjs";
|
|
48
|
+
import {
|
|
49
|
+
gateIssue,
|
|
50
|
+
pushGateIssue,
|
|
51
|
+
nextPrepareBuildBindingIssues,
|
|
52
|
+
prepareBuildGateIssue,
|
|
53
|
+
addPrepareBuildGateErrors,
|
|
54
|
+
checkpointExceptionPresent,
|
|
55
|
+
buildNextStep,
|
|
56
|
+
} from "./next-step.mjs";
|
|
57
|
+
|
|
58
|
+
function writeJson(path, value) {
|
|
59
|
+
mkdirSync(dirname(resolve(path)), { recursive: true });
|
|
60
|
+
writeFileSync(resolve(path), `${JSON.stringify(value, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function relativizeDoctorOutput(result, baseDir) {
|
|
64
|
+
const replacements = new Map();
|
|
65
|
+
for (const value of Object.values(result.derived || {})) {
|
|
66
|
+
if (isLocalAbsolutePath(value)) {
|
|
67
|
+
replacements.set(value, relFromDir(baseDir, value));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const sortedReplacements = [...replacements.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
71
|
+
|
|
72
|
+
function visit(value) {
|
|
73
|
+
if (Array.isArray(value)) return value.map(visit);
|
|
74
|
+
if (isObject(value)) {
|
|
75
|
+
return Object.fromEntries(Object.entries(value).map(([key, entryValue]) => [key, visit(entryValue)]));
|
|
76
|
+
}
|
|
77
|
+
if (typeof value !== "string") return value;
|
|
78
|
+
if (isLocalAbsolutePath(value)) return relFromDir(baseDir, value);
|
|
79
|
+
let nextValue = value;
|
|
80
|
+
for (const [absolutePath, relativePath] of sortedReplacements) {
|
|
81
|
+
nextValue = nextValue.split(absolutePath).join(relativePath);
|
|
82
|
+
}
|
|
83
|
+
return nextValue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return visit(result);
|
|
87
|
+
}
|
|
88
|
+
// Sidecar producer name; see the producer comment above NEXT_PRODUCER in src/cli.mjs.
|
|
89
|
+
const DOCTOR_PRODUCER = "doctor";
|
|
90
|
+
|
|
91
|
+
export function doctorCommand(args, { runDoctor = doctorPacket } = {}) {
|
|
92
|
+
// Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
|
|
93
|
+
// campaign that has only a built _site/ and no full Build Packet. Resolves
|
|
94
|
+
// scope from the built output and runs the built-output residue/text/
|
|
95
|
+
// demo-asset/pricing gates against the chosen family's brand contract.
|
|
96
|
+
const builtArg = args.built || args.site;
|
|
97
|
+
if (builtArg && !args.packet) {
|
|
98
|
+
return doctorBuiltOutput(args);
|
|
99
|
+
}
|
|
100
|
+
const packetPath = resolve(requireArg(args, "packet"));
|
|
101
|
+
const explicitSidecarArgs = Boolean(args.context || args.report);
|
|
102
|
+
const doctorOptions = {
|
|
103
|
+
contextPath: args.context ? resolve(args.context) : explicitSidecarArgs ? null : undefined,
|
|
104
|
+
reportPath: args.report ? resolve(args.report) : explicitSidecarArgs ? null : undefined,
|
|
105
|
+
outputBaseDir: args["strip-paths"] === true ? dirname(packetPath) : null,
|
|
106
|
+
};
|
|
107
|
+
const result = runDoctor(packetPath, doctorOptions);
|
|
108
|
+
// Inspection and recording are separate operations. A laptop's untracked
|
|
109
|
+
// built output can be stale while the delivered build's evidence is valid.
|
|
110
|
+
// Only an explicit producer action may replace the retained proof artifacts;
|
|
111
|
+
// --no-write wins if both flags are supplied.
|
|
112
|
+
if (args.write === true && args["no-write"] !== true) {
|
|
113
|
+
// The stage write-back restates the outcome into the report the
|
|
114
|
+
// inspection read: the one --report named, else the one the Build Context
|
|
115
|
+
// binds (`derived.assembly_report_path`, a `prepare-build --report-out`
|
|
116
|
+
// campaign's report), else the default location. Following the binding is
|
|
117
|
+
// what keeps the doctor stage on a bound report current; a report of
|
|
118
|
+
// another campaign is still refused by commitAssemblyReport's identity
|
|
119
|
+
// check, so the outcome never lands in another run's evidence. The
|
|
120
|
+
// sidecar itself goes under the target repo, where prepare-build, next
|
|
121
|
+
// and the QA stage refresh write it — not beside the packet.
|
|
122
|
+
const inspectedReportPath = optionalString(result.derived?.assembly_report_path);
|
|
123
|
+
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
124
|
+
reportPath: args.report
|
|
125
|
+
? resolve(args.report)
|
|
126
|
+
: inspectedReportPath
|
|
127
|
+
? resolve(dirname(packetPath), inspectedReportPath)
|
|
128
|
+
: undefined,
|
|
129
|
+
doctorOutPath: args["doctor-out"] ? resolve(args["doctor-out"]) : undefined,
|
|
130
|
+
followContextPointer: false,
|
|
131
|
+
});
|
|
132
|
+
// The sidecar is this inspection's result, written whether or not the
|
|
133
|
+
// report gained a new chapter (a re-run restating the outcome already on
|
|
134
|
+
// disk leaves the report's bytes, and every digest of them, alone). A
|
|
135
|
+
// report this inspection did not read is not opened at all: its state,
|
|
136
|
+
// malformed included, is not this run's concern.
|
|
137
|
+
// This function is the `doctor` command; it states its own name for the
|
|
138
|
+
// stage record and the sidecar's generated_by rather than re-reading
|
|
139
|
+
// argv, which a programmatic caller may not have shifted (#312).
|
|
140
|
+
commitAssemblyReport(workspace, (report) => recordDoctorStageOutcome(report, result, {
|
|
141
|
+
command: `campaigns-os ${DOCTOR_PRODUCER}`,
|
|
142
|
+
doctorOutPath: workspace.doctorOutPath,
|
|
143
|
+
}), { stage: "doctor", command: DOCTOR_PRODUCER, refreshDoctor: () => result });
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function recordDoctorStageOutcome(report, result, { command, doctorOutPath }) {
|
|
149
|
+
return recordProducerStageOutcome(report, {
|
|
150
|
+
stage: "doctor",
|
|
151
|
+
disposition: result.ok ? (result.warnings?.length ? "ready_with_warnings" : "ready") : "blocked",
|
|
152
|
+
timestamp: result.generated_at,
|
|
153
|
+
command,
|
|
154
|
+
outputs: [doctorOutPath],
|
|
155
|
+
blockers: (result.errors || []).map((issue) => issue?.message).filter(isNonEmptyString),
|
|
156
|
+
warnings: (result.warnings || []).map((issue) => issue?.message).filter(isNonEmptyString),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
// L7 non-packet doctor: resolve scope from a built _site/, run the built-output
|
|
162
|
+
// gates the family brand contract drives, and auto-emit a minimal Build Packet
|
|
163
|
+
// (optionally written with --emit-packet) so QA can run against the same
|
|
164
|
+
// campaign without a hand-authored packet.
|
|
165
|
+
export function doctorBuiltOutput(args) {
|
|
166
|
+
const targetRepo = resolve(String(args.built || args.site));
|
|
167
|
+
const errors = [];
|
|
168
|
+
const warnings = [];
|
|
169
|
+
const ready = [];
|
|
170
|
+
if (!existsSync(targetRepo) || !statSync(targetRepo).isDirectory()) {
|
|
171
|
+
addIssue(errors, "built_site.target", `Built campaign directory does not exist: ${targetRepo}`);
|
|
172
|
+
return { ok: false, status: "blocked", mode: "built_site", errors, warnings, ready, derived: { mode: "built_site" }, next: null };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug: optionalString(args.slug) });
|
|
176
|
+
if (!scope.ok) {
|
|
177
|
+
addIssue(errors, "built_site.scope", scope.error || "Could not resolve scope from the built _site/.");
|
|
178
|
+
return { ok: false, status: "blocked", mode: "built_site", errors, warnings, ready, derived: { mode: "built_site", scope }, next: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const family = optionalString(args.family) || null;
|
|
182
|
+
const baseUrl = optionalString(args["base-url"]);
|
|
183
|
+
const mapId = optionalString(args["map-id"]);
|
|
184
|
+
const deployTarget = optionalString(args["deploy-target"], "unknown");
|
|
185
|
+
|
|
186
|
+
const derived = {
|
|
187
|
+
mode: "built_site",
|
|
188
|
+
map_id: mapId || scope.slug || null,
|
|
189
|
+
public_route_slug: scope.slug || null,
|
|
190
|
+
template_family: family,
|
|
191
|
+
target_repo: targetRepo,
|
|
192
|
+
target_output_dir: scope.campaign_dir,
|
|
193
|
+
site_root: scope.site_root,
|
|
194
|
+
built_pages: scope.pages.map((page) => ({ page_id: page.page_id, type: page.page_type, route: page.route })),
|
|
195
|
+
doctor_checks: [],
|
|
196
|
+
checkpoint_gates: [],
|
|
197
|
+
};
|
|
198
|
+
ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
|
|
199
|
+
|
|
200
|
+
const resolution = resolveBrandContractOnce(derived, family);
|
|
201
|
+
let brandContract = null;
|
|
202
|
+
if (!family) {
|
|
203
|
+
addIssue(warnings, "assembly.template_family", "No --family given; the residue/placeholder-text/demo-asset gates need a family brand contract to run. Pass --family <family> (the family the campaign was built from).");
|
|
204
|
+
} else {
|
|
205
|
+
reportBrandContractDefectOnce(resolution, warnings, family);
|
|
206
|
+
brandContract = resolution.contract;
|
|
207
|
+
if (!brandContract) {
|
|
208
|
+
addIssue(warnings, "template_contract.brand_contract", `No brand/residue/pricing contract found for family "${family}". Built-output residue gates cannot run; confirm the family slug.`);
|
|
209
|
+
} else {
|
|
210
|
+
ready.push(`Template brand/residue/pricing contract loaded for ${family}`);
|
|
211
|
+
validateBuiltPlaceholderTextResidue(brandContract, warnings, ready, derived);
|
|
212
|
+
validateBuiltDemoAssetFidelity(brandContract, warnings, ready, derived);
|
|
213
|
+
// Pricing CSS-hide scan (report omitted -> a missing assets/css dir reads
|
|
214
|
+
// as a skipped ready-line, not a false "scan did not run" warning, since
|
|
215
|
+
// built page-kit output may lay CSS out differently).
|
|
216
|
+
runPricingCssHideCheck({ packet: { assembly: { template_family: family } }, derived, warnings, ready, report: null });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Family-agnostic generic placeholder residue (XXCODE / Product Title /
|
|
221
|
+
// next-logo.png ...) always runs against the built output.
|
|
222
|
+
const genericHits = collectGenericTemplateResidueMatches(scope.campaign_dir);
|
|
223
|
+
if (genericHits.length) {
|
|
224
|
+
addIssue(
|
|
225
|
+
warnings,
|
|
226
|
+
"template_contract.literal_residue",
|
|
227
|
+
`Built output contains generic starter/template placeholders: ${summarizeCopyMatches(genericHits)}. Replace these from CampaignSpec/API or remove dead template references.`,
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
ready.push("Built output has no generic starter placeholder or promo-code residue");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Upsell selector scope (#270). Deliberately outside the family/brand-contract
|
|
234
|
+
// branch above: the defect is family-independent, and this mode is reached
|
|
235
|
+
// without --family more often than with it. Page roles come from the built
|
|
236
|
+
// route (resolveBuiltSiteScope infers them) and from each page's own
|
|
237
|
+
// next-page-type meta, so no packet or spec is needed. No assembly report
|
|
238
|
+
// exists on this path, so there are no waivers to assess — a blocker here is
|
|
239
|
+
// repaired in the source, or waived through the packet path.
|
|
240
|
+
recordUpsellSelectorScopeGate({
|
|
241
|
+
subject: {
|
|
242
|
+
public_route_slug: scope.slug || null,
|
|
243
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
244
|
+
},
|
|
245
|
+
pages: scope.pages.map((page) => ({
|
|
246
|
+
page_id: page.page_id,
|
|
247
|
+
page_type: page.page_type,
|
|
248
|
+
file: relFromDir(targetRepo, page.built_path),
|
|
249
|
+
content: readFileSync(page.built_path, "utf8"),
|
|
250
|
+
})),
|
|
251
|
+
waivers: null,
|
|
252
|
+
errors,
|
|
253
|
+
warnings,
|
|
254
|
+
ready,
|
|
255
|
+
derived,
|
|
256
|
+
});
|
|
257
|
+
derived.doctor_checks.push(UPSELL_SELECTOR_SCOPE);
|
|
258
|
+
|
|
259
|
+
// Cross-page campaign identity (#301). Same placement and the same reasons:
|
|
260
|
+
// family-independent, needs no packet, and the borrowed-page defect it gates
|
|
261
|
+
// is most often introduced on exactly the page-kit campaigns this path
|
|
262
|
+
// inspects.
|
|
263
|
+
recordCampaignIdentityGate({
|
|
264
|
+
subject: {
|
|
265
|
+
public_route_slug: scope.slug || null,
|
|
266
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
267
|
+
},
|
|
268
|
+
pages: collectBuiltPageIdentityInputs(scope, targetRepo),
|
|
269
|
+
errors,
|
|
270
|
+
ready,
|
|
271
|
+
derived,
|
|
272
|
+
});
|
|
273
|
+
derived.doctor_checks.push(CAMPAIGN_IDENTITY);
|
|
274
|
+
|
|
275
|
+
// Static SDK markup checks (#303). Same placement, same reasons.
|
|
276
|
+
recordSdkMarkupGate({
|
|
277
|
+
subject: {
|
|
278
|
+
public_route_slug: scope.slug || null,
|
|
279
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
280
|
+
},
|
|
281
|
+
pages: collectBuiltPageIdentityInputs(scope, targetRepo),
|
|
282
|
+
errors,
|
|
283
|
+
warnings,
|
|
284
|
+
ready,
|
|
285
|
+
derived,
|
|
286
|
+
});
|
|
287
|
+
derived.doctor_checks.push(SDK_MARKUP);
|
|
288
|
+
|
|
289
|
+
// Campaign-owned script syntax (#480). Same placement, same reasons: a
|
|
290
|
+
// hand-edited script that no longer parses is invisible to every HTML gate.
|
|
291
|
+
recordScriptSyntaxGate({
|
|
292
|
+
subject: {
|
|
293
|
+
public_route_slug: scope.slug || null,
|
|
294
|
+
site_root: relFromDir(targetRepo, scope.campaign_dir),
|
|
295
|
+
},
|
|
296
|
+
inputs: collectBuiltScriptSyntaxInputs(scope, targetRepo),
|
|
297
|
+
errors,
|
|
298
|
+
warnings,
|
|
299
|
+
ready,
|
|
300
|
+
derived,
|
|
301
|
+
});
|
|
302
|
+
derived.doctor_checks.push(SCRIPT_SYNTAX);
|
|
303
|
+
|
|
304
|
+
const synthesized = synthesizeMinimalBuildPacket({
|
|
305
|
+
schemaVersion: PACKET_SCHEMA,
|
|
306
|
+
targetRepo,
|
|
307
|
+
scope,
|
|
308
|
+
family,
|
|
309
|
+
mapId,
|
|
310
|
+
baseUrl,
|
|
311
|
+
deployTarget,
|
|
312
|
+
});
|
|
313
|
+
derived.synthesized_packet = synthesized;
|
|
314
|
+
|
|
315
|
+
let emittedPacketPath = null;
|
|
316
|
+
if (args["emit-packet"]) {
|
|
317
|
+
emittedPacketPath = args["emit-packet"] === true
|
|
318
|
+
? join(targetRepo, ".campaign-runtime", "minimal-build-packet.json")
|
|
319
|
+
: resolve(String(args["emit-packet"]));
|
|
320
|
+
mkdirSync(dirname(emittedPacketPath), { recursive: true });
|
|
321
|
+
writeJson(emittedPacketPath, synthesized);
|
|
322
|
+
ready.push(`Emitted minimal Build Packet to ${relFromDir(targetRepo, emittedPacketPath)}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const next = buildNextStep(errors, warnings, derived, null);
|
|
326
|
+
const status = errors.length ? "blocked" : warnings.length ? "ready_with_warnings" : "ready";
|
|
327
|
+
return {
|
|
328
|
+
ok: errors.length === 0,
|
|
329
|
+
status,
|
|
330
|
+
mode: "built_site",
|
|
331
|
+
errors,
|
|
332
|
+
warnings,
|
|
333
|
+
ready,
|
|
334
|
+
derived,
|
|
335
|
+
scope: { slug: scope.slug, html_count: scope.html_count, pages: derived.built_pages, campaign_dir: scope.campaign_dir },
|
|
336
|
+
synthesized_packet: synthesized,
|
|
337
|
+
emitted_packet_path: emittedPacketPath,
|
|
338
|
+
next,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function doctorPacket(packetPath, options = {}) {
|
|
343
|
+
const result = withHtmlScanSnapshot(() => inspectDoctorPacket(packetPath, options));
|
|
344
|
+
// Per-finding cause classification lives HERE, at the single production
|
|
345
|
+
// boundary, and not in the doctor command. Four producers persist
|
|
346
|
+
// .campaign-runtime/doctor-output.json from a doctorPacket result — `doctor
|
|
347
|
+
// --write`, `next`, `start`/`build` (prepare-build runs no doctor), and the
|
|
348
|
+
// QA stage refresh — and annotating only one of them means running QA after
|
|
349
|
+
// doctor silently strips the labels back out of the retained artifact. Every
|
|
350
|
+
// consumer of a doctor result gets the same shape, whether or not it writes
|
|
351
|
+
// one. Each producer stamps the artifact with its own name on the way out
|
|
352
|
+
// (`generated_by`, #312; writeDoctorSidecar / stampDoctorProducer), so a
|
|
353
|
+
// retained sidecar always says which of the four wrote it. `standardize` is
|
|
354
|
+
// not one of them: it reads the target and writes nothing.
|
|
355
|
+
//
|
|
356
|
+
// The comparison set is the previous Run Record's own doctor observations
|
|
357
|
+
// (error_codes / warning_codes), which every Run Record ever written already
|
|
358
|
+
// carries — so this works against existing history rather than needing a run
|
|
359
|
+
// to go by first. Code granularity, because that is the granularity the
|
|
360
|
+
// record stores. baseDir is the packet directory, the same root the Run
|
|
361
|
+
// Record writes under.
|
|
362
|
+
// An invalid identity cannot select history. In particular, withholding a
|
|
363
|
+
// malformed local ID from derived must not turn it into an unfiltered or
|
|
364
|
+
// Map-only lookup of another campaign's findings.
|
|
365
|
+
const comparableIdentity = resolveCampaignIdentity(result.derived)
|
|
366
|
+
&& !result.errors.some(issue => issue.code === "spec.local_identity" || issue.code === "spec.map_id");
|
|
367
|
+
result.cause_summary = annotateDoctorIssueCauses({
|
|
368
|
+
errors: result.errors,
|
|
369
|
+
warnings: result.warnings,
|
|
370
|
+
baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
|
|
371
|
+
mapId: result.derived?.map_id || null,
|
|
372
|
+
localSpecId: result.derived?.local_spec_id || null,
|
|
373
|
+
});
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath = undefined, outputBaseDir = null } = {}) {
|
|
378
|
+
// The Build Context records where prepare-build wrote the report
|
|
379
|
+
// (--report-out). `next` follows that pointer when no --report is given;
|
|
380
|
+
// doctor reads the same report so its gates and its next block cannot
|
|
381
|
+
// disagree with the ladder over which report is the campaign's.
|
|
382
|
+
const { packet, targetRepo: gateTargetRepo, contextPath: resolvedContextPath, reportPath: resolvedReportPath } = resolveCampaignWorkspace(packetPath, {
|
|
383
|
+
contextPath,
|
|
384
|
+
reportPath,
|
|
385
|
+
followContextPointer: true,
|
|
386
|
+
});
|
|
387
|
+
const context = readJsonIfExists(resolvedContextPath);
|
|
388
|
+
const report = readJsonIfExists(resolvedReportPath);
|
|
389
|
+
const errors = [];
|
|
390
|
+
const warnings = [];
|
|
391
|
+
const ready = [];
|
|
392
|
+
const packetIdentity = resolveCampaignIdentity(packet?.spec);
|
|
393
|
+
const derived = {
|
|
394
|
+
packet_path: packetPath,
|
|
395
|
+
// The report this inspection read (null when the caller switched the
|
|
396
|
+
// report off), so a writer can refuse to restate the outcome into a
|
|
397
|
+
// different file.
|
|
398
|
+
assembly_report_path: typeof resolvedReportPath === "string" ? resolvedReportPath : null,
|
|
399
|
+
map_id: packet?.spec?.map_id || null,
|
|
400
|
+
...(packetIdentity?.kind === "local_spec" ? { local_spec_id: packetIdentity.id } : {}),
|
|
401
|
+
public_route_slug: packet?.campaign?.public_route_slug || null,
|
|
402
|
+
template_family: packet?.assembly?.template_family || null,
|
|
403
|
+
source_root: null,
|
|
404
|
+
target_repo: null,
|
|
405
|
+
target_output_dir: null,
|
|
406
|
+
spec_path: null,
|
|
407
|
+
doctor_checks: [],
|
|
408
|
+
checkpoint_gates: [],
|
|
409
|
+
polish_checkpoint_gate: null,
|
|
410
|
+
// The prepare-build gate `next` acts on, stored like every other gate so
|
|
411
|
+
// the ladder consumes doctor's evaluation instead of computing its own.
|
|
412
|
+
prepare_build_gate: null,
|
|
413
|
+
// The family brand contract, resolved once per run: { state, family } plus
|
|
414
|
+
// { code, detail } for a defect. The `next` advisories read it.
|
|
415
|
+
brand_contract: null,
|
|
416
|
+
page_kit_campaign_config: null,
|
|
417
|
+
scaffold_required: false,
|
|
418
|
+
scaffold_reason: null,
|
|
419
|
+
scope: {
|
|
420
|
+
mode: "unknown",
|
|
421
|
+
built_pages: [],
|
|
422
|
+
out_of_scope_pages: [],
|
|
423
|
+
previewable_routes: [],
|
|
424
|
+
blocked_runtime_pages: [],
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
validatePacket(packet, packetPath, errors, warnings, ready, derived, { context, report });
|
|
429
|
+
runDoctorChecks(ARTIFACT_DOCTOR_CHECKS, { context, report, errors, warnings, ready, derived });
|
|
430
|
+
|
|
431
|
+
// Doctor and the stage ladder must agree over one packet (#238): when the
|
|
432
|
+
// recorded assembly report holds prepare_build at "blocked" (or claims a
|
|
433
|
+
// terminal status while retaining blocking evidence), every `next <stage>`
|
|
434
|
+
// command refuses to run — so doctor surfaces the same blockers as errors
|
|
435
|
+
// instead of exiting 0 and naming a stage the ladder then rejects. Doctor
|
|
436
|
+
// exit 0 means the command it names will actually run.
|
|
437
|
+
const prepareBuildLadderGate = prepareBuildGateIssue(report);
|
|
438
|
+
if (prepareBuildLadderGate?.blocked) {
|
|
439
|
+
addPrepareBuildGateErrors(errors, report, prepareBuildLadderGate);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Theme gate: evaluated once here so `next`, QA, and run telemetry all read
|
|
443
|
+
// the same decision from derived.theme_gate. The doctor reports a blocked
|
|
444
|
+
// gate as a WARNING (not an error) because the fix happens during the build
|
|
445
|
+
// stage — but `next polish|deploy|qa` and `qa run` treat the same gate
|
|
446
|
+
// result as a hard blocker.
|
|
447
|
+
const themeGate = evaluateThemeGate({
|
|
448
|
+
reportTheme: report?.theme || null,
|
|
449
|
+
contextTheme: context?.theme || null,
|
|
450
|
+
scope: derived.scope,
|
|
451
|
+
packetPath,
|
|
452
|
+
});
|
|
453
|
+
derived.theme_gate = themeGate;
|
|
454
|
+
if (themeGate.status === "blocked") {
|
|
455
|
+
pushGateIssue({ errors, warnings }, gateIssue("theme_gate", themeGate));
|
|
456
|
+
} else if (themeGate.status === "waived") {
|
|
457
|
+
ready.push(`Theme gate waived: ${themeGate.waiver?.reason || "(no reason recorded)"}`);
|
|
458
|
+
} else if (themeGate.status === "pass") {
|
|
459
|
+
// The gate passes on two different facts (a brand layer applied, or no
|
|
460
|
+
// generatable brand theme at all); print the one it found, never the
|
|
461
|
+
// other. An operator reading ready[] on a token-less campaign must not
|
|
462
|
+
// believe brand styling shipped.
|
|
463
|
+
ready.push(`Theme gate passed: ${themeGate.reason}`);
|
|
464
|
+
}
|
|
465
|
+
runPricingCssHideCheck({ packet, derived, warnings, ready, report });
|
|
466
|
+
|
|
467
|
+
const polishCheckpointGate = evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report });
|
|
468
|
+
derived.polish_checkpoint_gate = polishCheckpointGate;
|
|
469
|
+
const polishGate = evaluatePolishGate({
|
|
470
|
+
report,
|
|
471
|
+
hiddenEagerMediaGate: polishCheckpointGate,
|
|
472
|
+
currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
|
|
473
|
+
});
|
|
474
|
+
derived.polish_gate = polishGate;
|
|
475
|
+
if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
|
|
476
|
+
pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
|
|
477
|
+
} else if (polishGate.status === "waived" && !polishGate.owned_checkpoint_only) {
|
|
478
|
+
ready.push(`Polish gate passed under waiver: ${polishGate.waiver?.reason || "(no reason recorded)"}`);
|
|
479
|
+
} else if (polishGate.status === "pass") {
|
|
480
|
+
ready.push("Polish gate passed: structured evidence is current for this build.");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (polishCheckpointGate.status === "blocked") {
|
|
484
|
+
pushGateIssue({ errors, warnings }, gateIssue("polish_checkpoint_gate", polishCheckpointGate));
|
|
485
|
+
} else if (polishCheckpointGate.status === "waived") {
|
|
486
|
+
addIssue(
|
|
487
|
+
warnings,
|
|
488
|
+
polishCheckpointGate.code,
|
|
489
|
+
`${polishCheckpointGate.reason} Waived by ${polishCheckpointGate.waiver.waived_by}: ${polishCheckpointGate.waiver.reason}`,
|
|
490
|
+
{ polish_checkpoint_gate: polishCheckpointGate },
|
|
491
|
+
);
|
|
492
|
+
ready.push(`Hidden eager-media checkpoint accepted under named-human exception (${polishCheckpointGate.waiver.waived_by}).`);
|
|
493
|
+
} else if (polishCheckpointGate.status === "pass") {
|
|
494
|
+
ready.push("Hidden eager-media checkpoint passed: package-owned page-load evidence is complete and current.");
|
|
495
|
+
} else {
|
|
496
|
+
ready.push("Hidden eager-media checkpoint not applicable before completed assembly.");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// The same fully resolved prepare-build gate the `next` command evaluates:
|
|
500
|
+
// DSP-required packets, the recorded report path and the context/report
|
|
501
|
+
// binding checks. A weaker gate here would let doctor name setup or build
|
|
502
|
+
// while `next` still answers prepare-build.
|
|
503
|
+
// With no context on hand (doctor --report alone) the binding checks have
|
|
504
|
+
// nothing to compare and are skipped; the report itself was resolved
|
|
505
|
+
// above the way `next` resolves it.
|
|
506
|
+
// The stage decision runs over exactly the artifacts the checks ran over.
|
|
507
|
+
// A caller that named one sidecar and not the other (doctor --context C)
|
|
508
|
+
// is inspecting, and its report checks are deliberately off; its next
|
|
509
|
+
// block decides without the report too, and says so in `reason`. The
|
|
510
|
+
// binding is evaluated whether or not a Build Context was found: an absent
|
|
511
|
+
// context is itself a binding failure for a packet that declares a Design
|
|
512
|
+
// Source Package, and `next` consumes this gate rather than computing its
|
|
513
|
+
// own, so the two cannot answer differently on the same repo.
|
|
514
|
+
const prepareBuildGate = prepareBuildGateIssue(report, {
|
|
515
|
+
required: isObject(packet?.design_source_package),
|
|
516
|
+
reportPath: resolvedReportPath,
|
|
517
|
+
bindingIssues: isObject(packet)
|
|
518
|
+
? nextPrepareBuildBindingIssues({
|
|
519
|
+
packet,
|
|
520
|
+
packetPath,
|
|
521
|
+
context,
|
|
522
|
+
contextPath: resolvedContextPath,
|
|
523
|
+
report,
|
|
524
|
+
reportPath: resolvedReportPath,
|
|
525
|
+
targetRepo: gateTargetRepo,
|
|
526
|
+
explicitReport: typeof reportPath === "string",
|
|
527
|
+
})
|
|
528
|
+
: [],
|
|
529
|
+
});
|
|
530
|
+
derived.prepare_build_gate = prepareBuildGate;
|
|
531
|
+
// Portable output (outputBaseDir set: start's generated doctor output,
|
|
532
|
+
// doctor --strip-paths) rebases them onto that base like every other path
|
|
533
|
+
// in the output, so a relocated handoff does not name the original machine.
|
|
534
|
+
const sidecarArg = (path) => shellToken(outputBaseDir ? relFromDir(outputBaseDir, path) : path);
|
|
535
|
+
const sidecarArgs = [
|
|
536
|
+
...(typeof contextPath === "string" ? [` --context ${sidecarArg(contextPath)}`] : []),
|
|
537
|
+
...(typeof reportPath === "string" ? [` --report ${sidecarArg(reportPath)}`] : []),
|
|
538
|
+
].join("");
|
|
539
|
+
const next = buildNextStep(errors, warnings, derived, report, packet, prepareBuildGate, { sidecarArgs });
|
|
540
|
+
// Portable output: a sidecar path the picker's reason names is rebased
|
|
541
|
+
// like every other path in the output.
|
|
542
|
+
if (outputBaseDir && typeof next?.reason === "string") {
|
|
543
|
+
for (const path of [resolvedContextPath, resolvedReportPath]) {
|
|
544
|
+
if (typeof path === "string" && next.reason.includes(path)) {
|
|
545
|
+
next.reason = next.reason.split(path).join(relFromDir(outputBaseDir, path));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const status = errors.length
|
|
550
|
+
? "blocked"
|
|
551
|
+
: checkpointExceptionPresent(derived)
|
|
552
|
+
? "ready_with_waivers"
|
|
553
|
+
: warnings.length
|
|
554
|
+
? "ready_with_warnings"
|
|
555
|
+
: "ready";
|
|
556
|
+
const result = {
|
|
557
|
+
schema_version: DOCTOR_SIDECAR_SCHEMA,
|
|
558
|
+
generated_at: new Date().toISOString(),
|
|
559
|
+
ok: errors.length === 0,
|
|
560
|
+
status,
|
|
561
|
+
errors,
|
|
562
|
+
warnings,
|
|
563
|
+
ready,
|
|
564
|
+
derived,
|
|
565
|
+
next,
|
|
566
|
+
};
|
|
567
|
+
return outputBaseDir ? relativizeDoctorOutput(result, outputBaseDir) : result;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Pricing surfaces are rendered by mode-driven partials, never hidden with
|
|
571
|
+
// campaign CSS — a display:none on a price wrapper is how the recovery-relief
|
|
572
|
+
// dogfood run shipped a full-price upsell with NO visible price. Deterministic
|
|
573
|
+
// static scan: campaign-owned CSS files (not the family core stylesheet, not
|
|
574
|
+
// the generated brand layer) must not display:none any selector the family
|
|
575
|
+
// brand contract lists under pricing_surfaces.forbidden_css_hides. Doctor
|
|
576
|
+
// reports a warning with the exact rule; browser QA enforces the outcome
|
|
577
|
+
// (zero visible price rows) as a blocker.
|
|
578
|
+
export function runPricingCssHideCheck({ packet, derived, warnings, ready, report = null }) {
|
|
579
|
+
const family = packet?.assembly?.template_family;
|
|
580
|
+
const resolution = resolveBrandContractOnce(derived, family);
|
|
581
|
+
if (resolution.error) {
|
|
582
|
+
reportBrandContractDefectOnce(resolution, warnings, family);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const contract = resolution.contract;
|
|
586
|
+
if (!contract?.pricing_surfaces?.forbidden_css_hides?.length) {
|
|
587
|
+
ready.push(`Pricing CSS scan not applicable for template family "${family || "(none)"}" (no brand contract with forbidden_css_hides)`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
// Missing campaign output is normal before setup/build (audit ready-line),
|
|
591
|
+
// but anomalous once the assembly stage is recorded terminal — at that
|
|
592
|
+
// point a missing dir means the scan that should have covered built CSS
|
|
593
|
+
// never ran, which the operator must see as a warning, not a footnote.
|
|
594
|
+
const assemblyDone = stageIsTerminal(report?.stages?.assembly?.status);
|
|
595
|
+
const skipScan = (reason) => {
|
|
596
|
+
if (assemblyDone) {
|
|
597
|
+
addIssue(warnings, "template_contract.price_css_scan_skipped", `Pricing CSS scan did NOT run although assembly is recorded terminal: ${reason}. Check assembly.target_repo / output_dir configuration.`);
|
|
598
|
+
} else {
|
|
599
|
+
ready.push(`Pricing CSS scan skipped: ${reason} (runs after setup/build)`);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
const outputDir = derived.target_output_dir;
|
|
603
|
+
if (!outputDir || !existsSync(outputDir)) {
|
|
604
|
+
skipScan("target output directory does not exist");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const cssDir = join(outputDir, "assets/css");
|
|
608
|
+
if (!existsSync(cssDir)) {
|
|
609
|
+
skipScan("campaign assets/css directory does not exist");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const coreStylesheet = contract.css_load_order?.core_stylesheet || "next-core.css";
|
|
613
|
+
const campaignCssFiles = readdirSync(cssDir)
|
|
614
|
+
.filter((name) => name.endsWith(".css") && name !== coreStylesheet && name !== "brand-theme.css");
|
|
615
|
+
let hideCount = 0;
|
|
616
|
+
for (const name of campaignCssFiles) {
|
|
617
|
+
const cssPath = join(cssDir, name);
|
|
618
|
+
let cssText = "";
|
|
619
|
+
try {
|
|
620
|
+
cssText = readFileSync(cssPath, "utf8");
|
|
621
|
+
} catch {
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
for (const hit of findForbiddenPriceHides(contract, cssText)) {
|
|
625
|
+
hideCount += 1;
|
|
626
|
+
addIssue(
|
|
627
|
+
warnings,
|
|
628
|
+
"template_contract.price_css_hide",
|
|
629
|
+
`Campaign CSS ${name} hides a pricing surface: "${hit.selector}" sets display:none on ${hit.target}. Use the template's declared pricing modes instead of hiding price rows; browser QA blocks upsells with zero visible price rows.`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (hideCount === 0 && campaignCssFiles.length) {
|
|
634
|
+
ready.push(`Campaign CSS has no display:none rules on ${family} pricing surfaces`);
|
|
635
|
+
}
|
|
636
|
+
}
|