@nextcommerce/campaigns-os 1.60.0 → 1.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +244 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +20 -0
- package/agents/codex/AGENTS.md +19 -0
- package/agents/copilot/copilot-instructions.md +19 -0
- package/agents/cursor/campaigns-os.mdc +21 -0
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- 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 +68 -61
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +1767 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +10 -10
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/access-model.md +20 -0
- package/docs/brand-theme-bridge.md +62 -3
- package/docs/build-packet.md +456 -36
- package/docs/campaign-build-brief.md +16 -0
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +112 -2
- package/docs/campaignspec-authoring-examples.md +141 -0
- package/docs/design-source-package.md +64 -8
- package/docs/effects.md +17 -9
- 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 +42 -2
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +197 -3
- package/docs/quickstart.md +438 -0
- package/docs/readback.md +154 -6
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +4 -2
- 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/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +238 -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 -4
- package/skills/next-campaigns-os/SKILL.md +7 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +8 -10
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +151 -25
- package/skills.json +11 -11
- package/src/brand-theme.mjs +415 -9
- package/src/brief-summary.mjs +6 -2
- package/src/browser-launch.mjs +2 -1
- package/src/build-brief.mjs +13 -3
- package/src/built-site-scope.mjs +2 -2
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +358 -97
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-file-reference.mjs +93 -0
- package/src/design-source-package.mjs +91 -1
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +87 -81
- package/src/doctor/ci.mjs +177 -0
- package/src/doctor/inspect.mjs +41 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor/source-provenance.mjs +74 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +159 -0
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/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 +189 -10
- 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-readability.mjs +221 -9
- package/src/polish-visual-fixtures.mjs +74 -0
- package/src/polish-visual.mjs +613 -0
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-correctness.mjs +14 -2
- package/src/qa-analytics-parity.mjs +111 -6
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +235 -22
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +176 -6
- package/src/qc-check-registry.mjs +42 -2
- package/src/qc-results.mjs +477 -42
- package/src/readback.mjs +113 -21
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-attribute-index.mjs +11 -11
- package/src/sdk-markup.mjs +77 -2
- package/src/sdk-storage-compatibility.mjs +115 -6
- package/src/source-html-intake.mjs +15 -1
- package/src/source-html-manifest.mjs +7 -0
- package/src/source-html-server.mjs +129 -0
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +100 -17
- package/src/target-lock.mjs +11 -1
- package/src/theme-gate.mjs +2 -2
- package/src/visual-diff.mjs +296 -0
- package/src/visual-reference.mjs +126 -0
package/src/doctor/checks.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../adapter-decision-contract.mjs";
|
|
15
15
|
import { createDoctorCheckRegistry, runDoctorCheckRegistry } from "../doctor-check-registry.mjs";
|
|
16
16
|
import { evaluateSourcePreparation } from "../source-prep.mjs";
|
|
17
|
+
import { staleRoutingHints } from "../spec-derive.mjs";
|
|
17
18
|
import { isLoopbackHostname } from "../remit.mjs";
|
|
18
19
|
import {
|
|
19
20
|
HOST_STRIPPED_CODE,
|
|
@@ -23,10 +24,10 @@ import {
|
|
|
23
24
|
} from "../source-html-intake.mjs";
|
|
24
25
|
import {
|
|
25
26
|
readSourceHtmlManifestFile,
|
|
26
|
-
SOURCE_HASH_PATTERN,
|
|
27
27
|
SOURCE_HTML_MANIFEST_SCHEMA,
|
|
28
28
|
} from "../source-html-manifest.mjs";
|
|
29
29
|
import { validateAssemblyReportThemeBlock, validateThemeContextBlock } from "../brand-theme.mjs";
|
|
30
|
+
import { packetRuntimeRelPath } from "../campaign-workspace.mjs";
|
|
30
31
|
import { singleLineDetail, singleLineField } from "../text-safety.mjs";
|
|
31
32
|
import {
|
|
32
33
|
campaignRouteRoot,
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
normalizePublicRouteSlug,
|
|
36
37
|
packetRouteRoot,
|
|
37
38
|
runtimeRelativeRouteForSpecValue,
|
|
39
|
+
specPublicRouteSlug,
|
|
38
40
|
stripPublicRoutePrefix,
|
|
39
41
|
} from "../route-identity.mjs";
|
|
40
42
|
import { evaluatePageKitBuildSummary } from "../page-kit-build-summary.mjs";
|
|
@@ -89,7 +91,8 @@ import { SCRIPT_SYNTAX, collectBuiltScriptSyntaxInputs, evaluateBuiltScriptSynta
|
|
|
89
91
|
import { CART_PLACEHOLDERS, CART_PLACEHOLDERS_LIMITS, evaluateCartPlaceholders, isFileReadFailure } from "../cart-placeholders.mjs";
|
|
90
92
|
import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, isBuiltPageReadFailure, pageScriptSources, parseBuiltPage, realPathOf } from "../built-smoke-qc.mjs";
|
|
91
93
|
import { recordQcResults } from "../qc-results.mjs";
|
|
92
|
-
import {
|
|
94
|
+
import { evaluateDesignAccess } from "../parity/design-access.mjs";
|
|
95
|
+
import { collectFigmaProvenanceFindings, FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
|
|
93
96
|
import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
|
|
94
97
|
import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
|
|
95
98
|
import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
@@ -102,6 +105,7 @@ import {
|
|
|
102
105
|
import { loadPageKitCampaignEntry, pageKitIgnoresAssetsHtml, projectPageKitCampaignLoad } from "../page-kit-campaign-config.mjs";
|
|
103
106
|
import { evaluatePageKitStoreProfile, PAGE_KIT_STORE_PROFILE_SCOPE, storeProfileIntentionallyEmptyNote } from "../page-kit-store-profile.mjs";
|
|
104
107
|
import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-kit-sdk-version.mjs";
|
|
108
|
+
import { evaluatePageKitAnalyticsVendors, PAGE_KIT_ANALYTICS_VENDORS_SCOPE } from "../page-kit-analytics-vendors.mjs";
|
|
105
109
|
// ADR-003: the public, canonical CampaignSpec rule registry. The doctor and any
|
|
106
110
|
// campaign authoring UI (e.g. a Map Builder bundle) import the same rules, so a
|
|
107
111
|
// spec check is authored once and reaches internal teams and agencies alike.
|
|
@@ -382,6 +386,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
382
386
|
phase: "target",
|
|
383
387
|
run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetStoreProfile(spec, errors, warnings, ready, derived, buildState),
|
|
384
388
|
},
|
|
389
|
+
{
|
|
390
|
+
id: PAGE_KIT_ANALYTICS_VENDORS_SCOPE,
|
|
391
|
+
phase: "target",
|
|
392
|
+
run: ({ spec, warnings, ready, buildState }) => validateTargetAnalyticsVendors(spec, warnings, ready, buildState),
|
|
393
|
+
},
|
|
385
394
|
{
|
|
386
395
|
id: "spec.shipping_countries",
|
|
387
396
|
phase: "spec",
|
|
@@ -392,6 +401,11 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
392
401
|
phase: "spec",
|
|
393
402
|
run: ({ spec, packet, warnings, ready, derived, buildState }) => validateSpecRoutingMetaTags(spec, packet, warnings, ready, derived, buildState),
|
|
394
403
|
},
|
|
404
|
+
{
|
|
405
|
+
id: "spec.routing_hint_stale",
|
|
406
|
+
phase: "spec",
|
|
407
|
+
run: ({ spec, packet, errors, ready }) => validateSpecRoutingHintsCurrent(spec, packet, errors, ready),
|
|
408
|
+
},
|
|
395
409
|
{
|
|
396
410
|
id: "spec.routing_host_prefix",
|
|
397
411
|
phase: "spec",
|
|
@@ -1416,6 +1430,18 @@ function validateTargetStoreProfile(spec, errors, warnings, ready, derived, buil
|
|
|
1416
1430
|
+ storeProfileIntentionallyEmptyNote(intentionallyEmpty));
|
|
1417
1431
|
}
|
|
1418
1432
|
|
|
1433
|
+
// The starter-template vendor keys against analytics.vendors: advisory only,
|
|
1434
|
+
// one warning per vendor the two sides disagree on (#689).
|
|
1435
|
+
function validateTargetAnalyticsVendors(spec, warnings, ready, buildState) {
|
|
1436
|
+
const result = evaluatePageKitAnalyticsVendors({ spec, targetLoad: buildState?.pageKitCampaignConfig });
|
|
1437
|
+
for (const finding of result.findings) {
|
|
1438
|
+
addIssue(warnings, finding.code, finding.message, { vendor: finding.vendor, id_key: finding.id_key, repair: finding.repair });
|
|
1439
|
+
}
|
|
1440
|
+
if (result.status === "ok" && result.vendors.length) {
|
|
1441
|
+
ready.push(`Target campaigns.json vendor keys match the CampaignSpec analytics.vendors (${result.vendors.join(", ")}).`);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1419
1445
|
function validateSpecShippingCountries(spec, warnings, ready) {
|
|
1420
1446
|
const countries = spec?.campaign?.available_shipping_countries;
|
|
1421
1447
|
if (countries === "all" || (Array.isArray(countries) && countries.length === 0)) {
|
|
@@ -1557,11 +1583,7 @@ export function validateRouteSlugIdentity(spec, packet, errors, ready) {
|
|
|
1557
1583
|
const mapId = optionalString(spec?.spec_identity?.map_id)
|
|
1558
1584
|
|| optionalString(spec?.map_id)
|
|
1559
1585
|
|| optionalString(packet?.spec?.map_id);
|
|
1560
|
-
const specSlug =
|
|
1561
|
-
optionalString(spec?.spec_identity?.public_route_slug)
|
|
1562
|
-
|| optionalString(spec?.campaign?.slug)
|
|
1563
|
-
|| optionalString(spec?.campaign?.id)
|
|
1564
|
-
);
|
|
1586
|
+
const specSlug = specPublicRouteSlug(spec);
|
|
1565
1587
|
const specDeclaresMapIdSlug = Boolean(specSlug) && Boolean(mapId) && specSlug === mapId;
|
|
1566
1588
|
|
|
1567
1589
|
if (mapId && packetSlug === mapId && !specDeclaresMapIdSlug) {
|
|
@@ -1692,6 +1714,31 @@ export function validateSpecRoutingMetaTags(spec, packet, warnings, ready, deriv
|
|
|
1692
1714
|
);
|
|
1693
1715
|
}
|
|
1694
1716
|
|
|
1717
|
+
// #531: a routing hint (`sdk_hints.meta_tags` next-success-url and the upsell
|
|
1718
|
+
// accept/decline urls) that does not match the route of the page its routing
|
|
1719
|
+
// field names. Nothing else catches it once the build exists: the built meta
|
|
1720
|
+
// check compares the built page against the hint, and QA derives its
|
|
1721
|
+
// expectation from the hint, so a moved upsell with checkout still pointing
|
|
1722
|
+
// at the old path passes both and sends shoppers to a 404 after payment. It
|
|
1723
|
+
// blocks on every read, before and after the build, and record spec refuses a
|
|
1724
|
+
// routes-only bind on the same function (spec-derive.mjs staleRoutingHints).
|
|
1725
|
+
export function validateSpecRoutingHintsCurrent(spec, packet, errors, ready) {
|
|
1726
|
+
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
1727
|
+
const stale = staleRoutingHints(spec, { publicRouteSlug });
|
|
1728
|
+
if (!stale.length) {
|
|
1729
|
+
if (activeSpecPages(spec).some((page) => isObject(page.sdk_hints?.meta_tags))) ready.push("CampaignSpec routing hints match the routes of the pages they name");
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const sample = stale.slice(0, 5).map((hint) => `${hint.page_id}:${hint.tag} ${JSON.stringify(hint.value)} but page "${hint.target_page_id}" routes to ${JSON.stringify(hint.derived_route)}`).join("; ");
|
|
1733
|
+
const more = stale.length > 5 ? `; plus ${stale.length - 5} more` : "";
|
|
1734
|
+
addIssue(
|
|
1735
|
+
errors,
|
|
1736
|
+
"spec.routing_hint_stale",
|
|
1737
|
+
`CampaignSpec routing hint(s) name a route the page they point at no longer has: ${sample}${more}. The built meta tag follows the hint, so the SDK would send shoppers to the old path. Edit each hint to the route shown in the spec (or, while the Map is still the spec's source, re-save the Map, which regenerates them), then run record spec.`,
|
|
1738
|
+
{ hints: stale },
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1695
1742
|
// #531: a route with a host in front of its path ("shop.example.com/route/x/")
|
|
1696
1743
|
// is nested under the campaign root by every stage that composes a URL
|
|
1697
1744
|
// (polish capture requested "/route/shop.example.com/route/x/"), so it blocks.
|
|
@@ -3264,6 +3311,12 @@ function scanMarketSensitiveCopy(spec, warnings, ready, derived) {
|
|
|
3264
3311
|
}
|
|
3265
3312
|
}
|
|
3266
3313
|
|
|
3314
|
+
// CampaignSpec owns `campaign.available_currencies`: every currency the
|
|
3315
|
+
// campaign sells in, the default included (the CampaignCurrencies rule checks
|
|
3316
|
+
// its codes). `additional_currencies` is the Campaigns App API's name for the
|
|
3317
|
+
// same list without the default and is not a spec field; both market scopes
|
|
3318
|
+
// still read it, and its camelCase spelling, so a spec copied from an API body
|
|
3319
|
+
// gets the copy review it needs.
|
|
3267
3320
|
function deriveMarketScope(spec) {
|
|
3268
3321
|
const campaign = spec?.campaign || {};
|
|
3269
3322
|
const defaultCurrency = normalizeCurrency(campaign.currency);
|
|
@@ -3674,6 +3727,12 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3674
3727
|
derived,
|
|
3675
3728
|
});
|
|
3676
3729
|
const active = activeSpecPages(spec);
|
|
3730
|
+
const designPackagePath = resolveFromFile(packetPath, packet?.design_source_package?.path);
|
|
3731
|
+
let designSourcePackage = null;
|
|
3732
|
+
try { if (designPackagePath) designSourcePackage = JSON.parse(readFileSync(designPackagePath, "utf8")); } catch { /* Missing or unreadable package is checked separately. */ }
|
|
3733
|
+
const designManifest = isNonEmptyString(sourceRoot) && existsSync(sourceRoot) && statSync(sourceRoot).isDirectory()
|
|
3734
|
+
? readSourceHtmlManifestFile(sourceRoot, { manifestPath: recordedDesignManifestPath(packet, packetPath) }).manifest : null;
|
|
3735
|
+
recordQcResults({ derived, warnings, results: evaluateDesignAccess({ activePages: active, mappings: pages, manifest: designManifest, designSourcePackage, measured_at: new Date().toISOString() }) });
|
|
3677
3736
|
const specPartialScope = spec?.build_scope?.mode === "partial";
|
|
3678
3737
|
const specPartialReasons = Array.isArray(spec?.build_scope?.reasons) ? spec.build_scope.reasons.filter(isNonEmptyString) : [];
|
|
3679
3738
|
const activeIds = new Set(active.map((page) => page.id));
|
|
@@ -3706,6 +3765,25 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3706
3765
|
if (!activeIds.has(page.page_id)) {
|
|
3707
3766
|
addIssue(warnings, "source_html.pages.extra", `Source mapping "${page.page_id}" is not an active CampaignSpec page.`);
|
|
3708
3767
|
}
|
|
3768
|
+
// A page added from the repo's page tree after handoff (#721): its source
|
|
3769
|
+
// is the target page file itself, so it is mapped and, once that file
|
|
3770
|
+
// exists, a build candidate like any other.
|
|
3771
|
+
if (page.origin === "repo") {
|
|
3772
|
+
const repoFile = optionalString(page.page_kit?.output_path) && derived.target_repo ? resolve(derived.target_repo, page.page_kit.output_path) : null;
|
|
3773
|
+
if (!repoFile || !existsSync(repoFile) || !statSync(repoFile).isFile()) {
|
|
3774
|
+
addIssue(errors, "source_html.pages.repo_page_missing", `Page "${page.page_id}" was added from the repo's page tree, but its page file ${page.page_kit?.output_path || "(no page_kit.output_path)"} is not in the target repo. Restore the file, or run spec derive and record spec to remove the page.`, { page_id: page.page_id, output_path: page.page_kit?.output_path || null });
|
|
3775
|
+
} else if (specPage) {
|
|
3776
|
+
builtPages.push({
|
|
3777
|
+
page_id: specPage.id,
|
|
3778
|
+
type: specPage.type || "page",
|
|
3779
|
+
role: pageRole(specPage.type),
|
|
3780
|
+
route: builtRoute,
|
|
3781
|
+
source_path: null,
|
|
3782
|
+
repo_source: page.page_kit.output_path,
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
continue;
|
|
3786
|
+
}
|
|
3709
3787
|
if (page.path) {
|
|
3710
3788
|
const fullPath = resolve(sourceRoot, page.path);
|
|
3711
3789
|
if (!existsSync(fullPath) || !statSync(fullPath).isFile()) {
|
|
@@ -3988,78 +4066,6 @@ function validateSourceProducerProvenance(manifest, { spec, errors, warnings, re
|
|
|
3988
4066
|
}
|
|
3989
4067
|
}
|
|
3990
4068
|
|
|
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
4069
|
|
|
4064
4070
|
function hasFigmaDesignSource(page) {
|
|
4065
4071
|
const designSource = page && isObject(page.design_source) ? page.design_source : null;
|
|
@@ -4691,7 +4697,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4691
4697
|
const publicRouteSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
4692
4698
|
const siteRoot = derived.target_repo && publicRouteSlug ? join(derived.target_repo, "_site", publicRouteSlug) : null;
|
|
4693
4699
|
if (!siteRoot || !existsSync(siteRoot) || !statSync(siteRoot).isDirectory()) return;
|
|
4694
|
-
const brief = loadBriefPayload(derived.target_repo);
|
|
4700
|
+
const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
|
|
4695
4701
|
const urgencyVerified = briefUrgencyVerified(brief?.payload);
|
|
4696
4702
|
const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
|
|
4697
4703
|
if (!findings.length) {
|
|
@@ -4760,7 +4766,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4760
4766
|
// non-shipped, non-usable assets stay warnings (the attestation queue), so a
|
|
4761
4767
|
// producer that correctly excluded them is not blocked.
|
|
4762
4768
|
export function validateProofAttestation(packet, errors, warnings, ready, derived, buildState = {}) {
|
|
4763
|
-
const brief = loadBriefPayload(derived.target_repo);
|
|
4769
|
+
const brief = loadBriefPayload(derived.target_repo, packetRuntimeRelPath(packet));
|
|
4764
4770
|
if (!brief) return; // not an AI-assembled run — no brief payload, no gate
|
|
4765
4771
|
if (brief.error) {
|
|
4766
4772
|
// Fail closed: the brief payload is the source of attestation state. If
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { worseChangeKind } from "../change-scope.mjs";
|
|
2
|
+
import { assessEvidenceDebt } from "../evidence-debt.mjs";
|
|
3
|
+
import { REFUSED_INVOCATION } from "../lifecycle.mjs";
|
|
4
|
+
|
|
5
|
+
export const DOCTOR_CI_EXIT_CODES = Object.freeze({ current: 0, owed: 3, unknown: 4, usage: 2 });
|
|
6
|
+
|
|
7
|
+
export function assessDoctorCi(inputs) {
|
|
8
|
+
const debt = assessEvidenceDebt(inputs);
|
|
9
|
+
// Advisory Polish (a page-only change) is reported, never required.
|
|
10
|
+
const stages = [debt.polish, debt.qa].filter((stage) => !stage.advisory);
|
|
11
|
+
// Any known debt is owed; otherwise incomplete evidence remains unknown.
|
|
12
|
+
const status = stages.some((stage) => stage.status === "owed") ? "owed"
|
|
13
|
+
: stages.some((stage) => stage.status === "unknown") ? "unknown" : "current";
|
|
14
|
+
return { status, exit_code: DOCTOR_CI_EXIT_CODES[status], ...debt };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// What the change owes, from the stages it left owed: "pages" when the only
|
|
18
|
+
// required stage is QA on the changed pages (Polish advisory or current),
|
|
19
|
+
// "full" when a full stage is owed, null when nothing is owed. `kind` is the
|
|
20
|
+
// worst change kind among the owed stages.
|
|
21
|
+
export function ciChangeScope(ci) {
|
|
22
|
+
const owed = ["polish", "qa"].filter((stage) => ci[stage]?.status === "owed");
|
|
23
|
+
if (!owed.length) return { scope: null, kind: null };
|
|
24
|
+
const kind = owed.map((stage) => ci[stage].change?.kind || "unknown").reduce(worseChangeKind, "none");
|
|
25
|
+
const required = owed.filter((stage) => !ci[stage].advisory);
|
|
26
|
+
const scope = required.every((stage) => stage === "qa" && ci.qa.scope === "pages") ? "pages" : "full";
|
|
27
|
+
return { scope, kind };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Page ids come from built routes ([A-Za-z0-9_-]); anything else is encoded.
|
|
31
|
+
const pageArg = (id) => (/^[A-Za-z0-9_-]+$/.test(String(id)) ? String(id) : text(id));
|
|
32
|
+
|
|
33
|
+
const commerceReasonText = new Map([
|
|
34
|
+
["unmapped_file", "a file outside any page changed"],
|
|
35
|
+
["page_added", "a page was added"],
|
|
36
|
+
["page_removed", "a page was removed"],
|
|
37
|
+
["checkout_page", "a checkout page changed"],
|
|
38
|
+
["commerce_markup_changed", "package, offer, shipping, upsell, route or script markup changed"],
|
|
39
|
+
["commerce_signature_unavailable", "the recorded build has no commerce signature to compare against"],
|
|
40
|
+
["campaign_config_changed", "the campaign's Page Kit config (SDK pin, analytics ids, store fields) changed"],
|
|
41
|
+
["campaign_config_unavailable", "the campaign's Page Kit config entry could not be read"],
|
|
42
|
+
["change_history_unavailable", "the rebuilds since the reviewed build were not kept"],
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Values can come from campaign files and will be rendered in a PR comment.
|
|
46
|
+
// Encode punctuation as well as HTML: diagnostics are plain text, and may
|
|
47
|
+
// contain Markdown, autolinks, code fences or line breaks from campaign files.
|
|
48
|
+
const text = (value) => String(value).replace(/\s/g, " ")
|
|
49
|
+
.replace(/[!-/:-@\[-`{-~]/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
50
|
+
// Inline code already protects ordinary path punctuation; preserve those
|
|
51
|
+
// bytes for readable page paths and issue codes, while preventing breakouts.
|
|
52
|
+
const code = (value) => `\`${String(value).replace(/\s/g, " ")
|
|
53
|
+
.replace(/[!-/:-@\[-`{-~]/g, (char) => "./_-".includes(char) ? char : `&#${char.charCodeAt(0)};`)}\``;
|
|
54
|
+
|
|
55
|
+
const unavailablePageChanges = new Map([
|
|
56
|
+
["packet_unavailable", "the build packet or its path is unavailable."],
|
|
57
|
+
["build_output_location_unknown", "the built output location cannot be determined."],
|
|
58
|
+
["build_output_missing", "the built output is missing."],
|
|
59
|
+
["build_output_unreadable", "the built output could not be read."],
|
|
60
|
+
["assembly_build_fingerprint_missing", "the assembly build fingerprint is missing."],
|
|
61
|
+
["build_output_pages_unresolved", "the built output could not be mapped to pages."],
|
|
62
|
+
["build_manifest_unavailable", "no usable assembly manifest is available."],
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const requiredStageReasons = new Map([
|
|
66
|
+
["stage_required_by_build", "the build requires this stage again"],
|
|
67
|
+
["stage_required_by_brief", "the brief requires this stage again"],
|
|
68
|
+
["stage_required_by_spec", "the spec requires this stage again"],
|
|
69
|
+
["stage_required", "the lifecycle requires this stage again"],
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
export function doctorCiFailure(error, { usage = false, code = usage ? "doctor.ci.packet" : "doctor.ci.failed" } = {}) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false, status: "blocked",
|
|
75
|
+
errors: [{ code, message: String(error?.message ?? error) }],
|
|
76
|
+
warnings: [], exit_code: usage ? DOCTOR_CI_EXIT_CODES.usage : DOCTOR_CI_EXIT_CODES.unknown,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Presentation boundary only: invocation policy still decides session and
|
|
81
|
+
// lifecycle behavior. Setup can throw before doctor's dispatch branch runs.
|
|
82
|
+
export async function withDoctorCiReport(args, invoke) {
|
|
83
|
+
const command = args._[0] === "campaigns-os" ? args._[1] : args._[0];
|
|
84
|
+
try {
|
|
85
|
+
return await invoke(args);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (command !== "doctor" || args.ci !== true) throw error;
|
|
88
|
+
// A flag refused up front (such as --packet=<path>) is a usage error.
|
|
89
|
+
const result = error?.code === REFUSED_INVOCATION ? doctorCiFailure(error, { usage: true, code: "doctor.ci.usage" }) : doctorCiFailure(error);
|
|
90
|
+
console.log(args.json ? JSON.stringify(result, null, 2) : formatDoctorCi(result));
|
|
91
|
+
process.exitCode = result.exit_code;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatDoctorCi(result) {
|
|
96
|
+
const ci = result.ci;
|
|
97
|
+
if (!ci) {
|
|
98
|
+
return [
|
|
99
|
+
`## Campaign evidence: could not evaluate (exit ${result.exit_code ?? DOCTOR_CI_EXIT_CODES.usage})`, "",
|
|
100
|
+
...(result.errors || []).map((issue) => `- ${code(issue.code)}: ${text(issue.message)}`), "",
|
|
101
|
+
result.errors?.[0]?.code === "doctor.ci.usage"
|
|
102
|
+
? "Remedy: correct the command's flags (each value as the next argument, `--flag value`) and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`."
|
|
103
|
+
: "Remedy: correct the packet or inspection failure and re-run `campaigns-os doctor --packet <packet> --ci --no-live-refs`.",
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
const lines = [`## Campaign evidence: ${text(ci.status)} (exit ${ci.exit_code})`, ""];
|
|
107
|
+
const advisory = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && ci[stage].advisory);
|
|
108
|
+
const owed = ["polish", "qa"].filter((stage) => ci[stage].status === "owed" && !ci[stage].advisory);
|
|
109
|
+
const change = ciChangeScope(ci);
|
|
110
|
+
const pageScoped = change.scope === "pages";
|
|
111
|
+
if (pageScoped) {
|
|
112
|
+
lines.push(`Change found: page-only. ${owed.length ? `It owes QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : "QA is current"}; Polish is advisory.`);
|
|
113
|
+
} else if (change.scope === "full") {
|
|
114
|
+
const reasons = [...new Set((ci.polish.change?.reasons || []).concat(ci.qa.change?.reasons || [])
|
|
115
|
+
.map((reason) => commerceReasonText.get(reason.code)).filter(Boolean))];
|
|
116
|
+
const found = change.kind === "commerce_affecting" ? "commerce-affecting"
|
|
117
|
+
: change.kind === "page_only" ? "page-only, but an input change or a Polish or QA that did not complete requires the full stages" : "not classified";
|
|
118
|
+
// Name only the stages that count, each at the scope it is owed: an
|
|
119
|
+
// advisory Polish is not owed, and QA can still be owed on pages only.
|
|
120
|
+
const qaPages = ci.qa.scope === "pages" ? `QA on ${ci.qa.owed_pages.map((page) => code(page.page_id)).join(", ")}` : null;
|
|
121
|
+
const owes = owed.length === 2 ? (qaPages ? `the full Polish, and ${qaPages}` : "the full Polish and QA")
|
|
122
|
+
: owed[0] === "polish" ? "the full Polish" : qaPages || "a full QA run";
|
|
123
|
+
lines.push(`Change found: ${found}${reasons.length ? ` (${reasons.join("; ")})` : ""}. It owes ${owes}${advisory.includes("polish") ? "; Polish is advisory" : ""}.`);
|
|
124
|
+
}
|
|
125
|
+
lines.push(`Owed stages: ${owed.length ? owed.map((stage) => stage === "qa" && ci.qa.scope === "pages" ? "qa (changed pages only)" : stage).join(", ") : "none"}.`);
|
|
126
|
+
for (const stage of owed) {
|
|
127
|
+
const reason = ci[stage].reason === "build_fingerprint_changed"
|
|
128
|
+
? "the recorded build fingerprint differs from the current build"
|
|
129
|
+
: requiredStageReasons.get(ci[stage].reason) ?? "the recorded stage evidence is owed";
|
|
130
|
+
lines.push(`- Owed ${stage}: ${reason}.`);
|
|
131
|
+
}
|
|
132
|
+
for (const stage of advisory) {
|
|
133
|
+
lines.push(`- Advisory ${stage}: the change since its last review is page-only, so it is reported and not required.`);
|
|
134
|
+
}
|
|
135
|
+
// The pages changed since the builds the owed stages reviewed, which
|
|
136
|
+
// record build keeps; without them, the drift from the assembly manifest.
|
|
137
|
+
const sincePages = new Map();
|
|
138
|
+
for (const stage of [...owed, ...advisory]) {
|
|
139
|
+
for (const page of ci[stage].change?.changed_pages || []) sincePages.set(page.built_path, page);
|
|
140
|
+
}
|
|
141
|
+
const changedPages = sincePages.size ? [...sincePages.values()] : ci.changes.changed_pages;
|
|
142
|
+
for (const page of changedPages) {
|
|
143
|
+
lines.push(`- Changed page: ${code(page.page_id)} (${code(page.built_path)})`);
|
|
144
|
+
}
|
|
145
|
+
if (!changedPages.length && (owed.length || ci.changes.status === "unknown")) {
|
|
146
|
+
lines.push(ci.changes.status === "unknown"
|
|
147
|
+
? `Changed pages unavailable: ${unavailablePageChanges.get(ci.changes.reason) ?? "the page-change reason is unrecognized."}`
|
|
148
|
+
: "Changed page ids unavailable: the retained assembly manifest may already describe the rebuilt output.");
|
|
149
|
+
}
|
|
150
|
+
for (const stage of ["polish", "qa"]) {
|
|
151
|
+
if (ci[stage].status === "unknown") lines.push(`- Unknown ${stage}: ${code(ci[stage].reason)}`);
|
|
152
|
+
}
|
|
153
|
+
if (ci.changes.status === "unknown") lines.push(`- Unknown page changes: ${code(ci.changes.reason)}`);
|
|
154
|
+
if (ci.status === "owed" && pageScoped && owed.length) {
|
|
155
|
+
const pages = ci.qa.owed_pages.map((page) => ` --page ${pageArg(page.page_id)}`).join("");
|
|
156
|
+
const recorded = ci.changes.recorded_build_fingerprint && ci.changes.recorded_build_fingerprint === ci.changes.current_build_fingerprint;
|
|
157
|
+
lines.push("", `Remedy: ${recorded ? "" : "run `campaigns-os record build --packet <packet>` for the rebuilt output, then "}run \`campaigns-os qa run --packet <packet>${pages}\` against that build. Polish capture is optional for a page-only change.`);
|
|
158
|
+
} else if (ci.status === "owed" || ci.status === "unknown") {
|
|
159
|
+
const matchingRequired = owed.length > 0
|
|
160
|
+
&& owed.every((stage) => requiredStageReasons.has(ci[stage].reason) && ci[stage].matches_current_build === true)
|
|
161
|
+
&& ["polish", "qa"].every((stage) => ci[stage].status !== "unknown");
|
|
162
|
+
if (matchingRequired) {
|
|
163
|
+
const commands = [];
|
|
164
|
+
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>`");
|
|
165
|
+
if (owed.includes("qa")) commands.push("`campaigns-os qa run --packet <packet>`");
|
|
166
|
+
lines.push("", `Remedy: re-run the required stages against the existing build: ${commands.join(", then ")}.`);
|
|
167
|
+
} else {
|
|
168
|
+
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.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
lines.push("", `Doctor inspection: ${(result.errors || []).length} error(s), ${(result.warnings || []).length} warning(s); CI exit status depends only on evidence debt.`);
|
|
172
|
+
if (result.errors?.length) {
|
|
173
|
+
lines.push(`Doctor errors: ${[...new Set(result.errors.map((issue) => issue.code))].map(code).join(", ")}.`);
|
|
174
|
+
}
|
|
175
|
+
lines.push("Use ordinary `doctor` or `--ci --json` for full inspection details.");
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Doctor entry points: the doctor command, packet inspection and built-output inspection.
|
|
2
2
|
import { resolveCampaignIdentity } from "../spec-source-identity.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import { assessDoctorCi, doctorCiFailure } from "./ci.mjs";
|
|
4
|
+
import { applyLocalPreviewToCheckpoint, applyLocalPreviewToPolishGate, applyPageOnlyChangeToCheckpoint, applyPageOnlyChangeToPolishGate, carriedForwardMessage, CARRIED_FORWARD } from "../local-preview-policy.mjs";
|
|
5
|
+
import { assessEvidenceDebt } from "../evidence-debt.mjs";
|
|
4
6
|
import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
5
7
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -10,7 +12,7 @@ import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledge
|
|
|
10
12
|
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
11
13
|
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
12
14
|
import { recordQcResults } from "../qc-results.mjs";
|
|
13
|
-
import { resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
15
|
+
import { packetRuntimeRelPath, resolveCampaignWorkspace } from "../campaign-workspace.mjs";
|
|
14
16
|
import { normalizePublicRouteSlug } from "../route-identity.mjs";
|
|
15
17
|
import { DEFAULT_PROXY_BASE } from "../spec-fetch.mjs";
|
|
16
18
|
import { disabledLiveRead, liveRefsDisabled, readLiveCampaignForPacket } from "../live-campaign-refs.mjs";
|
|
@@ -132,6 +134,13 @@ export async function readDoctorLiveCampaign(args, { fetchImpl = globalThis.fetc
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = undefined, qcStandIns = undefined } = {}) {
|
|
137
|
+
if (args.ci === true) {
|
|
138
|
+
try {
|
|
139
|
+
JSON.parse(readFileSync(resolve(requireArg(args, "packet")), "utf8"));
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return doctorCiFailure(error, { usage: true });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
135
144
|
// Non-packet mode (learnings L7): doctor a `campaign-build`'d page-kit
|
|
136
145
|
// campaign that has only a built _site/ and no full Build Packet. Resolves
|
|
137
146
|
// scope from the built output and runs the built-output residue/text/
|
|
@@ -153,6 +162,16 @@ export function doctorCommand(args, { runDoctor = doctorPacket, liveCampaign = u
|
|
|
153
162
|
...(qcStandIns !== undefined ? { qcStandIns } : {}),
|
|
154
163
|
};
|
|
155
164
|
const result = runDoctor(packetPath, doctorOptions);
|
|
165
|
+
if (args.ci === true) {
|
|
166
|
+
// Use precisely the context/report selection that inspection used,
|
|
167
|
+
// including explicit sidecar suppression and the context's report binding.
|
|
168
|
+
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
169
|
+
contextPath: doctorOptions.contextPath,
|
|
170
|
+
reportPath: doctorOptions.reportPath,
|
|
171
|
+
followContextPointer: true,
|
|
172
|
+
});
|
|
173
|
+
result.ci = assessDoctorCi({ packet: workspace.packet, packetPath, report: readJsonIfExists(workspace.reportPath) });
|
|
174
|
+
}
|
|
156
175
|
// Inspection and recording are separate operations. A laptop's untracked
|
|
157
176
|
// built output can be stale while the delivered build's evidence is valid.
|
|
158
177
|
// Only an explicit producer action may replace the retained proof artifacts;
|
|
@@ -440,6 +459,16 @@ export function doctorBuiltOutput(args) {
|
|
|
440
459
|
};
|
|
441
460
|
}
|
|
442
461
|
|
|
462
|
+
// Inspection already refused a packet whose runtime directory is unreadable;
|
|
463
|
+
// the history lookup then falls back to the project-level directory.
|
|
464
|
+
function packetRuntimeRelPathOrDefault(packetPath) {
|
|
465
|
+
try {
|
|
466
|
+
return packetRuntimeRelPath(JSON.parse(readFileSync(resolve(packetPath), "utf8")));
|
|
467
|
+
} catch {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
443
472
|
export function doctorPacket(packetPath, options = {}) {
|
|
444
473
|
const result = withHtmlScanSnapshot(() => inspectDoctorPacket(packetPath, options));
|
|
445
474
|
// Per-finding cause classification lives HERE, at the single production
|
|
@@ -469,6 +498,7 @@ export function doctorPacket(packetPath, options = {}) {
|
|
|
469
498
|
errors: result.errors,
|
|
470
499
|
warnings: result.warnings,
|
|
471
500
|
baseDir: comparableIdentity ? dirname(resolve(packetPath)) : null,
|
|
501
|
+
runtimeRelPath: comparableIdentity ? packetRuntimeRelPathOrDefault(packetPath) : undefined,
|
|
472
502
|
mapId: result.derived?.map_id || null,
|
|
473
503
|
localSpecId: result.derived?.local_spec_id || null,
|
|
474
504
|
});
|
|
@@ -569,16 +599,21 @@ function inspectDoctorPacket(packetPath, { contextPath = undefined, reportPath =
|
|
|
569
599
|
|
|
570
600
|
// The local preview policy (local-preview-policy.mjs) carries missing
|
|
571
601
|
// polish and page-load evidence forward as warnings on a local-serve packet.
|
|
572
|
-
|
|
602
|
+
// A page-only change since Polish last reviewed the campaign carries its
|
|
603
|
+
// stale evidence on any target: Polish is advisory for it (#720). The
|
|
604
|
+
// evidence debt is the one readback and doctor --ci assess.
|
|
605
|
+
const evidenceDebt = assessEvidenceDebt({ packet, packetPath, report });
|
|
606
|
+
derived.evidence_debt = evidenceDebt;
|
|
607
|
+
const polishCheckpointGate = applyPageOnlyChangeToCheckpoint(applyLocalPreviewToCheckpoint(
|
|
573
608
|
evaluateRecordedHiddenEagerMediaCheckpoint({ packet, report }),
|
|
574
609
|
{ packet, report },
|
|
575
|
-
);
|
|
610
|
+
), evidenceDebt);
|
|
576
611
|
derived.polish_checkpoint_gate = polishCheckpointGate;
|
|
577
|
-
const polishGate = applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
612
|
+
const polishGate = applyPageOnlyChangeToPolishGate(applyLocalPreviewToPolishGate(evaluatePolishGate({
|
|
578
613
|
report,
|
|
579
614
|
hiddenEagerMediaGate: polishCheckpointGate,
|
|
580
615
|
currentOutputFingerprint: derived.build_output_fingerprint?.value || null,
|
|
581
|
-
}), { packet, checkpointGate: polishCheckpointGate });
|
|
616
|
+
}), { packet, checkpointGate: polishCheckpointGate }), { debt: evidenceDebt, checkpointGate: polishCheckpointGate });
|
|
582
617
|
derived.polish_gate = polishGate;
|
|
583
618
|
if (polishGate.status === "blocked" && !polishGate.owned_checkpoint_only) {
|
|
584
619
|
pushGateIssue({ errors, warnings }, gateIssue("polish_gate", polishGate));
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -503,6 +503,13 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
503
503
|
}
|
|
504
504
|
if (cliStage === "qa" && qaRecordedBuildFingerprint(report) && currentBuildFingerprint(report)
|
|
505
505
|
&& !qaRecordedForCurrentBuild(report, currentBuildFingerprint(report))) {
|
|
506
|
+
// A page-only change since the build QA reviewed owes QA on the
|
|
507
|
+
// changed pages only (#720); qa run --page pays it.
|
|
508
|
+
const qaDebt = derived?.evidence_debt?.qa;
|
|
509
|
+
if (qaDebt?.scope === "pages" && qaDebt.owed_pages?.length) {
|
|
510
|
+
const pages = qaDebt.owed_pages.map((page) => `--page ${page.page_id}`).join(" ");
|
|
511
|
+
return { stage: "qa", reason: `QA was recorded for a different build, and the change since then is page-only: run QA on the changed pages with ${cmd("qa")} run --packet <packet> ${pages}.` };
|
|
512
|
+
}
|
|
506
513
|
return { stage: "qa", reason: "QA was recorded for a different build; run QA against the current build." };
|
|
507
514
|
}
|
|
508
515
|
// A terminal QA status is not the same claim as purchase proof. QA finalizes
|
|
@@ -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
|
+
}
|