@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.
- package/CHANGELOG.md +126 -0
- package/README.md +5 -5
- package/agents/claude/CLAUDE.md +15 -0
- package/agents/codex/AGENTS.md +15 -0
- package/agents/copilot/copilot-instructions.md +15 -0
- package/agents/cursor/campaigns-os.mdc +15 -0
- package/compatibility.json +1 -1
- package/contracts/brand-theme-target-tokens.next-core.v0.json +95 -25
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -26
- package/contracts/release-ledger.json +768 -0
- package/contracts/supported-surface.json +4 -4
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +28 -1
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaigns-os-build-flow.md +61 -2
- package/docs/campaignspec-authoring-examples.md +116 -0
- package/docs/design-source-package.md +46 -2
- package/docs/entry-points.md +188 -0
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +40 -2
- package/docs/qa-and-test-orders.md +50 -2
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +119 -6
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +3 -1
- package/docs/skills-revision.md +10 -10
- package/docs/source-adapters.md +213 -0
- package/package.json +10 -3
- package/schemas/campaign-design-source-package.v0.schema.json +8 -0
- package/schemas/campaigns-os-readback.v2.schema.json +177 -1
- package/schemas/source-html-manifest.v0.schema.json +3 -0
- 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 -4
- package/skills/next-campaigns-os/SKILL.md +4 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +6 -9
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +117 -24
- package/skills.json +11 -11
- package/src/brand-theme.mjs +413 -8
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +9 -2
- package/src/built-site-scope.mjs +2 -2
- package/src/cli.mjs +52 -13
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +8 -74
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +18 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/parity/compare.mjs +316 -0
- package/src/parity/design-access.mjs +54 -0
- package/src/parity/element-parity-record.mjs +569 -0
- package/src/parity/probe.mjs +205 -0
- package/src/parity/snapshot.mjs +110 -0
- package/src/png-header.mjs +87 -0
- package/src/polish-browser.mjs +186 -9
- package/src/polish-captures-record.mjs +52 -0
- package/src/polish-deadline.mjs +14 -0
- package/src/polish-gate.mjs +25 -6
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +94 -5
- package/src/qa-node.mjs +39 -6
- package/src/qc-accept.mjs +148 -6
- package/src/qc-check-registry.mjs +37 -1
- package/src/qc-results.mjs +404 -16
- package/src/readback.mjs +57 -13
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +94 -4
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/stage-record.mjs +45 -7
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- 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
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
|
1176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|