@nextcommerce/campaigns-os 1.59.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 +242 -0
- package/README.md +11 -10
- 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 +1311 -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 +42 -13
- package/docs/campaign-build-brief.md +17 -1
- package/docs/campaigns-os-build-flow.md +62 -3
- 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 +41 -3
- 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 +5 -5
- 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 +12 -16
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills/next-campaigns-sdk-bump/SKILL.md +286 -0
- package/skills.json +18 -10
- 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 +7 -14
- package/src/campaign-identity.mjs +6 -4
- package/src/cli.mjs +89 -73
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +55 -1
- package/src/doctor/checks.mjs +14 -77
- package/src/doctor/ci.mjs +112 -0
- package/src/doctor/inspect.mjs +20 -8
- package/src/doctor/next-step.mjs +6 -3
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/evidence-debt.mjs +119 -0
- package/src/orchestration-stage-contract.mjs +6 -3
- package/src/page-kit-build-summary.mjs +21 -1
- package/src/page-kit-campaign-config.mjs +19 -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 +26 -11
- package/src/polish-node.mjs +81 -7
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +606 -0
- package/src/progress-node.mjs +2 -1
- 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/spec-derive.mjs +24 -9
- package/src/stage-ledger.mjs +27 -1
- package/src/stage-record.mjs +63 -9
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
|
@@ -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";
|
|
@@ -99,7 +99,7 @@ import {
|
|
|
99
99
|
assessAssemblySourcePackageFreshnessWaivers,
|
|
100
100
|
currentBuildFingerprint,
|
|
101
101
|
} from "../polish-gate.mjs";
|
|
102
|
-
import { loadPageKitCampaignEntry, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
102
|
+
import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
103
103
|
import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
|
|
104
104
|
import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
|
|
105
105
|
// ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
|
|
@@ -3039,7 +3039,7 @@ function builtHtmlPathForPage(targetRepo, publicRouteSlug, page, derived = {}) {
|
|
|
3039
3039
|
mapping: derived?.page_kit_entry_mappings?.[page?.id],
|
|
3040
3040
|
});
|
|
3041
3041
|
if (entryRoute) return join(targetRepo, "_site", publicRouteSlug, ...(entryRoute.route ? [entryRoute.route] : []), "index.html");
|
|
3042
|
-
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page);
|
|
3042
|
+
const sourcePermalink = sourcePermalinkForPage(derived?.target_output_dir, publicRouteSlug, page, { assetsHtmlIgnored: pageKitIgnoresAssetsHtml(targetRepo) });
|
|
3043
3043
|
const route = sourcePermalink || runtimeRelativeRouteForSpecValue(publicRouteForPage(page), publicRouteSlug);
|
|
3044
3044
|
if (!route) return join(targetRepo, "_site", publicRouteSlug, "index.html");
|
|
3045
3045
|
const clean = route.replace(/^\/+|\/+$/g, "");
|
|
@@ -3103,13 +3103,16 @@ export function entryPageKitFileRoute(targetRepo, publicRouteSlug, page, { targe
|
|
|
3103
3103
|
return routes[0] ?? null;
|
|
3104
3104
|
}
|
|
3105
3105
|
|
|
3106
|
-
|
|
3106
|
+
// page-kit 0.3.0 renders no .html under the campaign's top-level assets/, so
|
|
3107
|
+
// a permalink stated there is no route on that version (assetsHtmlIgnored).
|
|
3108
|
+
function sourcePermalinkForPage(targetOutputDir, publicRouteSlug, page, { assetsHtmlIgnored = false } = {}) {
|
|
3107
3109
|
if (!targetOutputDir || !existsSync(targetOutputDir) || !statSync(targetOutputDir).isDirectory()) return null;
|
|
3108
3110
|
|
|
3109
3111
|
const expectedTerminal = terminalRouteSegment(publicRouteForPage(page));
|
|
3110
3112
|
const candidates = [];
|
|
3111
3113
|
for (const file of collectHtmlFiles(targetOutputDir)) {
|
|
3112
3114
|
if (file.path.includes("_includes/") || file.path.includes("_layouts/")) continue;
|
|
3115
|
+
if (assetsHtmlIgnored && file.path.split(sep)[0] === "assets") continue;
|
|
3113
3116
|
const fullPath = join(targetOutputDir, file.path);
|
|
3114
3117
|
const content = readFileSync(fullPath, "utf8");
|
|
3115
3118
|
const permalink = extractFrontmatterValue(content, "permalink");
|
|
@@ -3671,6 +3674,12 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3671
3674
|
derived,
|
|
3672
3675
|
});
|
|
3673
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() }) });
|
|
3674
3683
|
const specPartialScope = spec?.build_scope?.mode === "partial";
|
|
3675
3684
|
const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
|
|
3676
3685
|
const activeIds = new Set(active.map((page) => page.id));
|
|
@@ -3985,78 +3994,6 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
|
|
|
3985
3994
|
}
|
|
3986
3995
|
}
|
|
3987
3996
|
|
|
3988
|
-
// Returns every Figma-provenance finding in emission order, each tagged with
|
|
3989
|
-
// its severity: "provenance" (the waivable source_html.producer_provenance*
|
|
3990
|
-
// and source_html.files.partial/asset blockers) or "warning".
|
|
3991
|
-
function collectFigmaProvenanceFindings(manifest, provenance, rawProvenance) {
|
|
3992
|
-
const findings = [];
|
|
3993
|
-
const add = (severity, code, message) => findings.push({ severity, code, message });
|
|
3994
|
-
if (!rawProvenance) {
|
|
3995
|
-
add(
|
|
3996
|
-
"provenance",
|
|
3997
|
-
"source_html.producer_provenance",
|
|
3998
|
-
"Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
|
|
3999
|
-
);
|
|
4000
|
-
}
|
|
4001
|
-
|
|
4002
|
-
if (provenance.source_type !== "semantic_figma_export") {
|
|
4003
|
-
add(
|
|
4004
|
-
"provenance",
|
|
4005
|
-
"source_html.producer_provenance.source_type",
|
|
4006
|
-
`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.`
|
|
4007
|
-
);
|
|
4008
|
-
}
|
|
4009
|
-
if (provenance.screenshot_fallback_used !== false) {
|
|
4010
|
-
add(
|
|
4011
|
-
"provenance",
|
|
4012
|
-
"source_html.producer_provenance.screenshot_fallback_used",
|
|
4013
|
-
"Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
|
|
4014
|
-
);
|
|
4015
|
-
}
|
|
4016
|
-
if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
|
|
4017
|
-
add(
|
|
4018
|
-
"provenance",
|
|
4019
|
-
"source_html.producer_provenance.semantic_section_count",
|
|
4020
|
-
"Figma source manifest must report semantic_section_count > 0."
|
|
4021
|
-
);
|
|
4022
|
-
}
|
|
4023
|
-
if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
|
|
4024
|
-
add(
|
|
4025
|
-
"provenance",
|
|
4026
|
-
"source_html.producer_provenance.material_fingerprint",
|
|
4027
|
-
"Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
|
|
4028
|
-
);
|
|
4029
|
-
}
|
|
4030
|
-
|
|
4031
|
-
const files = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
4032
|
-
if (!files.some((entry) => entry.role === "partial")) {
|
|
4033
|
-
add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
|
|
4034
|
-
}
|
|
4035
|
-
if (!files.some((entry) => entry.role === "asset")) {
|
|
4036
|
-
add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
|
|
4037
|
-
}
|
|
4038
|
-
|
|
4039
|
-
const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
|
|
4040
|
-
if (!sectionExports.length) {
|
|
4041
|
-
add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
|
|
4042
|
-
} else {
|
|
4043
|
-
const withoutNodeIds = sectionExports
|
|
4044
|
-
.filter((entry) => {
|
|
4045
|
-
const nodeIds = isObject(entry?.node_ids) ? entry.node_ids : null;
|
|
4046
|
-
return !nodeIds || !Object.keys(nodeIds).length;
|
|
4047
|
-
})
|
|
4048
|
-
.map((entry) => entry?.section || "unknown");
|
|
4049
|
-
if (withoutNodeIds.length) {
|
|
4050
|
-
add(
|
|
4051
|
-
"warning",
|
|
4052
|
-
"source_html.producer_provenance.section_exports.node_ids",
|
|
4053
|
-
`Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
|
|
4054
|
-
);
|
|
4055
|
-
}
|
|
4056
|
-
}
|
|
4057
|
-
|
|
4058
|
-
return findings;
|
|
4059
|
-
}
|
|
4060
3997
|
|
|
4061
3998
|
function hasFigmaDesignSource(page) {
|
|
4062
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
|
+
}
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Doctor entry points: the doctor command, packet inspection and built-output inspection.
|
|
2
2
|
import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
|
|
3
|
+
import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
|
|
3
4
|
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
|
|
4
5
|
import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
5
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
@@ -63,7 +64,7 @@ import {
|
|
|
63
64
|
nextPrepareBuildBindingIssues,
|
|
64
65
|
prepareBuildGateIssue,
|
|
65
66
|
addPrepareBuildGateErrors,
|
|
66
|
-
|
|
67
|
+
readinessStatus,
|
|
67
68
|
buildNextStep,
|
|
68
69
|
} from "./next-step.mjs";
|
|
69
70
|
|
|
@@ -132,6 +133,13 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
|
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
|
|
136
|
+
if (args.ci === true) {
|
|
137
|
+
try {
|
|
138
|
+
JSON.parse(readFileSync(resolve(requireArg(args, "packet")), "utf8"));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return doctorCiFailure(error, { usage: true });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
135
143
|
// Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
|
|
136
144
|
// campaign that has only a built _site/ and no full Build Packet. Resolves
|
|
137
145
|
// scope from the built output and runs the built-output residue/text/
|
|
@@ -153,6 +161,16 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
|
|
|
153
161
|
...(qcStandIns !== undefined ? { qcStandIns } : {}),
|
|
154
162
|
};
|
|
155
163
|
const result = runDoctor(packetPath, doctorOptions);
|
|
164
|
+
if (args.ci === true) {
|
|
165
|
+
// Use precisely the context/report selection that inspection used,
|
|
166
|
+
// including explicit sidecar suppression and the context's report binding.
|
|
167
|
+
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
168
|
+
contextPath: doctorOptions.contextPath,
|
|
169
|
+
reportPath: doctorOptions.reportPath,
|
|
170
|
+
followContextPointer: true,
|
|
171
|
+
});
|
|
172
|
+
result.ci = assessDoctorCi({ packet: workspace.packet, packetPath, report: readJsonIfExists(workspace.reportPath) });
|
|
173
|
+
}
|
|
156
174
|
// Inspection and recording are separate operations. A laptop's untracked
|
|
157
175
|
// built output can be stale while the delivered build's evidence is valid.
|
|
158
176
|
// Only an explicit producer action may replace the retained proof artifacts;
|
|
@@ -664,13 +682,7 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
664
682
|
}
|
|
665
683
|
}
|
|
666
684
|
}
|
|
667
|
-
const status = errors.length
|
|
668
|
-
? "blocked"
|
|
669
|
-
: checkpointExceptionPresent(derived)
|
|
670
|
-
? "ready_with_waivers"
|
|
671
|
-
: warnings.length
|
|
672
|
-
? "ready_with_warnings"
|
|
673
|
-
: "ready";
|
|
685
|
+
const status = errors.length ? "blocked" : readinessStatus(warnings, derived);
|
|
674
686
|
const result = {
|
|
675
687
|
schema_version: DOCTOR_SIDECAR_SCHEMA,
|
|
676
688
|
generated_at: new Date().toISOString(),
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -413,7 +413,7 @@ export function assessPurchaseProofCoverage({ packet = null, report = null } = {
|
|
|
413
413
|
};
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
-
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null
|
|
416
|
+
function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate, purchaseProof = null) {
|
|
417
417
|
const polishGate = derived?.polish_gate || evaluatePolishGate({ report });
|
|
418
418
|
const polishCheckpointGate = derived?.polish_checkpoint_gate || null;
|
|
419
419
|
// prepare-build is the earliest lifecycle prerequisite. Surface its
|
|
@@ -542,6 +542,9 @@ function checkpointExceptionPresent(derived) {
|
|
|
542
542
|
|| derived?.polish_checkpoint_gate?.status === "waived";
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
+
// The readiness ladder below "blocked", written once: doctor's status, the
|
|
546
|
+
// `next` command's status and doctor's `next` block each decide "blocked"
|
|
547
|
+
// first, then call it with their own warnings.
|
|
545
548
|
function readinessStatus(warnings, derived) {
|
|
546
549
|
if (checkpointExceptionPresent(derived)) return "ready_with_waivers";
|
|
547
550
|
return warnings.length ? "ready_with_warnings" : "ready";
|
|
@@ -664,7 +667,7 @@ function buildNextStep(errors, warnings, derived, report = null, packet = null,
|
|
|
664
667
|
&& (polishGate.status === "blocked" || polishCheckpointGate?.status === "blocked");
|
|
665
668
|
const codes = new Set([...errors, ...warnings].map((issue) => issue.code));
|
|
666
669
|
const purchaseProof = report ? assessPurchaseProofCoverage({ packet, report }) : null;
|
|
667
|
-
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof
|
|
670
|
+
const picked = pickNextStage(report, { errors, derived }, prepareBuildGate, purchaseProof);
|
|
668
671
|
// The picker's vocabulary and this table must not drift apart: a stage the
|
|
669
672
|
// table does not know would otherwise be relabelled as an operator step and
|
|
670
673
|
// sliced into the whole ladder. Fail loudly instead.
|
|
@@ -758,6 +761,6 @@ export {
|
|
|
758
761
|
prepareBuildGateIssue,
|
|
759
762
|
addPrepareBuildGateErrors,
|
|
760
763
|
deployUrlFromReportOutputs,
|
|
761
|
-
|
|
764
|
+
readinessStatus,
|
|
762
765
|
buildNextStep,
|
|
763
766
|
};
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// validity, wrapper policy, screenshot proof) is evaluated elsewhere and is
|
|
19
19
|
// untouched by this gate.
|
|
20
20
|
|
|
21
|
+
import { SOURCE_HASH_PATTERN } from "../source-html-manifest.mjs";
|
|
21
22
|
import {
|
|
22
23
|
assessCheckpointWaivers,
|
|
23
24
|
checkpointStateFingerprint,
|
|
@@ -182,3 +183,76 @@ export function evaluateSourceProvenanceGates({ pages = [], blockingCodes = [],
|
|
|
182
183
|
|
|
183
184
|
return { gates, inert: { counts, pages: inertPages } };
|
|
184
185
|
}
|
|
186
|
+
|
|
187
|
+
// Returns every Figma-provenance finding in emission order, each tagged with
|
|
188
|
+
// its severity: "provenance" (the waivable source_html.producer_provenance*
|
|
189
|
+
// and source_html.files.partial/asset blockers) or "warning".
|
|
190
|
+
export function collectFigmaProvenanceFindings(manifest, provenance = manifest?.producer_provenance || {}, rawProvenance = manifest?.producer_provenance) {
|
|
191
|
+
const findings = [];
|
|
192
|
+
const add = (severity, code, message) => findings.push({ severity, code, message });
|
|
193
|
+
if (!rawProvenance) {
|
|
194
|
+
add(
|
|
195
|
+
"provenance",
|
|
196
|
+
"source_html.producer_provenance",
|
|
197
|
+
"Figma source manifest is missing producer_provenance. Re-run figma-sections-export handoff so Campaigns OS can gate semantic exporter provenance before assembly."
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (provenance.source_type !== "semantic_figma_export") {
|
|
202
|
+
add(
|
|
203
|
+
"provenance",
|
|
204
|
+
"source_html.producer_provenance.source_type",
|
|
205
|
+
`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.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (provenance.screenshot_fallback_used !== false) {
|
|
209
|
+
add(
|
|
210
|
+
"provenance",
|
|
211
|
+
"source_html.producer_provenance.screenshot_fallback_used",
|
|
212
|
+
"Figma source manifest reports screenshot_fallback_used=true. Re-run semantic figma-sections-export before assembly."
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (!Number.isInteger(provenance.semantic_section_count) || provenance.semantic_section_count <= 0) {
|
|
216
|
+
add(
|
|
217
|
+
"provenance",
|
|
218
|
+
"source_html.producer_provenance.semantic_section_count",
|
|
219
|
+
"Figma source manifest must report semantic_section_count > 0."
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (!SOURCE_HASH_PATTERN.test(String(provenance.material_fingerprint || ""))) {
|
|
223
|
+
add(
|
|
224
|
+
"provenance",
|
|
225
|
+
"source_html.producer_provenance.material_fingerprint",
|
|
226
|
+
"Figma source manifest must include a 64-character material_fingerprint over the handed-off source package."
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const files = Array.isArray(manifest?.files) ? manifest.files : [];
|
|
231
|
+
if (!files.some((entry) => entry.role === "partial")) {
|
|
232
|
+
add("provenance", "source_html.files.partial", "Figma source manifest files[] must include section partials.");
|
|
233
|
+
}
|
|
234
|
+
if (!files.some((entry) => entry.role === "asset")) {
|
|
235
|
+
add("provenance", "source_html.files.asset", "Figma source manifest files[] must include exported assets.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const sectionExports = Array.isArray(provenance.section_exports) ? provenance.section_exports : [];
|
|
239
|
+
if (!sectionExports.length) {
|
|
240
|
+
add("provenance", "source_html.producer_provenance.section_exports", "Figma source manifest must include section_exports with Figma node IDs and extraction commands.");
|
|
241
|
+
} else {
|
|
242
|
+
const withoutNodeIds = sectionExports
|
|
243
|
+
.filter((entry) => {
|
|
244
|
+
const nodeIds = isPlainObject(entry?.node_ids) ? entry.node_ids : null;
|
|
245
|
+
return !nodeIds || !Object.keys(nodeIds).length;
|
|
246
|
+
})
|
|
247
|
+
.map((entry) => entry?.section || "unknown");
|
|
248
|
+
if (withoutNodeIds.length) {
|
|
249
|
+
add(
|
|
250
|
+
"warning",
|
|
251
|
+
"source_html.producer_provenance.section_exports.node_ids",
|
|
252
|
+
`Some Figma section exports do not list node_ids: ${withoutNodeIds.slice(0, 6).join(", ")}${withoutNodeIds.length > 6 ? ", ..." : ""}.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return findings;
|
|
258
|
+
}
|