@nextcommerce/campaigns-os 1.60.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +5 -5
  3. package/agents/claude/CLAUDE.md +15 -0
  4. package/agents/codex/AGENTS.md +15 -0
  5. package/agents/copilot/copilot-instructions.md +15 -0
  6. package/agents/cursor/campaigns-os.mdc +15 -0
  7. package/compatibility.json +1 -1
  8. package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
  9. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  10. package/contracts/commerce-surface-catalog.json +27 -26
  11. package/contracts/release-ledger.json +768 -0
  12. package/contracts/supported-surface.json +4 -4
  13. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  14. package/docs/access-model.md +20 -0
  15. package/docs/brand-theme-bridge.md +62 -3
  16. package/docs/build-packet.md +28 -1
  17. package/docs/campaign-build-brief.md +16 -0
  18. package/docs/campaigns-os-build-flow.md +61 -2
  19. package/docs/campaignspec-authoring-examples.md +116 -0
  20. package/docs/design-source-package.md +46 -2
  21. package/docs/entry-points.md +188 -0
  22. package/docs/local-setup.md +1 -1
  23. package/docs/orientation-contract-reference.md +1 -1
  24. package/docs/polish-evidence.md +40 -2
  25. package/docs/qa-and-test-orders.md +50 -2
  26. package/docs/quickstart.md +438 -0
  27. package/docs/readback.md +119 -6
  28. package/docs/runtime-readiness.md +1 -1
  29. package/docs/sdk-storage-compatibility.md +3 -1
  30. package/docs/skills-revision.md +10 -10
  31. package/docs/source-adapters.md +213 -0
  32. package/package.json +10 -3
  33. package/schemas/campaign-design-source-package.v0.schema.json +8 -0
  34. package/schemas/campaigns-os-readback.v2.schema.json +177 -1
  35. package/schemas/source-html-manifest.v0.schema.json +3 -0
  36. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  37. package/skills/campaign-readback-classification/SKILL.md +3 -3
  38. package/skills/campaign-run-evidence/SKILL.md +3 -3
  39. package/skills/contribution-intake/SKILL.md +3 -3
  40. package/skills/next-campaigns-build/SKILL.md +4 -4
  41. package/skills/next-campaigns-os/SKILL.md +4 -3
  42. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  43. package/skills/next-campaigns-polish/SKILL.md +6 -9
  44. package/skills/next-campaigns-qa/SKILL.md +3 -3
  45. package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
  46. package/skills.json +11 -11
  47. package/src/brand-theme.mjs +413 -8
  48. package/src/brief-summary.mjs +6 -2
  49. package/src/browser-launch.mjs +2 -1
  50. package/src/build-brief.mjs +9 -2
  51. package/src/built-site-scope.mjs +2 -2
  52. package/src/cli.mjs +52 -13
  53. package/src/design-file-reference.mjs +93 -0
  54. package/src/design-source-package.mjs +55 -1
  55. package/src/doctor/checks.mjs +8 -74
  56. package/src/doctor/ci.mjs +112 -0
  57. package/src/doctor/inspect.mjs +18 -0
  58. package/src/doctor/source-provenance.mjs +74 -0
  59. package/src/evidence-debt.mjs +119 -0
  60. package/src/parity/compare.mjs +316 -0
  61. package/src/parity/design-access.mjs +54 -0
  62. package/src/parity/element-parity-record.mjs +569 -0
  63. package/src/parity/probe.mjs +205 -0
  64. package/src/parity/snapshot.mjs +110 -0
  65. package/src/png-header.mjs +87 -0
  66. package/src/polish-browser.mjs +186 -9
  67. package/src/polish-captures-record.mjs +52 -0
  68. package/src/polish-deadline.mjs +14 -0
  69. package/src/polish-gate.mjs +25 -6
  70. package/src/polish-node.mjs +81 -7
  71. package/src/polish-visual-fixtures.mjs +74 -0
  72. package/src/polish-visual.mjs +606 -0
  73. package/src/qa-analytics-correctness.mjs +14 -2
  74. package/src/qa-analytics-parity.mjs +94 -5
  75. package/src/qa-node.mjs +39 -6
  76. package/src/qc-accept.mjs +148 -6
  77. package/src/qc-check-registry.mjs +37 -1
  78. package/src/qc-results.mjs +404 -16
  79. package/src/readback.mjs +57 -13
  80. package/src/sdk-attribute-index.mjs +11 -11
  81. package/src/sdk-markup.mjs +77 -2
  82. package/src/sdk-storage-compatibility.mjs +94 -4
  83. package/src/source-html-manifest.mjs +7 -0
  84. package/src/source-html-server.mjs +129 -0
  85. package/src/stage-record.mjs +45 -7
  86. package/src/theme-gate.mjs +2 -2
  87. package/src/visual-diff.mjs +296 -0
  88. package/src/visual-reference.mjs +126 -0
package/src/cli.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
2
+ import { withDoctorCiReport } from "./doctor/ci.mjs";
2
3
  import { createHash, randomUUID } from "node:crypto";
4
+ import { resolveDesignFileReferences } from "./design-file-reference.mjs";
3
5
  import { withDesignSourcePublication } from "./design-source-publication.mjs";
4
6
  import { createDemo, demoArguments } from "./demo.mjs";
5
7
  import { execFileSync } from "node:child_process";
@@ -320,7 +322,7 @@ Usage:
320
322
  [--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
321
323
  [--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
322
324
  [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
323
- campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
325
+ campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--ci] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. --ci: Markdown (or JSON ci object), exits 0 current / 3 owed / 4 unknown / 2 packet usage error; other doctor errors do not affect the CI exit. Without --ci: 0 ok / 2 errors. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
324
326
  campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet. Reads the local files only: --base-url is not fetched, it fills deploy.preview_url in the minimal packet (check served pages with qa run --site)
325
327
  campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
326
328
  campaigns-os sdk storage-check --target <git-root> --target-sdk <x.y.z> --manifest <SDK-manifest.json> --scope <dir,file> [--exclude <dir,file>] [--json]
@@ -351,7 +353,7 @@ Usage:
351
353
  campaigns-os logout [--store <subdomain>]
352
354
  campaigns-os tooling status [--platform <claude|codex|agents|all>] [--target <skills-dir>] [--skills-revision <bundle-revision|skill-id@version>] [--packet <campaign-runtime.build.json>] [--force] [--json] # install-mode, git, skill freshness, and local gateway login/store/expiry/reported version. --skills-revision checks the bundle revision the skill you loaded states on its first body line (or that one skill's <skill-id>@<version>) against the bundle THIS CLI ships: revision_check is match, mismatch or unchecked, and a mismatch prints the full status and exits 2 because skill text already in context cannot be refreshed by re-running — start a fresh session. The pin check reports one executable per project: the project pin first — the first exact spec for this package (x.y.z, =x.y.z or vx.y.z) on the walk up from the nearest package.json, devDependencies then dependencies in each, entering a workspace root and stopping there, never peerDependencies or optionalDependencies — then the Build Packet's campaigns_os_version (the project's campaign-runtime.build.json, or --packet <path>); the Pin: line names the key and manifest (or packet) each version came from; pin.status is match, stale_pin (the pin is not the running version), conflicting_pin (the two sources disagree) or unpinned (neither, or only a range; exit 0). stale_pin and conflicting_pin exit 2 with the file to change; --force (bare) overrides them, is reported as pin.forced and lands on the lifecycle journal entry. See docs/skills-revision.md
353
355
  campaigns-os tooling diagnose [--packet <packet>] [--platform <claude|codex|agents|all>] [--json] # read-only redacted support summary
354
- campaigns-os install-agent-context --target <page-kit-dir> [--dry-run]
356
+ campaigns-os install-agent-context --target <page-kit-dir> [--dry-run] [--json]
355
357
  campaigns-os next [${NEXT_STAGE_ORDER.join("|")}] --packet <json> [--no-write] [--no-remit] [--proxy-base <url>] [--json] # no stage self-decides; returns gates[] + next_actions[] alongside the prompt
356
358
  campaigns-os next setup --packet <json> [--context <json>] [--report <json>] [--json]
357
359
  campaigns-os next build --packet <json> [--context <json>] [--report <json>] [--json]
@@ -359,7 +361,7 @@ Usage:
359
361
  campaigns-os next deploy --packet <json> --report <json> [--json]
360
362
  campaigns-os next qa --packet <json> --report <json> [--json]
361
363
  campaigns-os qa resolve --packet <json> [--base-url <url>] [--no-probe] [--probe-timeout-ms <ms>] [--json] # probes the derived entry URLs; a dead route set reports routes_unresolved, an unprobed one ready_unprobed
362
- campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
364
+ campaigns-os qa run --packet <json> [--base-url <url>] [--currency <code>] [--browser] [--test-order <mode>] [--select-package <ref[:qty],...>] [--apply-coupon <code>] [--test-email <email>] [--test-country <code> --test-address1 <line> --test-city <city> --test-province <code> --test-postal <code>] [--no-post-verdict] [--no-remit] [--no-live-refs] [--output-dir <dir>] [--json]
363
365
  campaigns-os qa promote --packet <json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
364
366
  campaigns-os qa publish --packet <json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict (the sidecar's run, or --verdict) to the QA portal without a re-run or an order; refuses a stale spec_hash or an already-published verdict. --dry-run runs every one of those refusal checks and prints what would be posted (endpoint, verdict run id, payload bytes) without the POST
365
367
  campaigns-os qa policy set --packet <json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json] # --order-path-depth writes qa.proof_policy.order_path_depth and refreshes the assembly report's proof_policy mirror
@@ -484,14 +486,14 @@ export async function main(argv, { authentication, qcStandIns } = {}) {
484
486
  console.log(packageVersion());
485
487
  return;
486
488
  }
487
- return runInvocation(parseArgs(argv), {
489
+ return withDoctorCiReport(parseArgs(argv), (args) => runInvocation(args, {
488
490
  dispatch: (command, args, context) => dispatch(command, args, { ...context, argv, authentication, qcStandIns }),
489
491
  closeOutStaleRunSessions,
490
492
  ambientRunSession,
491
493
  lifecycleIdentity,
492
494
  persistLifecycle: persistLifecycleIfRequested,
493
495
  autoEndAfterQa: autoEndRunSessionAfterTerminalQa,
494
- });
496
+ }));
495
497
  }
496
498
 
497
499
  // The lifecycle entry's identity (T6): its argv shape and its run_id. Ambient
@@ -1049,8 +1051,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1049
1051
  const liveCampaign = await readDoctorLiveCampaign(args);
1050
1052
  const result = doctorCommand(args, { liveCampaign, qcStandIns });
1051
1053
  if (!result.ok) recorder.recordFindingCodes(result.errors?.map((issue) => issue.code));
1052
- writeResult(result, args, result.ok ? 0 : 2);
1053
- printDoctorTinyPrompt(result, args);
1054
+ if (args.ci === true) {
1055
+ const { formatDoctorCi, DOCTOR_CI_EXIT_CODES } = await import("./doctor/ci.mjs");
1056
+ if (!args.json) console.log(formatDoctorCi(result));
1057
+ else writeResult(result, args, 0);
1058
+ process.exitCode = result.ci?.exit_code ?? result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage;
1059
+ } else {
1060
+ writeResult(result, args, result.ok ? 0 : 2);
1061
+ printDoctorTinyPrompt(result, args);
1062
+ }
1054
1063
  return;
1055
1064
  }
1056
1065
 
@@ -1172,8 +1181,15 @@ async function dispatch(command, args, { recorder = NOOP_RECORDER, ambient = nul
1172
1181
 
1173
1182
  if (command === "install-agent-context") {
1174
1183
  const target = requireArg(args, "target");
1175
- const result = installAgentContext(resolve(target), Boolean(args["dry-run"]));
1176
- writeResult(result, args, 0);
1184
+ const dryRun = isDryRun(args);
1185
+ const result = installAgentContext(resolve(target), dryRun);
1186
+ if (dryRun && !args.json) {
1187
+ for (const file of result.file_contents) {
1188
+ console.log(`Destination: ${file.destination}\n${file.content}`);
1189
+ }
1190
+ } else {
1191
+ writeResult(result, args, 0);
1192
+ }
1177
1193
  return;
1178
1194
  }
1179
1195
 
@@ -1845,6 +1861,7 @@ function prepareBuildUnderLock({
1845
1861
  // campaign's report), and the previous brief carries field provenance.
1846
1862
  const priorBriefInputs = priorBriefInputsForIntake({ reportPath, briefPath, identity: { spec: { map_id: mapId, ...(localSpecId ? { local_spec_id: localSpecId } : {}) }, campaign: { public_route_slug: publicRouteSlug } } });
1847
1863
  const buildBrief = createCampaignBuildBriefArtifact({
1864
+ designFiles: resolveDesignFileReferences({ activePages, mappings: matched.mappings, manifest: manifestResult.manifest }).pages,
1848
1865
  inputPath: briefDiscovery?.path || null,
1849
1866
  inputSource: briefDiscovery?.source || null,
1850
1867
  spec,
@@ -2285,7 +2302,7 @@ function assemblyThemeFromContext(theme) {
2285
2302
  load_order: "unknown",
2286
2303
  commerce_pages: [],
2287
2304
  evidence: wroteCss
2288
- ? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact into campaign assets and load it after next-core.css on commerce pages."]
2305
+ ? ["prepare-build auto-generated .campaign-runtime/theme/brand-theme.css; build should copy that existing artifact (and brand-fonts.css, when written) into campaign assets and load it after next-core.css on commerce pages."]
2289
2306
  : theme.generated?.can_generate
2290
2307
  ? ["theme inspect found a generatable brand theme; run theme generate or explicit auto policy before applying."]
2291
2308
  : ["theme inspect completed; no generated brand theme was applied during prepare-build."],
@@ -2805,6 +2822,8 @@ export async function polishCaptureCommand(args, options = {}) {
2805
2822
  adapterCloseDeadlineMs: options.adapterCloseDeadlineMs,
2806
2823
  probeClock: options.probeClock,
2807
2824
  targetRepo,
2825
+ visual: { packetPath, targetRepo, ...options.visual },
2826
+ onFrozen: options.onFrozen,
2808
2827
  });
2809
2828
  // Nothing was measured: the held plan error stands.
2810
2829
  if (heldPlanError && !capture.readability) throw heldPlanError;
@@ -2834,6 +2853,8 @@ export async function polishCaptureCommand(args, options = {}) {
2834
2853
  pageLoad: capture.page_load,
2835
2854
  mediaWeight: capture.media_weight,
2836
2855
  readability: capture.readability,
2856
+ captures: capture.captures,
2857
+ elementParity: capture.element_parity,
2837
2858
  now: options.now ?? new Date(),
2838
2859
  });
2839
2860
  checkpoint = evaluateRecordedHiddenEagerMediaCheckpoint({
@@ -2879,6 +2900,15 @@ export async function polishCaptureCommand(args, options = {}) {
2879
2900
  route_scope: capture.plan?.route_scope ?? null,
2880
2901
  routes: (capture.plan?.routes ?? []).map((route) => route.requested_route),
2881
2902
  viewports: (capture.plan?.viewports ?? []).map((viewport) => viewport.key),
2903
+ visual: capture.captures ? { cells: capture.captures.cells.length,
2904
+ rendered: capture.captures.cells.filter((cell) => cell.status === "rendered").length,
2905
+ comparisons_compared: capture.captures.comparisons.filter((comparison) => comparison.status === "compared").length,
2906
+ comparisons_unsupported: capture.captures.comparisons.filter((comparison) => comparison.status === "unsupported").length,
2907
+ dependencies: capture.captures.dependencies, adapters: capture.captures.adapters,
2908
+ // A run budget spent: a cell never rendered, or a decode or diff never ran (§2.4).
2909
+ budget_exhausted: capture.captures.cells.some((cell) => cell.status === "run_budget_exhausted")
2910
+ || capture.captures.references.some((reference) => reference.verification?.reason === "verify_budget_exhausted")
2911
+ || capture.captures.comparisons.some((comparison) => comparison.reason === "diff_budget_exhausted") } : null,
2882
2912
  },
2883
2913
  };
2884
2914
  }
@@ -5071,7 +5101,7 @@ Rules:
5071
5101
  - Prepared AI/exported HTML must be converted into page-kit-ready source first: keep page-owned body markup, strip document wrappers, add YAML frontmatter, move shared CSS/assets into the campaign structure, and use Liquid helpers only for page-kit links/assets/includes. Page Kit publishes src/<slug>/assets/config.js as /<slug>/config.js and src/<slug>/assets/products/foo.png as /<slug>/products/foo.png; do not leave raw /assets/... or /<slug>/assets/... references in rendered pages.
5072
5102
  - Preserve prepared source HTML for landing/presell pages when it is a real standalone design.
5073
5103
  - For checkout/upsell/downsell/receipt, use the starter template as the SDK contract reference only: preserve required data-next controls and runtime wiring, but let the campaign/source own visual chrome, copy hierarchy, imagery, and brand layer.
5074
- - Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
5104
+ - Read context.theme and .campaign-runtime/theme/theme-report.json when present. If a fresh brand-theme.css exists, copy it into the campaign assets/css folder and list it after next-core.css in checkout/upsell/downsell/receipt frontmatter styles; when theme generate also wrote brand-fonts.css (the design's own faces), copy it beside brand-theme.css and list it on the same pages; if policy is inspect_only, generate or skip explicitly before applying a new brand layer.
5075
5105
  - Generated brand-theme.css v0 is root-variable-only. Do not edit SDK-owned selectors, package controls, payment fields, totals, submit controls, receipt templates, route meta tags, or SDK JavaScript as part of theme application.
5076
5106
  - If you copy starter-template files, copy the selected family atomically with dependent pages, _includes, _layouts, assets/css, and assets/js; do not copy only checkout.html and receipt.html.
5077
5107
  - Resolve SDK routing meta tags to campaign-root paths such as ${campaignRouteRoot(packet) || `/${packet.campaign.public_route_slug}/`}upsell/, not source filenames or unrooted spec literals.
@@ -5466,10 +5496,13 @@ function installAgentContext(targetRepo, dryRun = false) {
5466
5496
  ["copilot-instructions.md", join(ROOT, "agents/copilot/copilot-instructions.md")],
5467
5497
  ];
5468
5498
  const written = [];
5499
+ const fileContents = [];
5469
5500
  if (!dryRun) mkdirSync(outDir, { recursive: true });
5470
5501
  for (const [name, source] of files) {
5471
5502
  const dest = join(outDir, name);
5472
- if (!dryRun) writeFileSync(dest, readFileSync(source, "utf8"));
5503
+ const content = readFileSync(source, "utf8");
5504
+ if (!dryRun) writeFileSync(dest, content);
5505
+ else fileContents.push({ destination: dest, content });
5473
5506
  written.push(dest);
5474
5507
  }
5475
5508
  const gitignore = ensureRuntimeStateIgnored(targetRepo, { dryRun });
@@ -5479,6 +5512,7 @@ function installAgentContext(targetRepo, dryRun = false) {
5479
5512
  target_repo: targetRepo,
5480
5513
  directory: outDir,
5481
5514
  files: written,
5515
+ ...(dryRun ? { file_contents: fileContents } : {}),
5482
5516
  gitignore,
5483
5517
  note: "Context files are staged under .campaign-runtime/agent-context and do not overwrite root agent files.",
5484
5518
  };
@@ -6080,7 +6114,12 @@ function toolingCommand(args) {
6080
6114
  ? stalePlatforms.map((platform) => ["--platform", platform])
6081
6115
  : [["--platform", args.platform || "all"]];
6082
6116
  const commands = invocations.map((skillArgs) => `${cli.invocation_prefix} install-skills ${skillArgs.join(" ")}`);
6083
- actions.push(`Refresh installed skills: ${commands.join(" and ")}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
6117
+ // A per-platform refresh names the harnesses it is for, so a session in
6118
+ // another harness does not read it as its own required step (#683).
6119
+ const forHarnesses = !args.target && skillScope.scope === "installed_platforms" && stalePlatforms.length < SKILL_PLATFORMS.length
6120
+ ? ` (for ${stalePlatforms.map((id) => SKILL_PLATFORMS.find((platform) => platform.id === id)?.label || id).join(" and ")}; not needed in a session that uses another harness)`
6121
+ : "";
6122
+ actions.push(`Refresh installed skills: ${commands.join(" and ")}${forHarnesses}. ${SKILLS_READ_NOW_FOLLOW_UP}`);
6084
6123
  }
6085
6124
 
6086
6125
  if (install.mode === "checkout" && cli.global_binary.status === "not_found") {
@@ -0,0 +1,93 @@
1
+ import { collectFigmaProvenanceFindings } from "./doctor/source-provenance.mjs";
2
+ import { createHash } from "node:crypto";
3
+ import { canonicalJson } from "./polish-capture.mjs";
4
+
5
+ const VIEWPORTS = ["desktop", "tablet", "mobile"];
6
+ const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
7
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value);
8
+ const absoluteHttps = (value) => {
9
+ if (!nonEmpty(value)) return null;
10
+ try {
11
+ const url = new URL(value);
12
+ return url.protocol === "https:" ? url : null;
13
+ } catch { return null; }
14
+ };
15
+
16
+ // Declared links are data. No design-file reader or network access lives here.
17
+ export function normalizeDesignFileReference(value) {
18
+ const warnings = [];
19
+ const url = object(value) && absoluteHttps(value.file_url);
20
+ if (!url) return { declaration: null, warnings: ["file_url must be a non-empty absolute https: URL"] };
21
+ const type = nonEmpty(value.type) ? value.type.toLowerCase() : (url.hostname === "figma.com" || url.hostname.endsWith(".figma.com")) ? "figma" : "other";
22
+ let fileKey = null;
23
+ if (type === "figma") {
24
+ const parts = url.pathname.split("/");
25
+ const index = parts.findIndex((part) => ["file", "design", "proto", "board"].includes(part));
26
+ let candidate = index < 0 ? null : parts[index + 1];
27
+ const branch = parts.indexOf("branch", index + 2);
28
+ if (index >= 0 && branch >= 0 && /^[A-Za-z0-9]+$/.test(parts[branch + 1] ?? "")) candidate = parts[branch + 1];
29
+ if (typeof candidate === "string" && /^[A-Za-z0-9]+$/.test(candidate)) fileKey = candidate;
30
+ }
31
+ const identity = fileKey == null ? { type, url: url.origin + url.pathname } : { type, file_key: fileKey };
32
+ const file_identity_sha256 = createHash("sha256").update(canonicalJson(identity)).digest("hex");
33
+ const frames = {};
34
+ for (const viewport of VIEWPORTS) {
35
+ if (!object(value.breakpoints) || !Object.hasOwn(value.breakpoints, viewport)) continue;
36
+ const link = value.breakpoints[viewport];
37
+ let frame;
38
+ try { frame = nonEmpty(link) ? new URL(link) : null; } catch { frame = null; }
39
+ if (!frame) { warnings.push(`breakpoints.${viewport} must be an absolute frame URL`); continue; }
40
+ frames[viewport] = { url: link, node_id: frame.searchParams.get("node-id")?.replaceAll("-", ":") ?? null };
41
+ }
42
+ return { declaration: { type, file_url: value.file_url, file_key: fileKey, identity, file_identity_sha256, frames }, warnings };
43
+ }
44
+
45
+ export function resolveDesignFileReferences({ activePages = [], mappings = [], manifest = null, specChannel = { mode: "intake" } } = {}) {
46
+ const pages = [];
47
+ const files = new Map();
48
+ // Intake records only Figma spec pointers backed by #534's provenance test.
49
+ // Doctor first uses today's intake declaration. Only a page with recorded
50
+ // design-file lineage can fall back to its Figma spec without provenance.
51
+ // Manifest declarations stand alone; a shared file's producer is not per-page.
52
+ const exportProvenance = manifest != null && !collectFigmaProvenanceFindings(manifest).some((finding) => finding.severity === "provenance");
53
+ const producer = manifest?.producer_provenance;
54
+ const producerKeys = new Set([producer?.figma_file_key, ...(Array.isArray(producer?.figma_file_keys) ? producer.figma_file_keys : [])].filter(nonEmpty));
55
+ for (const page of activePages) {
56
+ const mapping = mappings.find((entry) => entry?.page_id === page.id);
57
+ if (!nonEmpty(mapping?.path)) continue;
58
+ const matches = (Array.isArray(manifest?.pages) ? manifest.pages : []).filter((entry) => entry?.page_id === page.id && entry.path === mapping.path);
59
+ const manifestValue = matches.length === 1 ? matches[0].design_file : null;
60
+ const specDeclaration = normalizeDesignFileReference(page.design_source).declaration;
61
+ const fromManifest = normalizeDesignFileReference(manifestValue);
62
+ const intakeSpec = specDeclaration?.type === "figma" && exportProvenance ? specDeclaration : null;
63
+ const fallbackSpec = !intakeSpec && !fromManifest.declaration
64
+ && specChannel.mode === "doctor" && specChannel.recordedPageIds?.has(page.id)
65
+ && specDeclaration?.type === "figma" ? specDeclaration : null;
66
+ const fromSpec = intakeSpec || fallbackSpec;
67
+ const declaration = fromSpec || fromManifest.declaration;
68
+ if (!declaration) continue;
69
+ const declared_by = fromSpec ? "campaign_spec" : "source_html_manifest";
70
+ let conflict = null;
71
+ // Provenance decides which declaration records lineage, never whether a
72
+ // Figma spec that disagrees with the manifest is reported.
73
+ const specNamed = fromSpec || (specDeclaration?.type === "figma" ? specDeclaration : null);
74
+ if (specNamed && fromManifest.declaration) {
75
+ const other = fromManifest.declaration;
76
+ const frames = VIEWPORTS.filter((viewport) => specNamed.frames[viewport] && other.frames[viewport] && specNamed.frames[viewport].node_id !== other.frames[viewport].node_id).sort();
77
+ if (specNamed.file_identity_sha256 !== other.file_identity_sha256 || frames.length) {
78
+ conflict = { kind: "spec_vs_manifest", identities: [specNamed.file_identity_sha256, other.file_identity_sha256].sort(), ...(frames.length ? { frames } : {}) };
79
+ }
80
+ }
81
+ if (!conflict && declaration.type === "figma" && declaration.file_key && producerKeys.size && !producerKeys.has(declaration.file_key)) {
82
+ conflict = { kind: "producer_file_key", identities: [declaration.file_identity_sha256] };
83
+ }
84
+ pages.push({ page_id: page.id, declaration, declared_by, conflict, warnings: manifestValue == null ? [] : fromManifest.warnings });
85
+ const sha = declaration.file_identity_sha256;
86
+ if (!files.has(sha)) files.set(sha, { contribution_id: `design-file-${sha.slice(0, 12)}`, identity: declaration.identity, file_url: declaration.file_url, producer: declared_by, pages: [] });
87
+ const file = files.get(sha);
88
+ // A shared file keeps the manifest's provenance if any page declared it there.
89
+ if (declared_by === "source_html_manifest") file.producer = declared_by;
90
+ file.pages.push(page.id);
91
+ }
92
+ return { pages, files: [...files.values()] };
93
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { resolveDesignFileReferences } from "./design-file-reference.mjs";
2
3
 
3
4
  export const DESIGN_SOURCE_PACKAGE_SCHEMA = "campaign-design-source-package/v0";
4
5
  export const DESIGN_SOURCE_PACKAGE_REL_PATH = ".campaign-runtime/input/design-source-package.json";
@@ -166,7 +167,7 @@ const PRESENTATION_INTENT_FIELDS = new Set([
166
167
  "brand",
167
168
  "responsive_behavior",
168
169
  ]);
169
- const SOURCE_REF_FIELDS = new Set(["id", "kind", "path", "url", "sha256", "role", "media_type", "notes"]);
170
+ const SOURCE_REF_FIELDS = new Set(["id", "kind", "path", "url", "sha256", "role", "media_type", "notes", "design_file_type"]);
170
171
  const VISUAL_REF_FIELDS = new Set([
171
172
  "id",
172
173
  "kind",
@@ -195,6 +196,7 @@ const TEMPLATE_REFERENCE_FIELDS = new Set([
195
196
  "standard_viewport_refs",
196
197
  ]);
197
198
  const COVERAGE_MAPPING_FIELDS = new Set([
199
+ "derived_from",
198
200
  "id",
199
201
  "surface_id",
200
202
  "coverage_role",
@@ -337,6 +339,7 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
337
339
  });
338
340
 
339
341
  const manifestInput = normalizeManifestInput(manifest, manifestPath);
342
+ const designFiles = resolveDesignFileReferences({ activePages, mappings: resolvedMappings, manifest: manifestInput.value });
340
343
  const crawl = isObject(sourceAssetCrawl) ? sourceAssetCrawl : isObject(assetCrawl) ? assetCrawl : null;
341
344
  const crawlInputName = isObject(sourceAssetCrawl) ? "sourceAssetCrawl" : "assetCrawl";
342
345
  assertOptionalInputArray(manifestInput.value?.files, "manifest.files");
@@ -480,6 +483,9 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
480
483
  htmlCoverage.push({
481
484
  id: `html-${surfaceId}-coverage`,
482
485
  surface_id: surfaceId,
486
+ ...(designFiles.pages.some((entry) => entry.page_id === page.pageId) ? {
487
+ derived_from: designFiles.files.find((file) => file.pages.includes(page.pageId)).contribution_id,
488
+ } : {}),
483
489
  coverage_role: explicitRole || "primary_design",
484
490
  confidence: normalizeConfidence(mapping?.confidence)
485
491
  || confidenceForHtmlMapping(mapping, manifestPage),
@@ -525,6 +531,37 @@ export function synthesizeHtmlFunnelDesignSourcePackage({
525
531
  };
526
532
 
527
533
  const contributions = [htmlContribution];
534
+ for (const file of [...designFiles.files].sort((a, b) => a.contribution_id.localeCompare(b.contribution_id))) {
535
+ const id = file.contribution_id;
536
+ // kind collapses non-Figma tools to "other". Use a new field so existing
537
+ // source-reference notes remain nonmaterial, including design_file refs.
538
+ const sourceRefs = [{ id: `${id}-file`, kind: "url", url: file.file_url, role: "design_file",
539
+ ...(!["figma", "other"].includes(file.identity.type) ? { design_file_type: file.identity.type } : {}),
540
+ }];
541
+ const mappings = file.pages.map((pageId) => {
542
+ const surfaceId = surfaceByPageId.get(pageId);
543
+ const declaration = designFiles.pages.find((page) => page.page_id === pageId).declaration;
544
+ const frameRefs = Object.entries(declaration.frames).map(([viewport, frame]) => ({
545
+ id: `${id}-${surfaceId}-frame-${viewport}`, kind: "url", url: frame.url, role: `design_frame:${viewport}`,
546
+ }));
547
+ sourceRefs.push(...frameRefs);
548
+ return { id: `${id}-${surfaceId}-coverage`, surface_id: surfaceId, coverage_role: "reference_only", confidence: "high", source_refs: [`${id}-file`, ...frameRefs.map((ref) => ref.id)], screenshot_refs: [], reference_refs: [] };
549
+ });
550
+ const fromManifest = file.producer === "source_html_manifest";
551
+ contributions.push({
552
+ id, kind: file.identity.type === "figma" ? "figma_frames" : "other", trust: file.identity.type === "figma" ? "native" : "opaque", renderable: false,
553
+ provenance: {
554
+ source_type: "design_file", adapter: "design-file-reference", producer: file.producer,
555
+ generator: null, generator_version: null, source_root: null,
556
+ manifest_schema_version: fromManifest ? optionalString(manifestInput.value?.schema_version) : null,
557
+ manifest_path: fromManifest ? optionalString(manifestInput.path) : null,
558
+ manifest_sha256: fromManifest ? validSha(manifestInput.value?.sha256) : null,
559
+ producer_material_fingerprint: null, asset_crawl_schema_version: null,
560
+ },
561
+ presentation_intent: { summary: "Design file the page's HTML export was produced from. Recorded as the design authority; this version does not read it." },
562
+ source_refs: sourceRefs, screenshot_refs: [], reference_refs: [], mappings,
563
+ });
564
+ }
528
565
  const template = normalizeTemplateFamily(templateFamily);
529
566
  const qualifyingHtmlSurfaces = new Set(
530
567
  htmlCoverage
@@ -998,6 +1035,15 @@ export function validateDesignSourcePackage(value, {
998
1035
  if (contributionIds.has(contribution.id)) add("design_source_package.contribution_id_duplicate", `${path}.id`, `Duplicate contribution id "${contribution.id}".`);
999
1036
  contributionIds.add(contribution.id);
1000
1037
  });
1038
+ contributions.forEach((contribution, index) => {
1039
+ arrayOrEmpty(contribution?.mappings).forEach((mapping, mappingIndex) => {
1040
+ if (!Object.hasOwn(mapping || {}, "derived_from")) return;
1041
+ const target = contributions.find((entry) => entry?.id === mapping.derived_from && entry !== contribution);
1042
+ if (!target || !["figma_frames", "figma_sections", "other"].includes(target.kind) || !arrayOrEmpty(target.mappings).some((entry) => entry?.surface_id === mapping.surface_id)) {
1043
+ add("design_source_package.derived_from_ref", `$.contributions[${index}].mappings[${mappingIndex}].derived_from`, "derived_from must name another design contribution mapped to the same surface_id.");
1044
+ }
1045
+ });
1046
+ });
1001
1047
 
1002
1048
  validateRecordArray(value.source_gaps, "$.source_gaps", GAP_FIELDS,
1003
1049
  ["id", "kind", "scope", "applies_to", "reason", "status", "attributed_by"], add,
@@ -1394,6 +1440,8 @@ function projectContribution(contribution) {
1394
1440
  function projectSourceRef(ref) {
1395
1441
  return compactDefined({
1396
1442
  ...pickFields(ref, ["id", "kind", "path", "url", "role", "media_type"]),
1443
+ // Absent from every BASE-valid package; never project existing prose notes.
1444
+ design_file_type: ref?.design_file_type,
1397
1445
  sha256: canonicalHashSpelling(ref?.sha256),
1398
1446
  });
1399
1447
  }
@@ -1451,6 +1499,7 @@ function projectProvenance(provenance) {
1451
1499
 
1452
1500
  function projectCoverageMapping(mapping) {
1453
1501
  return compactDefined({
1502
+ derived_from: mapping?.derived_from,
1454
1503
  id: mapping?.id,
1455
1504
  surface_id: mapping?.surface_id,
1456
1505
  coverage_role: mapping?.coverage_role,
@@ -2252,6 +2301,10 @@ function validateSourceReference(value, path, add) {
2252
2301
  if (!checkStrictObject(value, path, SOURCE_REF_FIELDS, ["id", "kind"], add)) return;
2253
2302
  checkNonEmptyString(value.id, `${path}.id`, add);
2254
2303
  checkEnum(value.kind, SOURCE_REF_KINDS, `${path}.kind`, add);
2304
+ if (Object.hasOwn(value, "design_file_type")) {
2305
+ checkNonEmptyString(value.design_file_type, `${path}.design_file_type`, add);
2306
+ if (value.role !== "design_file") add("design_source_package.design_file_type", `${path}.design_file_type`, "design_file_type requires role design_file.");
2307
+ }
2255
2308
  if (!isNonEmptyString(value.path) && !isNonEmptyString(value.url)) add("design_source_package.source_ref_location", path, "Source reference requires path or url.");
2256
2309
  for (const field of ["path", "url", "role", "media_type"]) if (value[field] != null) checkNonEmptyString(value[field], `${path}.${field}`, add);
2257
2310
  if (value.sha256 != null && !SHA256_PATTERN.test(value.sha256)) add("design_source_package.sha256", `${path}.sha256`, "sha256 must be 64 lowercase hex, with optional sha256: prefix.");
@@ -2293,6 +2346,7 @@ function validateCoverageMapping(value, path, add) {
2293
2346
  "id", "surface_id", "coverage_role", "confidence", "source_refs", "screenshot_refs", "reference_refs",
2294
2347
  ], add)) return false;
2295
2348
  checkNonEmptyString(value.id, `${path}.id`, add);
2349
+ if (Object.hasOwn(value, "derived_from")) checkNonEmptyString(value.derived_from, `${path}.derived_from`, add);
2296
2350
  checkNonEmptyString(value.surface_id, `${path}.surface_id`, add);
2297
2351
  checkEnum(value.coverage_role, new Set(COVERAGE_ROLES), `${path}.coverage_role`, add);
2298
2352
  checkEnum(value.confidence, new Set(MAPPING_CONFIDENCE_LEVELS), `${path}.confidence`, add);
@@ -23,7 +23,6 @@ import {
23
23
  } from "../source-html-intake.mjs";
24
24
  import {
25
25
  readSourceHtmlManifestFile,
26
- SOURCE_HASH_PATTERN,
27
26
  SOURCE_HTML_MANIFEST_SCHEMA,
28
27
  } from "../source-html-manifest.mjs";
29
28
  import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
@@ -89,7 +88,8 @@ import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSynta
89
88
  import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
90
89
  import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
91
90
  import { recordQcResults } from "../qc-results.mjs";
92
- import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
91
+ import { evaluateDesignAccess } from "../parity/design-access.mjs";
92
+ import { collectFigmaProvenanceFindings, FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
93
93
  import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
94
94
  import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
95
95
  import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
@@ -3674,6 +3674,12 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
3674
3674
  derived,
3675
3675
  });
3676
3676
  const active = activeSpecPages(spec);
3677
+ const designPackagePath = resolveFromFile(packetPath, packet?.design_source_package?.path);
3678
+ let designSourcePackage = null;
3679
+ try { if (designPackagePath) designSourcePackage = JSON.parse(readFileSync(designPackagePath, "utf8")); } catch { /* Missing or unreadable package is checked separately. */ }
3680
+ const designManifest = isNonEmptyString(sourceRoot) && existsSync(sourceRoot) && statSync(sourceRoot).isDirectory()
3681
+ ? readSourceHtmlManifestFile(sourceRoot, { manifestPath: recordedDesignManifestPath(packet, packetPath) }).manifest : null;
3682
+ recordQcResults({ derived, warnings, results: evaluateDesignAccess({ activePages: active, mappings: pages, manifest: designManifest, designSourcePackage, measured_at: new Date().toISOString() }) });
3677
3683
  const specPartialScope = spec?.build_scope?.mode === "partial";
3678
3684
  const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
3679
3685
  const activeIds = new Set(active.map((page) => page.id));
@@ -3988,78 +3994,6 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
3988
3994
  }
3989
3995
  }
3990
3996
 
3991
- // Returns every Figma-provenance finding in emission order, each tagged with
3992
- // its severity: "provenance" (the waivable source_html.producer_provenance*
3993
- // and source_html.files.partial/asset blockers) or "warning".
3994
- function collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) {
3995
- const findings = [];
3996
- const add = (severity, code, message) => findings.push({ severity, code, message });
3997
- if (!rawProvenance) {
3998
- add(
3999
- "provenance",
4000
- "source_html.producer_provenance",
4001
- "Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
4002
- );
4003
- }
4004
-
4005
- if (provenance.source_type !== "semantic_figma_export") {
4006
- add(
4007
- "provenance",
4008
- "source_html.producer_provenance.source_type",
4009
- `Figma source manifest source_type is "${provenance.source_type || "missing"}"; expected "semantic_figma_export". Screenshot or hand-authored fallback output cannot satisfy the Figma provenance gate.`
4010
- );
4011
- }
4012
- if (provenance.screenshot_fallback_used !== false) {
4013
- add(
4014
- "provenance",
4015
- "source_html.producer_provenance.screenshot_fallback_used",
4016
- "Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
4017
- );
4018
- }
4019
- if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
4020
- add(
4021
- "provenance",
4022
- "source_html.producer_provenance.semantic_section_count",
4023
- "Figma source manifest must report semantic_section_count > 0."
4024
- );
4025
- }
4026
- if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
4027
- add(
4028
- "provenance",
4029
- "source_html.producer_provenance.material_fingerprint",
4030
- "Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
4031
- );
4032
- }
4033
-
4034
- const files = Array.isArray(manifest?.files) ? manifest.files : [];
4035
- if (!files.some((entry) => entry.role === "partial")) {
4036
- add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
4037
- }
4038
- if (!files.some((entry) => entry.role === "asset")) {
4039
- add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
4040
- }
4041
-
4042
- const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
4043
- if (!sectionExports.length) {
4044
- add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
4045
- } else {
4046
- const withoutNodeIds = sectionExports
4047
- .filter((entry) => {
4048
- const nodeIds = isObject(entry?.node_ids) ? entry.node_ids : null;
4049
- return !nodeIds || !Object.keys(nodeIds).length;
4050
- })
4051
- .map((entry) => entry?.section || "unknown");
4052
- if (withoutNodeIds.length) {
4053
- add(
4054
- "warning",
4055
- "source_html.producer_provenance.section_exports.node_ids",
4056
- `Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
4057
- );
4058
- }
4059
- }
4060
-
4061
- return findings;
4062
- }
4063
3997
 
4064
3998
  function hasFigmaDesignSource(page) {
4065
3999
  const designSource = page && isObject(page.design_source) ? page.design_source : null;
@@ -0,0 +1,112 @@
1
+ import { assessEvidenceDebt } from "../evidence-debt.mjs";
2
+
3
+ export const DOCTOR_CI_EXIT_CODES = Object.freeze({ current: 0, owed: 3, unknown: 4, usage: 2 });
4
+
5
+ export function assessDoctorCi(inputs) {
6
+ const debt = assessEvidenceDebt(inputs);
7
+ const stages = [debt.polish, debt.qa];
8
+ // Any known debt is owed; otherwise incomplete evidence remains unknown.
9
+ const status = stages.some((stage) => stage.status === "owed") ? "owed"
10
+ : stages.some((stage) => stage.status === "unknown") ? "unknown" : "current";
11
+ return { status, exit_code: DOCTOR_CI_EXIT_CODES[status], ...debt };
12
+ }
13
+
14
+ // Values can come from campaign files and will be rendered in a PR comment.
15
+ // Encode punctuation as well as HTML: diagnostics are plain text, and may
16
+ // contain Markdown, autolinks, code fences or line breaks from campaign files.
17
+ const text = (value) => String(value).replace(/\s/g, " ")
18
+ .replace(/[!-/:-@\[-`{-~]/g, (char) => `&#${char.charCodeAt(0)};`);
19
+ // Inline code already protects ordinary path punctuation; preserve those
20
+ // bytes for readable page paths and issue codes, while preventing breakouts.
21
+ const code = (value) => `\`${String(value).replace(/\s/g, " ")
22
+ .replace(/[!-/:-@\[-`{-~]/g, (char) => "./_-".includes(char) ? char : `&#${char.charCodeAt(0)};`)}\``;
23
+
24
+ const unavailablePageChanges = new Map([
25
+ ["packet_unavailable", "the build packet or its path is unavailable."],
26
+ ["build_output_location_unknown", "the built output location cannot be determined."],
27
+ ["build_output_missing", "the built output is missing."],
28
+ ["build_output_unreadable", "the built output could not be read."],
29
+ ["assembly_build_fingerprint_missing", "the assembly build fingerprint is missing."],
30
+ ["build_output_pages_unresolved", "the built output could not be mapped to pages."],
31
+ ["build_manifest_unavailable", "no usable assembly manifest is available."],
32
+ ]);
33
+
34
+ const requiredStageReasons = new Map([
35
+ ["stage_required_by_build", "the build requires this stage again"],
36
+ ["stage_required_by_brief", "the brief requires this stage again"],
37
+ ["stage_required_by_spec", "the spec requires this stage again"],
38
+ ["stage_required", "the lifecycle requires this stage again"],
39
+ ]);
40
+
41
+ export function doctorCiFailure(error, { usage = false } = {}) {
42
+ return {
43
+ ok: false, status: "blocked",
44
+ errors: [{ code: usage ? "doctor.ci.packet" : "doctor.ci.failed", message: String(error?.message ?? error) }],
45
+ warnings: [], exit_code: usage ? DOCTOR_CI_EXIT_CODES.usage : DOCTOR_CI_EXIT_CODES.unknown,
46
+ };
47
+ }
48
+
49
+ // Presentation boundary only: invocation policy still decides session and
50
+ // lifecycle behavior. Setup can throw before doctor's dispatch branch runs.
51
+ export async function withDoctorCiReport(args, invoke) {
52
+ const command = args._[0] === "campaigns-os" ? args._[1] : args._[0];
53
+ try {
54
+ return await invoke(args);
55
+ } catch (error) {
56
+ if (command !== "doctor" || args.ci !== true) throw error;
57
+ const result = doctorCiFailure(error);
58
+ console.log(args.json ? JSON.stringify(result, null, 2) : formatDoctorCi(result));
59
+ process.exitCode = result.exit_code;
60
+ }
61
+ }
62
+
63
+ export function formatDoctorCi(result) {
64
+ const ci = result.ci;
65
+ if (!ci) {
66
+ return [
67
+ `## Campaign evidence: could not evaluate (exit ${result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage})`, "",
68
+ ...(result.errors || []).map((issue) => `- ${code(issue.code)}: ${text(issue.message)}`), "",
69
+ "Remedy: correct the packet or inspection failure and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`.",
70
+ ].join("\n");
71
+ }
72
+ const lines = [`## Campaign evidence: ${text(ci.status)} (exit ${ci.exit_code})`, ""];
73
+ const owed = ["polish", "qa"].filter((stage) => ci[stage].status === "owed");
74
+ lines.push(`Owed stages: ${owed.length ? owed.join(", ") : "none"}.`);
75
+ for (const stage of owed) {
76
+ const reason = ci[stage].reason === "build_fingerprint_changed"
77
+ ? "the recorded build fingerprint differs from the current build"
78
+ : requiredStageReasons.get(ci[stage].reason) ?? "the recorded stage evidence is owed";
79
+ lines.push(`- Owed ${stage}: ${reason}.`);
80
+ }
81
+ for (const page of ci.changes.changed_pages) {
82
+ lines.push(`- Changed page: ${code(page.page_id)} (${code(page.built_path)})`);
83
+ }
84
+ if (!ci.changes.changed_pages.length && (owed.length || ci.changes.status === "unknown")) {
85
+ lines.push(ci.changes.status === "unknown"
86
+ ? `Changed pages unavailable: ${unavailablePageChanges.get(ci.changes.reason) ?? "the page-change reason is unrecognized."}`
87
+ : "Changed page ids unavailable: the retained assembly manifest may already describe the rebuilt output.");
88
+ }
89
+ for (const stage of ["polish", "qa"]) {
90
+ if (ci[stage].status === "unknown") lines.push(`- Unknown ${stage}: ${code(ci[stage].reason)}`);
91
+ }
92
+ if (ci.changes.status === "unknown") lines.push(`- Unknown page changes: ${code(ci.changes.reason)}`);
93
+ if (ci.status === "owed" || ci.status === "unknown") {
94
+ const matchingRequired = owed.length > 0
95
+ && owed.every((stage) => requiredStageReasons.has(ci[stage].reason) && ci[stage].matches_current_build === true)
96
+ && ["polish", "qa"].every((stage) => ci[stage].status !== "unknown");
97
+ if (matchingRequired) {
98
+ const commands = [];
99
+ if (owed.includes("polish")) commands.push("`campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route", "`campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`");
100
+ if (owed.includes("qa")) commands.push("`campaigns-os qa run --packet <packet>`");
101
+ lines.push("", `Remedy: re-run the required stages against the existing build: ${commands.join(", then ")}.`);
102
+ } else {
103
+ lines.push("", "Remedy: rebuild, then run `campaigns-os record build --packet <packet>`, `campaigns-os polish capture --packet <packet> --base-url <served-current-build-url>` for the whole route, and `campaigns-os record polish --packet <packet> --evidence <polish-evidence.json>`, in that order. When QA is owed, run `campaigns-os qa run --packet <packet>` against that build.");
104
+ }
105
+ }
106
+ lines.push("", `Doctor inspection: ${(result.errors || []).length} error(s), ${(result.warnings || []).length} warning(s); CI exit status depends only on evidence debt.`);
107
+ if (result.errors?.length) {
108
+ lines.push(`Doctor errors: ${[...new Set(result.errors.map((issue) => issue.code))].map(code).join(", ")}.`);
109
+ }
110
+ lines.push("Use ordinary `doctor` or `--ci --json` for full inspection details.");
111
+ return lines.join("\n");
112
+ }