@nextcommerce/campaigns-os 1.41.2 → 1.43.1
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 +178 -0
- package/README.md +6 -4
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/effects.v1.json +111 -18
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +752 -0
- package/contracts/supported-surface.json +12 -11
- package/docs/build-packet.md +56 -6
- package/docs/campaigns-os-build-flow.md +2 -2
- package/docs/effects.md +70 -1
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +6 -0
- package/docs/qa-and-test-orders.md +26 -10
- package/docs/runtime-readiness.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +3 -2
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +6 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-progress-snapshot.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +1 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +1 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +13 -8
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +8 -6
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +17 -4
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +10 -9
- package/skills.json +11 -11
- package/src/cli.mjs +187 -70
- package/src/finding-cause.mjs +14 -10
- package/src/lifecycle.mjs +5 -4
- package/src/polish-node.mjs +5 -2
- package/src/progress-node.mjs +3 -2
- package/src/progress.mjs +5 -3
- package/src/qa-node.mjs +58 -26
- package/src/qa-publish.mjs +4 -0
- package/src/qa-sidecar.mjs +2 -0
- package/src/qa-verdict-discovery.mjs +11 -0
- package/src/qa-verdict-publish.mjs +1 -0
- package/src/qa-verdict.mjs +8 -1
- package/src/readback.mjs +2 -1
- package/src/run-record-closeout.mjs +3 -4
- package/src/run-record.mjs +4 -0
- package/src/sidecar-bundle.mjs +21 -0
- package/src/spec-source-identity.mjs +44 -0
- package/src/stage-ledger.mjs +4 -1
- package/src/tooling-setup.mjs +160 -0
package/src/lifecycle.mjs
CHANGED
|
@@ -78,10 +78,11 @@ export function runWithRefusalScope(fn) {
|
|
|
78
78
|
* the throw, because the paths that catch a refusal to render it (waiveOrRefuse)
|
|
79
79
|
* hand onFinish no error to inspect. The cost of marking early is that a
|
|
80
80
|
* `refused()` built inside a `try` that discards it would suppress the journal
|
|
81
|
-
* entry for an invocation whose handler did run.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* entry for an invocation whose handler did run. The optional QA progress
|
|
82
|
+
* probe checks for a packet before its swallowing `try`; closeRunSession runs
|
|
83
|
+
* its nested run-record attempt in a separate refusal scope. Keep those
|
|
84
|
+
* boundaries: if you need to probe an argument, test for it rather than
|
|
85
|
+
* constructing a refusal speculatively.
|
|
85
86
|
*/
|
|
86
87
|
export function refused(message) {
|
|
87
88
|
const store = refusalScope.getStore();
|
package/src/polish-node.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { HIDDEN_EAGER_MEDIA_ACTIONS } from "./gate-actions.mjs";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -330,8 +331,8 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
330
331
|
const slug = captureCampaignSlug(packet, report);
|
|
331
332
|
const packetMapId = nonemptyString(packet?.spec?.map_id);
|
|
332
333
|
const reportMapId = nonemptyString(report?.identity?.map_id);
|
|
333
|
-
if (!
|
|
334
|
-
throw new Error("polish capture requires matching packet and Assembly Report
|
|
334
|
+
if (!campaignIdentitiesMatch(packet?.spec, report?.identity)) {
|
|
335
|
+
throw new Error("polish capture requires matching packet and Assembly Report campaign identities.");
|
|
335
336
|
}
|
|
336
337
|
const buildFingerprint = currentBuildFingerprint(report);
|
|
337
338
|
if (!buildFingerprint) throw new Error("polish capture requires a strict current Assembly Report build fingerprint.");
|
|
@@ -360,6 +361,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
360
361
|
resolved_path: resolvedPacketPath,
|
|
361
362
|
resolved_target_repo: resolvedTargetRepo,
|
|
362
363
|
map_id: packetMapId,
|
|
364
|
+
...localSpecIdentityFields(packet.spec),
|
|
363
365
|
campaign_slug: slug,
|
|
364
366
|
route_root: nonemptyString(packet?.campaign?.route_root),
|
|
365
367
|
target_repo: nonemptyString(packet?.assembly?.target_repo),
|
|
@@ -368,6 +370,7 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
|
|
|
368
370
|
run_id: runId,
|
|
369
371
|
identity: {
|
|
370
372
|
map_id: reportMapId,
|
|
373
|
+
...localSpecIdentityFields(report.identity),
|
|
371
374
|
public_route_slug: nonemptyString(report?.identity?.public_route_slug),
|
|
372
375
|
spec_hash: nonemptyString(report?.identity?.spec_hash),
|
|
373
376
|
},
|
package/src/progress-node.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { campaignSpecIdentity, campaignIdentitiesMatch, localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
1
2
|
// Best-effort producer adapter. Sanitized immutable bytes are durable before delivery.
|
|
2
3
|
import {randomBytes,createHash} from 'node:crypto';
|
|
3
4
|
import {mkdirSync,readFileSync,writeFileSync,renameSync,rmSync,readdirSync,lstatSync} from 'node:fs';
|
|
@@ -46,7 +47,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
46
47
|
const aligned=contextBound&&mapId&&localMapId===mapId&&baseline?.map_id===mapId&&baseline?.algorithm==='map-store-v1'&&hash(baseline.hash)&&localHash&&localHash===hash(baseline.local_spec_material_hash);
|
|
47
48
|
const build=hash(doctor?.derived?.build_output_fingerprint?.value);
|
|
48
49
|
const recordedBuild=hash(report?.stages?.assembly?.build_fingerprint);
|
|
49
|
-
const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&
|
|
50
|
+
const reportBound=doctor?.derived?.prepare_build_gate?.binding_failure!==true&&contextBound&&campaignIdentitiesMatch(packet?.spec,campaignSpecIdentity(spec))&&campaignIdentitiesMatch(packet?.spec,report?.identity)&&localHash&&localHash===hash(report?.identity?.spec_material_hash);
|
|
50
51
|
const qaSource=hash(report?.stages?.qa?.evidence?.source_build_fingerprint);
|
|
51
52
|
const verdict=qaResult?.verdict;
|
|
52
53
|
const qa=verdict?{
|
|
@@ -60,7 +61,7 @@ export function projectProgressObservation({workspace,context,report,doctor,cont
|
|
|
60
61
|
const preview=typeof packet?.deploy?.preview_url==='string'&&packet.deploy.preview_url?packet.deploy.preview_url:null;
|
|
61
62
|
return {
|
|
62
63
|
schema_version:PROGRESS_SCHEMA_VERSION,package_version:packageVersion,producer:qaResult?'qa':'next',
|
|
63
|
-
identity:{map_id:mapId,map_revision_hash:contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null),map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
|
|
64
|
+
identity:{map_id:mapId,...localSpecIdentityFields(packet?.spec),map_revision_hash:mapId?(contextBound&&baseline?.map_id===mapId?hash(baseline?.hash):(localMapId===mapId?hash(spec?.spec_identity?.spec_hash||spec?.spec_hash):null)):null,map_revision_algorithm:'map-store-v1',saved_revision_alignment:aligned?'aligned':'unconfirmed',local_spec_material_hash:localHash,local_spec_material_algorithm:'campaign-spec-material-v1',build_fingerprint:build,build_fingerprint_algorithm:'sha256-manifest/v1'},
|
|
64
65
|
stages:PROGRESS_STAGES.map(stage=>{
|
|
65
66
|
const status=reportBound?accepted(report?.stages?.[stage]?.status,PROGRESS_STAGE_STATUSES):'unknown';
|
|
66
67
|
const source=stage==='assembly'?recordedBuild:stage==='qa'?qaSource:null;
|
package/src/progress.mjs
CHANGED
|
@@ -22,7 +22,7 @@ const str = pattern => ({type:'string', pattern});
|
|
|
22
22
|
const hash = {type:['string','null'], pattern:'^sha256:[0-9a-f]{64}$'};
|
|
23
23
|
const opaque = {type:['string','null'], pattern:'^[A-Za-z0-9_-]{1,64}$'};
|
|
24
24
|
const enumeration = values => ({enum:values});
|
|
25
|
-
const object = properties => ({type:'object', additionalProperties:false, required:Object.keys(properties), properties});
|
|
25
|
+
const object = (properties, optional=[]) => ({type:'object', additionalProperties:false, required:Object.keys(properties).filter(key=>!optional.includes(key)), properties});
|
|
26
26
|
export const PROGRESS_SNAPSHOT_SCHEMA = {
|
|
27
27
|
$schema:'https://json-schema.org/draft/2020-12/schema',
|
|
28
28
|
$id:'https://nextcommerce.com/schemas/campaigns-os-progress-snapshot.v0.schema.json',
|
|
@@ -32,10 +32,10 @@ export const PROGRESS_SNAPSHOT_SCHEMA = {
|
|
|
32
32
|
stream_id:str('^progress_[0-9a-f]{32}$'), sequence:{type:'integer',minimum:1,maximum:2147483647},
|
|
33
33
|
previous_snapshot_id:hash, observed_at:str('^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$'),
|
|
34
34
|
package_version:str('^\\d{1,6}\\.\\d{1,6}\\.\\d{1,6}$'), producer:enumeration(['next','qa']),
|
|
35
|
-
identity:object({map_id:opaque,map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
|
|
35
|
+
identity:object({map_id:opaque,local_spec_id:str('^[A-Za-z0-9_-]{1,64}$'),map_revision_hash:hash,map_revision_algorithm:{const:'map-store-v1'},
|
|
36
36
|
saved_revision_alignment:enumeration(['aligned','unconfirmed']),local_spec_material_hash:hash,
|
|
37
37
|
local_spec_material_algorithm:{const:'campaign-spec-material-v1'},build_fingerprint:hash,
|
|
38
|
-
build_fingerprint_algorithm:{const:'sha256-manifest/v1'}}),
|
|
38
|
+
build_fingerprint_algorithm:{const:'sha256-manifest/v1'}},['local_spec_id']),
|
|
39
39
|
stages:{type:'array',minItems:6,maxItems:6,items:object({stage:enumeration(PROGRESS_STAGES),status:enumeration(PROGRESS_STAGE_STATUSES),
|
|
40
40
|
build_binding:enumeration(['matching','unconfirmed']),source_build_fingerprint:hash})},
|
|
41
41
|
preview:object({present:{type:'boolean'},url_hash:hash}),
|
|
@@ -46,6 +46,7 @@ export const PROGRESS_SNAPSHOT_SCHEMA = {
|
|
|
46
46
|
binding:enumeration(['matching','unconfirmed']),publish_state:enumeration(['skipped','ok','failed','unknown'])})]},
|
|
47
47
|
}),
|
|
48
48
|
};
|
|
49
|
+
|
|
49
50
|
export function canonicalProgressJson(value) {
|
|
50
51
|
if (Array.isArray(value)) return `[${value.map(canonicalProgressJson).join(',')}]`;
|
|
51
52
|
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key=>`${JSON.stringify(key)}:${canonicalProgressJson(value[key])}`).join(',')}}`;
|
|
@@ -92,6 +93,7 @@ export function validateProgressSnapshot(value) {
|
|
|
92
93
|
}
|
|
93
94
|
if (!check(value,PROGRESS_SNAPSHOT_SCHEMA)) errors.push('progress.invalid_shape');
|
|
94
95
|
if (!errors.length && (new Set(value.stages.map(stage=>stage.stage)).size!==6 || value.stages.some((stage,index)=>stage.stage!==PROGRESS_STAGES[index]))) errors.push('progress.invalid_stages');
|
|
96
|
+
if (!errors.length && value.identity.local_spec_id && (value.identity.map_id || value.identity.map_revision_hash || value.identity.saved_revision_alignment !== 'unconfirmed')) errors.push('progress.invalid_local_identity');
|
|
95
97
|
if (!errors.length && value.identity.saved_revision_alignment==='aligned' && (!value.identity.map_id||!value.identity.map_revision_hash||!value.identity.local_spec_material_hash)) errors.push('progress.invalid_alignment');
|
|
96
98
|
if (!errors.length && value.continuation.gates.some(gate=>gate.id==='unknown'&&gate.state!=='unknown')) errors.push('progress.unsupported_authority');
|
|
97
99
|
if (!errors.length && value.sequence===1 && value.previous_snapshot_id!==null) errors.push('progress.invalid_chain');
|
package/src/qa-node.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
1
2
|
import { expectedBinding, createBindingScriptLoader, observeBinding, bindingAssertion } from './qa-binding-evidence.mjs';
|
|
2
3
|
import { shellToken } from "./shell-token.mjs";
|
|
3
4
|
import { requiredActionText } from "./gate-actions.mjs";
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
import { singleLineFragment } from "./text-safety.mjs";
|
|
14
15
|
import { absentOrMalformed } from "./fs-identity.mjs";
|
|
15
16
|
import { DEFAULT_PROXY_BASE, fetchSpecByMapId } from "./spec-fetch.mjs";
|
|
16
|
-
import { specMaterialHash } from "./spec-identity.mjs";
|
|
17
|
+
import { specMaterialHash, specHashesMatch } from "./spec-identity.mjs";
|
|
17
18
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
18
19
|
import { PLAYWRIGHT_INSTALL_HINT } from "./browser-launch.mjs";
|
|
19
20
|
import { dirname, join, relative, resolve, isAbsolute } from "node:path";
|
|
@@ -345,9 +346,20 @@ async function resolveQaInputs(args, {
|
|
|
345
346
|
readJsonFile = readJson,
|
|
346
347
|
loadCampaignEntry = loadPageKitCampaignEntry,
|
|
347
348
|
} = {}) {
|
|
349
|
+
// Selector flags without a non-empty value are argv-only refusals for both
|
|
350
|
+
// qa run and qa resolve. Check them before checkpoint preflight or site reads.
|
|
351
|
+
for (const flag of ["packet", "site", "built", "map-id"]) {
|
|
352
|
+
if (Object.hasOwn(args, flag) && !stringArg(args[flag])) {
|
|
353
|
+
throw refused(`Missing value for --${flag}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
348
356
|
if (args.packet && args.spec) {
|
|
349
357
|
throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
|
|
350
358
|
}
|
|
359
|
+
// No non-empty campaign selector in argv is also decided before any read.
|
|
360
|
+
if (![args.packet, args.site, args.built, args._[2], args["map-id"]].some(stringArg)) {
|
|
361
|
+
throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
|
|
362
|
+
}
|
|
351
363
|
// Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
|
|
352
364
|
// that has only a built _site/ and a served URL — no Build Packet, no Map ID,
|
|
353
365
|
// no CampaignSpec. Scope (pages + funnel types) is resolved from the built
|
|
@@ -371,15 +383,13 @@ async function resolveQaInputs(args, {
|
|
|
371
383
|
const mapId = stringArg(args["map-id"])
|
|
372
384
|
|| stringArg(args._[2])
|
|
373
385
|
|| stringArg(packet?.spec?.map_id);
|
|
374
|
-
// A
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// for the same identity question, and one message cannot be two verdicts.)
|
|
382
|
-
if (!mapId) throw refused("QA requires a Map ID. Provide --packet or positional <map-id>.");
|
|
386
|
+
// A named packet has been read by checkpoint preflight. Its missing or
|
|
387
|
+
// conflicting identity is a handler failure, not an argv-only refusal.
|
|
388
|
+
const localSpecId = packet?.spec?.local_spec_id ?? null;
|
|
389
|
+
if (!mapId && !localSpecId) throw new Error("QA requires a Map ID or a local-spec packet. The named Build Packet has no campaign identity.");
|
|
390
|
+
if (packet && localSpecId != null && (!resolveCampaignIdentity(packet.spec) || mapId)) {
|
|
391
|
+
throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
|
|
392
|
+
}
|
|
383
393
|
|
|
384
394
|
const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
|
|
385
395
|
const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
|
|
@@ -404,6 +414,13 @@ async function resolveQaInputs(args, {
|
|
|
404
414
|
specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
|
|
405
415
|
}
|
|
406
416
|
|
|
417
|
+
if (!packet && rawSpec?.spec_identity?.local_spec_id != null) {
|
|
418
|
+
throw new Error("Local-spec QA requires --packet; a local spec cannot be published under a Map ID.");
|
|
419
|
+
}
|
|
420
|
+
if (packet && (localSpecId != null || rawSpec?.spec_identity?.local_spec_id != null)
|
|
421
|
+
&& !campaignIdentitiesMatch(packet.spec, campaignSpecIdentity(rawSpec))) {
|
|
422
|
+
throw new Error("Packet local_spec_id does not match the local CampaignSpec identity. Re-run prepare-build from the intended spec.");
|
|
423
|
+
}
|
|
407
424
|
const normalized = normalizeSpec(rawSpec);
|
|
408
425
|
const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
|
|
409
426
|
const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
|
|
@@ -449,6 +466,7 @@ async function resolveQaInputs(args, {
|
|
|
449
466
|
packetPath,
|
|
450
467
|
packet,
|
|
451
468
|
mapId,
|
|
469
|
+
localSpecId,
|
|
452
470
|
publicRouteSlug,
|
|
453
471
|
proxyBase,
|
|
454
472
|
baseUrl,
|
|
@@ -478,6 +496,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
478
496
|
} = {}) {
|
|
479
497
|
const packetPath = resolve(String(args.packet));
|
|
480
498
|
const packet = readJsonFile(packetPath);
|
|
499
|
+
if (packet?.spec?.local_spec_id != null && (!resolveCampaignIdentity(packet.spec)
|
|
500
|
+
|| stringArg(args["map-id"]) || stringArg(args._?.[2]))) {
|
|
501
|
+
throw new Error("Local-spec packet QA cannot use a Map ID override or an ambiguous identity.");
|
|
502
|
+
}
|
|
481
503
|
const specPath = stringArg(args.spec)
|
|
482
504
|
? resolve(String(args.spec))
|
|
483
505
|
: stringArg(packet?.spec?.local_path)
|
|
@@ -513,6 +535,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
513
535
|
report = null;
|
|
514
536
|
}
|
|
515
537
|
}
|
|
538
|
+
if (packet?.spec?.local_spec_id != null && (!reportMatchesPacketIdentity(report, packet)
|
|
539
|
+
|| (specStatus === "ok" && !specHashesMatch(report.identity?.spec_material_hash, computeSpecHash(rawSpec))))) {
|
|
540
|
+
throw new Error("Local-spec QA requires the matching Assembly Report and current spec material hash. Re-run prepare-build after a material revision; do not reuse foreign or stale proof.");
|
|
541
|
+
}
|
|
516
542
|
const checkpointGates = [
|
|
517
543
|
evaluatePageKitStoreProfile({
|
|
518
544
|
specCampaign: rawSpec?.campaign || null,
|
|
@@ -548,12 +574,9 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
548
574
|
|
|
549
575
|
function reportMatchesPacketIdentity(report, packet) {
|
|
550
576
|
if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
|
|
551
|
-
const packetMapId = stringArg(packet?.spec?.map_id);
|
|
552
|
-
const reportMapId = stringArg(report.identity.map_id);
|
|
553
577
|
const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
554
578
|
const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
|
|
555
|
-
return
|
|
556
|
-
&& packetMapId === reportMapId
|
|
579
|
+
return campaignIdentitiesMatch(packet?.spec, report.identity)
|
|
557
580
|
&& !!packetSlug
|
|
558
581
|
&& packetSlug === reportSlug;
|
|
559
582
|
}
|
|
@@ -640,7 +663,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
640
663
|
brandContractStatus: "not_evaluated",
|
|
641
664
|
packetPath: preflight.packetPath,
|
|
642
665
|
packet: preflight.packet,
|
|
643
|
-
mapId: stringArg(preflight.packet?.spec?.map_id) || "unknown-map",
|
|
666
|
+
mapId: stringArg(preflight.packet?.spec?.map_id) || (preflight.packet?.spec?.local_spec_id ? null : "unknown-map"),
|
|
667
|
+
localSpecId: preflight.packet?.spec?.local_spec_id ?? null,
|
|
644
668
|
publicRouteSlug,
|
|
645
669
|
proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
|
|
646
670
|
baseUrl: inputBaseUrl,
|
|
@@ -666,18 +690,18 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
666
690
|
// yields "not_applicable" rather than blocking, so browser QA still runs the
|
|
667
691
|
// residue/placeholder/demo gates. Test orders are not attempted (no policy).
|
|
668
692
|
export function resolveQaInputsFromSite(args) {
|
|
669
|
-
const targetRepo = resolve(String(args.site || args.built));
|
|
670
|
-
const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
|
|
671
|
-
if (!scope.ok) {
|
|
672
|
-
throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
|
|
673
|
-
}
|
|
674
693
|
const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
|
|
675
694
|
if (!baseUrl) {
|
|
676
|
-
throw
|
|
695
|
+
throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
|
|
677
696
|
}
|
|
678
697
|
const templateFamily = stringArg(args.family);
|
|
679
698
|
if (!templateFamily) {
|
|
680
|
-
throw
|
|
699
|
+
throw refused("Non-packet site QA requires --family <template-family> so residue, placeholder, and demo-asset gates can load the family brand contract.");
|
|
700
|
+
}
|
|
701
|
+
const targetRepo = resolve(String(args.site || args.built));
|
|
702
|
+
const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
|
|
703
|
+
if (!scope.ok) {
|
|
704
|
+
throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
|
|
681
705
|
}
|
|
682
706
|
const brandContract = loadBrandContract(templateFamily);
|
|
683
707
|
if (brandContract.status !== "loaded" || !brandContract.contract) {
|
|
@@ -1506,6 +1530,7 @@ function resolvePayload(resolved, { routeProbe = null } = {}) {
|
|
|
1506
1530
|
ok: status !== "blocked" && status !== "routes_unresolved",
|
|
1507
1531
|
status,
|
|
1508
1532
|
map_id: resolved.mapId,
|
|
1533
|
+
...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
|
|
1509
1534
|
...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
|
|
1510
1535
|
...reportPathField(resolved),
|
|
1511
1536
|
...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
|
|
@@ -2311,12 +2336,14 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2311
2336
|
baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
|
|
2312
2337
|
targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
|
|
2313
2338
|
mapId: resolved.mapId,
|
|
2339
|
+
localSpecId: resolved.localSpecId,
|
|
2314
2340
|
currentRunId: runId,
|
|
2315
2341
|
isFinding: isFindingAssertion,
|
|
2316
2342
|
});
|
|
2317
2343
|
const verdict = createVerdict({
|
|
2318
2344
|
runId,
|
|
2319
2345
|
mapId: resolved.mapId,
|
|
2346
|
+
localSpecId: resolved.localSpecId,
|
|
2320
2347
|
publicRouteSlug: resolved.publicRouteSlug || null,
|
|
2321
2348
|
campaignRefId: resolved.spec.campaign?.ref_id || null,
|
|
2322
2349
|
specVersion: resolved.specVersion,
|
|
@@ -2370,7 +2397,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2370
2397
|
// config, or scope mismatch should be visible on the QA path too, not just
|
|
2371
2398
|
// on remit (Kilo review, PR #177).
|
|
2372
2399
|
const consent = resolveConsent({ proxyBase: resolved.proxyBase });
|
|
2373
|
-
const publishDecision =
|
|
2400
|
+
const publishDecision = resolved.localSpecId
|
|
2401
|
+
? { publish: false, reason: "local_spec", flag_invalid: false }
|
|
2402
|
+
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
|
|
2374
2403
|
if (publishDecision.flag_invalid) {
|
|
2375
2404
|
process.stderr.write(`[campaigns-os] --post-verdict "${args["post-verdict"]}" is not a recognized value (use true|1|yes|y|on or false|0|no|n|off); the flag was ignored and the default publish decision applied.\n`);
|
|
2376
2405
|
}
|
|
@@ -2390,6 +2419,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2390
2419
|
status: verdict.disposition,
|
|
2391
2420
|
run_id: verdict.run_id,
|
|
2392
2421
|
map_id: resolved.mapId,
|
|
2422
|
+
...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
|
|
2393
2423
|
public_route_slug: resolved.publicRouteSlug || null,
|
|
2394
2424
|
...reportPathField(resolved),
|
|
2395
2425
|
base_url: resolved.baseUrl,
|
|
@@ -3015,7 +3045,7 @@ function output(value, args) {
|
|
|
3015
3045
|
}
|
|
3016
3046
|
if (value.verdict) {
|
|
3017
3047
|
console.log(`QA run complete.`);
|
|
3018
|
-
console.log(
|
|
3048
|
+
console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
|
|
3019
3049
|
console.log(`Base URL: ${value.base_url || "(missing)"}`);
|
|
3020
3050
|
printEntryUrlLines(value.entry_urls);
|
|
3021
3051
|
console.log(`Run ID: ${value.run_id}`);
|
|
@@ -3048,7 +3078,7 @@ function output(value, args) {
|
|
|
3048
3078
|
}
|
|
3049
3079
|
console.log(`QA resolve complete.`);
|
|
3050
3080
|
console.log(`Status: ${value.status}`);
|
|
3051
|
-
console.log(
|
|
3081
|
+
console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
|
|
3052
3082
|
console.log(`Spec: ${value.spec_source}`);
|
|
3053
3083
|
console.log(`Base URL: ${value.base_url || "(missing)"}`);
|
|
3054
3084
|
printEntryUrlLines(value.entry_urls);
|
|
@@ -3198,7 +3228,9 @@ export function qaResolveNextProofLines(value) {
|
|
|
3198
3228
|
return [
|
|
3199
3229
|
`Next expected proof: ${qaRunCommandFromResolve(value)}`,
|
|
3200
3230
|
`Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
|
|
3201
|
-
|
|
3231
|
+
value.local_spec_id
|
|
3232
|
+
? "Typed-card test orders use global test cards (no transactions/no permission gate); local-spec QA stays in the repository."
|
|
3233
|
+
: "Typed-card test orders use global test cards (no transactions/no permission gate); QA publishes to the portal by default.",
|
|
3202
3234
|
];
|
|
3203
3235
|
}
|
|
3204
3236
|
|
package/src/qa-publish.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export const QA_PUBLISH_STATUSES = Object.freeze({
|
|
|
46
46
|
// thrown error, so --json readers get a code to branch on.
|
|
47
47
|
export const QA_PUBLISH_REFUSALS = Object.freeze({
|
|
48
48
|
packet_required: "packet_required",
|
|
49
|
+
local_spec: "local_spec",
|
|
49
50
|
order_flags_refused: "order_flags_refused",
|
|
50
51
|
verdict_missing: "verdict_missing",
|
|
51
52
|
verdict_unreadable: "verdict_unreadable",
|
|
@@ -218,6 +219,9 @@ async function attemptPublish(args, operations, dryRun) {
|
|
|
218
219
|
return refusal(QA_PUBLISH_REFUSALS.packet_required, `Build Packet ${packetPath} is not readable (${error.code || error.message}).`);
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
if (packet?.spec?.local_spec_id != null) {
|
|
223
|
+
return refusal(QA_PUBLISH_REFUSALS.local_spec, "Local-spec QA has no saved Map destination; keep its verdict in the repository. Portal publication requires a saved Map and fresh evidence.");
|
|
224
|
+
}
|
|
221
225
|
const source = resolveStoredVerdictSource({ args, packetPath, packet, readJsonFile: ops.readJsonFile, exists: ops.exists });
|
|
222
226
|
if (source.error) return source.error;
|
|
223
227
|
let verdict;
|
package/src/qa-sidecar.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localSpecIdentityFields } from "./spec-source-identity.mjs";
|
|
1
2
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -77,6 +78,7 @@ export function projectVerdictForSidecar(verdict, { generatedAt }) {
|
|
|
77
78
|
schema_version: verdict.schema_version,
|
|
78
79
|
run_id: verdict.run_id,
|
|
79
80
|
campaign_slug: verdict.campaign_slug,
|
|
81
|
+
...localSpecIdentityFields(verdict),
|
|
80
82
|
...(verdict.public_route_slug != null ? { public_route_slug: verdict.public_route_slug } : {}),
|
|
81
83
|
...(verdict.campaign_ref_id != null ? { campaign_ref_id: verdict.campaign_ref_id } : {}),
|
|
82
84
|
spec_version: verdict.spec_version,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
|
|
1
2
|
// QA verdict discovery: the one walk over the local verdicts a campaign has
|
|
2
3
|
// written, with the projections each reader needs.
|
|
3
4
|
//
|
|
@@ -28,11 +29,20 @@ function qaVerdictSlug(packet) {
|
|
|
28
29
|
// the slug as identity. A verdict is this campaign's when its campaign_slug
|
|
29
30
|
// is one of them.
|
|
30
31
|
export function qaVerdictIdentifiers(packet) {
|
|
32
|
+
if (packet?.spec?.local_spec_id != null) {
|
|
33
|
+
const identity = resolveCampaignIdentity(packet.spec);
|
|
34
|
+
return identity?.kind === "local_spec" ? [localQaIdentifier(identity.id)] : [];
|
|
35
|
+
}
|
|
31
36
|
return [...new Set([optionalString(packet?.spec?.map_id), qaVerdictSlug(packet)].filter(Boolean))];
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
export function qaVerdictIdentityMatch(verdict, packet) {
|
|
35
40
|
const slug = optionalString(verdict?.campaign_slug);
|
|
41
|
+
if (packet?.spec?.local_spec_id != null || verdict?.local_spec_id != null) {
|
|
42
|
+
const identity = resolveCampaignIdentity(packet?.spec);
|
|
43
|
+
return identity?.kind === "local_spec" && verdict?.local_spec_id === identity.id
|
|
44
|
+
&& slug === localQaIdentifier(identity.id);
|
|
45
|
+
}
|
|
36
46
|
return Boolean(slug) && qaVerdictIdentifiers(packet).includes(slug);
|
|
37
47
|
}
|
|
38
48
|
|
|
@@ -174,6 +184,7 @@ export function qaVerdictCandidateScore(candidate, packet) {
|
|
|
174
184
|
const mapId = optionalString(packet?.spec?.map_id);
|
|
175
185
|
const slug = qaVerdictSlug(packet);
|
|
176
186
|
let score = 0;
|
|
187
|
+
if (packet?.spec?.local_spec_id && qaVerdictIdentityMatch(verdict, packet)) score += 100;
|
|
177
188
|
if (mapId && verdict.campaign_slug === mapId) score += 100;
|
|
178
189
|
if (slug && verdict.campaign_slug === slug) score += 80;
|
|
179
190
|
if (verdict.schema_version === "1.0" || verdict.schema_version === "campaigns-os-qa-verdict/v0") score += 10;
|
|
@@ -42,6 +42,7 @@ export const QA_VERDICT_PUBLISH_STATES = Object.freeze(["skipped", "ok", "failed
|
|
|
42
42
|
* must not claim a credential is travelling in clear.
|
|
43
43
|
*/
|
|
44
44
|
export async function publishQaVerdict(verdict, proxyBase, { fetchImpl = globalThis.fetch, remitImpl = remit } = {}) {
|
|
45
|
+
if (verdict?.local_spec_id != null) return { ...skippedQaVerdictPublish(), reason: "local_spec" };
|
|
45
46
|
let httpStatus = null;
|
|
46
47
|
let response = null;
|
|
47
48
|
let failure = null;
|
package/src/qa-verdict.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveCampaignIdentity, localQaIdentifier } from "./spec-source-identity.mjs";
|
|
1
2
|
export const QA_SCHEMA_VERSION = "1.0";
|
|
2
3
|
|
|
3
4
|
export const STATUS = Object.freeze({
|
|
@@ -95,6 +96,7 @@ function dispositionWithCommercial(assertions, commercial) {
|
|
|
95
96
|
export function createVerdict({
|
|
96
97
|
runId,
|
|
97
98
|
mapId,
|
|
99
|
+
localSpecId = null,
|
|
98
100
|
publicRouteSlug = null,
|
|
99
101
|
campaignRefId = null,
|
|
100
102
|
specVersion,
|
|
@@ -126,7 +128,8 @@ export function createVerdict({
|
|
|
126
128
|
run_id: runId,
|
|
127
129
|
// campaign_slug carries the Map ID for schema back-compat; the true public
|
|
128
130
|
// route slug rides alongside so consumers stop conflating the two.
|
|
129
|
-
campaign_slug: mapId,
|
|
131
|
+
campaign_slug: localSpecId ? localQaIdentifier(localSpecId) : mapId,
|
|
132
|
+
...(localSpecId ? { local_spec_id: localSpecId } : {}),
|
|
130
133
|
public_route_slug: optionalString(publicRouteSlug),
|
|
131
134
|
campaign_ref_id: campaignRefId,
|
|
132
135
|
spec_version: specVersion,
|
|
@@ -208,6 +211,10 @@ export function deriveExceptions(assertions = []) {
|
|
|
208
211
|
|
|
209
212
|
export function validateVerdict(verdict) {
|
|
210
213
|
const errors = [];
|
|
214
|
+
if (verdict?.local_spec_id != null && (!resolveCampaignIdentity({ local_spec_id: verdict.local_spec_id })
|
|
215
|
+
|| verdict.campaign_slug !== localQaIdentifier(verdict.local_spec_id))) {
|
|
216
|
+
errors.push("Local-spec verdict requires a valid local_spec_id and its local QA identifier.");
|
|
217
|
+
}
|
|
211
218
|
if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
|
|
212
219
|
return ["verdict: must be an object"];
|
|
213
220
|
}
|
package/src/readback.mjs
CHANGED
|
@@ -1187,6 +1187,7 @@ function renderIdentity(views, lines) {
|
|
|
1187
1187
|
const spec = packet.spec || {};
|
|
1188
1188
|
const campaign = packet.campaign || {};
|
|
1189
1189
|
if (spec.map_id) entries.push(["map_id", spec.map_id, "build packet"]);
|
|
1190
|
+
if (spec.local_spec_id) entries.push(["local_spec_id", spec.local_spec_id, "build packet"]);
|
|
1190
1191
|
if (campaign.public_route_slug) {
|
|
1191
1192
|
entries.push(["public_route_slug", campaign.public_route_slug, "build packet"]);
|
|
1192
1193
|
}
|
|
@@ -1194,7 +1195,7 @@ function renderIdentity(views, lines) {
|
|
|
1194
1195
|
if (assembly.template_family) entries.push(["template_family", assembly.template_family, "build packet"]);
|
|
1195
1196
|
} else if (doctor) {
|
|
1196
1197
|
const derived = doctor.derived || {};
|
|
1197
|
-
for (const field of ["map_id", "public_route_slug", "template_family"]) {
|
|
1198
|
+
for (const field of ["map_id", "local_spec_id", "public_route_slug", "template_family"]) {
|
|
1198
1199
|
if (derived[field]) entries.push([field, derived[field], "doctor output"]);
|
|
1199
1200
|
}
|
|
1200
1201
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
1
2
|
// Run Record closeout recognition.
|
|
2
3
|
//
|
|
3
4
|
// `next` at stage "done" used to demand a Run Record unconditionally, because
|
|
@@ -93,13 +94,11 @@ function qaVerdictDigests(record) {
|
|
|
93
94
|
* satisfy closeout — and `run-record` never re-emits under its id.
|
|
94
95
|
*/
|
|
95
96
|
export function identityMatches(record, packet) {
|
|
96
|
-
const mapId = text(packet?.spec?.map_id);
|
|
97
97
|
const slug = text(packet?.campaign?.public_route_slug);
|
|
98
98
|
const identity = isObject(record?.identity) ? record.identity : {};
|
|
99
|
-
const recordMapId = text(identity.map_id);
|
|
100
99
|
const recordSlug = text(identity.campaign_slug);
|
|
101
|
-
if (!
|
|
102
|
-
return
|
|
100
|
+
if (!slug || !recordSlug) return false;
|
|
101
|
+
return campaignIdentitiesMatch(identity, packet?.spec) && recordSlug === slug;
|
|
103
102
|
}
|
|
104
103
|
|
|
105
104
|
function outcome(reason_code, detail, entry = null) {
|
package/src/run-record.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localSpecIdentityFields, resolveCampaignIdentity } from "./spec-source-identity.mjs";
|
|
1
2
|
// Run Telemetry — per-run Run Record capture for Campaigns OS.
|
|
2
3
|
// See docs/workflow-findings-sidecar.md (Run Telemetry).
|
|
3
4
|
//
|
|
@@ -155,6 +156,8 @@ export function validateRunRecord(record) {
|
|
|
155
156
|
if (record.identity != null) {
|
|
156
157
|
if (typeof record.identity !== "object" || Array.isArray(record.identity)) {
|
|
157
158
|
add("record.identity", "identity must be an object when present.");
|
|
159
|
+
} else if (record.identity.local_spec_id != null && resolveCampaignIdentity(record.identity)?.kind !== "local_spec") {
|
|
160
|
+
add("record.identity.local_spec_id", "local_spec_id must be a canonical local ID with no saved Map identity.");
|
|
158
161
|
}
|
|
159
162
|
}
|
|
160
163
|
|
|
@@ -449,6 +452,7 @@ export function selectRunFindingIds(journal, runId) {
|
|
|
449
452
|
function normalizeIdentity(identity = {}) {
|
|
450
453
|
return {
|
|
451
454
|
map_id: identity.map_id ?? null,
|
|
455
|
+
...localSpecIdentityFields(identity),
|
|
452
456
|
campaign_slug: identity.campaign_slug ?? null,
|
|
453
457
|
template_family: identity.template_family ?? null,
|
|
454
458
|
entry_point_shape: identity.entry_point_shape ?? null,
|
package/src/sidecar-bundle.mjs
CHANGED
|
@@ -84,6 +84,27 @@ function identityValuesAgree(identityField, left, right) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function compareIdentity(errors, records, identityField) {
|
|
87
|
+
const local = identityField.local_spec_alternative;
|
|
88
|
+
if (local && records.get("build_packet")?.value?.spec?.local_spec_id != null) {
|
|
89
|
+
compareIdentity(errors, records, local);
|
|
90
|
+
// A local identity can never borrow a Map identity from another artifact.
|
|
91
|
+
for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
|
|
92
|
+
if (kind === "qa_verdict") continue; // campaign_slug is the local QA storage key.
|
|
93
|
+
if (valueAt(records.get(kind)?.value, path) != null) {
|
|
94
|
+
errors.push(artifactFinding("bundle.identity.map_id_mismatch", kind,
|
|
95
|
+
"Local-spec bundle carries a saved Map identity.", "Regenerate artifacts from the same local CampaignSpec."));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (local) {
|
|
101
|
+
for (const [kind, path] of Object.entries(local.artifact_paths)) {
|
|
102
|
+
if (valueAt(records.get(kind)?.value, path) != null) {
|
|
103
|
+
errors.push(artifactFinding("bundle.identity.local_spec_id_mismatch", kind,
|
|
104
|
+
"Saved-Map bundle carries local-spec evidence.", "Regenerate artifacts from the same saved Map."));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
87
108
|
const label = identityField.name;
|
|
88
109
|
const present = [];
|
|
89
110
|
for (const [kind, path] of Object.entries(identityField.artifact_paths)) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Stable campaign identity is separate from both the public route and the
|
|
2
|
+
// material spec hash. Local IDs never identify a saved Map or a portal URL.
|
|
3
|
+
export const LOCAL_SPEC_ID_PATTERN = "^[A-Za-z0-9_-]{1,64}$";
|
|
4
|
+
const localIdPattern = new RegExp(LOCAL_SPEC_ID_PATTERN);
|
|
5
|
+
const text = value => typeof value === "string" && value.trim() ? value.trim() : null;
|
|
6
|
+
|
|
7
|
+
export function campaignSpecIdentity(spec) {
|
|
8
|
+
return {
|
|
9
|
+
map_id: spec?.spec_identity?.map_id ?? spec?.map_id ?? null,
|
|
10
|
+
local_spec_id: spec?.spec_identity?.local_spec_id ?? null,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveCampaignIdentity(fields) {
|
|
15
|
+
// Saved Map IDs retain their existing whitespace normalization. Local IDs
|
|
16
|
+
// are canonical, repository-owned tokens: never trim one into another ID.
|
|
17
|
+
const mapId = text(fields?.map_id);
|
|
18
|
+
const localId = fields?.local_spec_id;
|
|
19
|
+
if (localId != null) {
|
|
20
|
+
if (fields?.map_id != null || typeof localId !== "string" || !localIdPattern.test(localId)) return null;
|
|
21
|
+
return { kind: "local_spec", id: localId };
|
|
22
|
+
}
|
|
23
|
+
return mapId ? { kind: "saved_map", id: mapId } : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function campaignIdentitiesMatch(left, right) {
|
|
27
|
+
const a = resolveCampaignIdentity(left);
|
|
28
|
+
const b = resolveCampaignIdentity(right);
|
|
29
|
+
return !!a && !!b && a.kind === b.kind && a.id === b.id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function localSpecIdentityFields(fields) {
|
|
33
|
+
if (fields?.local_spec_id == null) return {};
|
|
34
|
+
const identity = resolveCampaignIdentity(fields);
|
|
35
|
+
// Omitting a malformed marker would let a conflicting identity fall back to
|
|
36
|
+
// its Map ID. Writers must refuse it rather than silently change its kind.
|
|
37
|
+
if (identity?.kind !== "local_spec") throw new Error("Invalid local_spec_id or conflicting saved Map identity.");
|
|
38
|
+
return { local_spec_id: identity.id };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function localQaIdentifier(localSpecId) {
|
|
42
|
+
if (typeof localSpecId !== "string" || !localIdPattern.test(localSpecId)) throw new Error("Invalid local_spec_id.");
|
|
43
|
+
return `local-spec-${localSpecId}`;
|
|
44
|
+
}
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { campaignIdentitiesMatch } from "./spec-source-identity.mjs";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { markDoctorSidecarStale, writeDoctorSidecar, writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
3
4
|
import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
@@ -377,7 +378,9 @@ export function qaGatePassedForCurrentBuild(report, gate, { buildFingerprint })
|
|
|
377
378
|
*/
|
|
378
379
|
export function assemblyReportMatchesPacket(report, packet) {
|
|
379
380
|
return isPlainObject(report)
|
|
380
|
-
&&
|
|
381
|
+
&& (report?.identity?.local_spec_id != null || packet?.spec?.local_spec_id != null
|
|
382
|
+
? campaignIdentitiesMatch(report?.identity, packet?.spec)
|
|
383
|
+
: optionalString(report?.identity?.map_id) === optionalString(packet?.spec?.map_id))
|
|
381
384
|
&& optionalString(report?.identity?.public_route_slug) === optionalString(packet?.campaign?.public_route_slug);
|
|
382
385
|
}
|
|
383
386
|
|