@nextcommerce/campaigns-os 1.37.3 → 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/AGENTS.md +114 -10
- package/CHANGELOG.md +708 -0
- package/README.md +44 -31
- package/agents/claude/CLAUDE.md +5 -1
- package/campaign-spec/dist/types.d.ts +2 -0
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4887 -0
- package/contracts/migration-sidecar-bundle.v0.json +9 -0
- package/contracts/release-ledger.json +1541 -0
- package/contracts/supported-surface.json +33 -12
- package/docs/build-packet.md +83 -22
- package/docs/campaigns-os-build-flow.md +2 -2
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +350 -0
- package/docs/gateway-login.md +113 -0
- package/docs/local-setup.md +51 -0
- package/docs/migration-sidecar-bundle.md +6 -1
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +9 -3
- package/docs/qa-and-test-orders.md +29 -13
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +10 -4
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +6 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +11 -1
- package/schemas/campaign-spec.v4.schema.json +4 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -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-readback.v2.schema.json +267 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +1 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +179 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +142 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +59 -22
- package/skills/next-campaigns-os/references/session-intake.md +4 -4
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +53 -28
- package/skills.json +40 -7
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +1178 -270
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/finding-cause.mjs +14 -10
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +96 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- 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 +105 -36
- package/src/qa-publish.mjs +112 -2
- 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 +1937 -0
- package/src/remit.mjs +17 -3
- 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/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";
|
|
@@ -60,6 +61,10 @@ import {
|
|
|
60
61
|
import { resolveConsent } from "./consent.mjs";
|
|
61
62
|
import { markDoctorSidecarStale } from "./doctor-sidecar.mjs";
|
|
62
63
|
import { commitAssemblyReport } from "./stage-ledger.mjs";
|
|
64
|
+
// Tags a refusal raised before a qa handler runs, so the CLI's lifecycle
|
|
65
|
+
// persist step suppresses the journal append from one place. lifecycle.mjs
|
|
66
|
+
// imports nothing from this repository, so this cannot be circular.
|
|
67
|
+
import { refused, refusing } from "./lifecycle.mjs";
|
|
63
68
|
import { campaignSidecarPaths, explicitReportPath, resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
64
69
|
import { loadParityFixture } from "./qa-parity-fixture.mjs";
|
|
65
70
|
import { assessParityCapture, resolveParityScenario, runParityCapture } from "./qa-parity-capture.mjs";
|
|
@@ -99,7 +104,7 @@ Usage:
|
|
|
99
104
|
campaigns-os qa policy set --packet <campaign-runtime.build.json> [--allowed-domains-confirmed true|false] [--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--order-path-depth <off|common|full>] [--json]
|
|
100
105
|
campaigns-os qa waive --packet <campaign-runtime.build.json> --assertion analytics-correctness:purchase-fires --reason "<why>" [--waived-by <who>] [--report <assembly-report.json>] [--json]
|
|
101
106
|
campaigns-os qa promote --packet <campaign-runtime.build.json> --verdict <full-verdict.json> [--json] # project one explicit qa-output verdict to the committed .campaign-runtime/qa-verdict.json sidecar
|
|
102
|
-
campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
|
|
107
|
+
campaigns-os qa publish --packet <campaign-runtime.build.json> [--verdict <full-verdict.json>] [--republish] [--proxy-base <url>] [--dry-run] [--json] # post an already-stored verdict to the QA portal; no re-run, no orders
|
|
103
108
|
campaigns-os qa resolve <map-id> --spec <campaign-spec.json> [--base-url <url>]
|
|
104
109
|
campaigns-os qa run <map-id> --spec <campaign-spec.json> --base-url <url>
|
|
105
110
|
campaigns-os qa run --site <page-kit-target-repo> --base-url <url> --family <family> [--slug <slug>] [--browser] # L7: QA a built _site/ with no packet/spec
|
|
@@ -146,6 +151,10 @@ Options:
|
|
|
146
151
|
per the Run Record (already_published), an untrusted one, or one for another
|
|
147
152
|
campaign. Exit 2 on a refusal, 1 on a failed post, 0 when published.
|
|
148
153
|
--republish qa publish: post a verdict its Run Record already records as published.
|
|
154
|
+
--dry-run qa publish: run every refusal check and print what would be posted (endpoint,
|
|
155
|
+
verdict run id, payload bytes) without the POST. Nothing is sent and the Run
|
|
156
|
+
Record is not stamped; a refusal still exits 2, a clean dry run exits 0
|
|
157
|
+
(--json: dry_run, would_publish, would_post).
|
|
149
158
|
--no-remit When an ambient run session is active, write the local Run Record but skip Run Telemetry remit.
|
|
150
159
|
--auth-cookie <cookie> Cookie header for protected previews.
|
|
151
160
|
--browser Run Playwright-rendered browser checks after static Node checks.
|
|
@@ -250,7 +259,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
|
|
|
250
259
|
return result;
|
|
251
260
|
}
|
|
252
261
|
if (subcommand === "policy") {
|
|
253
|
-
if (args._[2] !== "set") throw
|
|
262
|
+
if (args._[2] !== "set") throw refused(`Unknown qa policy command. Use: ${cmd("qa")} policy set --packet <campaign-runtime.build.json>`);
|
|
254
263
|
const result = updateQaPolicy(args);
|
|
255
264
|
output(result, args);
|
|
256
265
|
return result;
|
|
@@ -281,7 +290,7 @@ export async function runQaCli(args, { ambient = null } = {}) {
|
|
|
281
290
|
process.exitCode = result.ok ? 0 : 1;
|
|
282
291
|
return result;
|
|
283
292
|
}
|
|
284
|
-
throw
|
|
293
|
+
throw refused(`Unknown qa command: ${subcommand}`);
|
|
285
294
|
}
|
|
286
295
|
|
|
287
296
|
// The package-owned browser install, runnable from any install mode. It is
|
|
@@ -337,8 +346,19 @@ async function resolveQaInputs(args, {
|
|
|
337
346
|
readJsonFile = readJson,
|
|
338
347
|
loadCampaignEntry = loadPageKitCampaignEntry,
|
|
339
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
|
+
}
|
|
340
356
|
if (args.packet && args.spec) {
|
|
341
|
-
throw
|
|
357
|
+
throw refused("Packet QA does not accept --spec; it always uses packet.spec.local_path.");
|
|
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>.");
|
|
342
362
|
}
|
|
343
363
|
// Non-packet mode (learnings L7): QA a `campaign-build`'d page-kit campaign
|
|
344
364
|
// that has only a built _site/ and a served URL — no Build Packet, no Map ID,
|
|
@@ -363,7 +383,13 @@ async function resolveQaInputs(args, {
|
|
|
363
383
|
const mapId = stringArg(args["map-id"])
|
|
364
384
|
|| stringArg(args._[2])
|
|
365
385
|
|| stringArg(packet?.spec?.map_id);
|
|
366
|
-
|
|
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
|
+
}
|
|
367
393
|
|
|
368
394
|
const proxyBase = stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE;
|
|
369
395
|
const inputBaseUrl = normalizeBaseUrl(stringArg(args["base-url"]) || packet?.deploy?.preview_url || packet?.deploy?.production_url || null);
|
|
@@ -388,6 +414,13 @@ async function resolveQaInputs(args, {
|
|
|
388
414
|
specSource = `${proxyBase.replace(/\/+$/, "")}/api/spec/${encodeURIComponent(mapId)}`;
|
|
389
415
|
}
|
|
390
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
|
+
}
|
|
391
424
|
const normalized = normalizeSpec(rawSpec);
|
|
392
425
|
const publicRouteSlug = resolvePublicRouteSlug({ packet, spec: normalized, rawSpec });
|
|
393
426
|
const baseUrl = normalizeQaBaseUrl(inputBaseUrl, publicRouteSlug);
|
|
@@ -433,6 +466,7 @@ async function resolveQaInputs(args, {
|
|
|
433
466
|
packetPath,
|
|
434
467
|
packet,
|
|
435
468
|
mapId,
|
|
469
|
+
localSpecId,
|
|
436
470
|
publicRouteSlug,
|
|
437
471
|
proxyBase,
|
|
438
472
|
baseUrl,
|
|
@@ -462,6 +496,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
462
496
|
} = {}) {
|
|
463
497
|
const packetPath = resolve(String(args.packet));
|
|
464
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
|
+
}
|
|
465
503
|
const specPath = stringArg(args.spec)
|
|
466
504
|
? resolve(String(args.spec))
|
|
467
505
|
: stringArg(packet?.spec?.local_path)
|
|
@@ -497,6 +535,10 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
497
535
|
report = null;
|
|
498
536
|
}
|
|
499
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
|
+
}
|
|
500
542
|
const checkpointGates = [
|
|
501
543
|
evaluatePageKitStoreProfile({
|
|
502
544
|
specCampaign: rawSpec?.campaign || null,
|
|
@@ -532,12 +574,9 @@ function resolvePacketCheckpointPreflight(args, {
|
|
|
532
574
|
|
|
533
575
|
function reportMatchesPacketIdentity(report, packet) {
|
|
534
576
|
if (!isPlainObject(report) || !isPlainObject(report.identity)) return false;
|
|
535
|
-
const packetMapId = stringArg(packet?.spec?.map_id);
|
|
536
|
-
const reportMapId = stringArg(report.identity.map_id);
|
|
537
577
|
const packetSlug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
538
578
|
const reportSlug = normalizePublicRouteSlug(report.identity.public_route_slug);
|
|
539
|
-
return
|
|
540
|
-
&& packetMapId === reportMapId
|
|
579
|
+
return campaignIdentitiesMatch(packet?.spec, report.identity)
|
|
541
580
|
&& !!packetSlug
|
|
542
581
|
&& packetSlug === reportSlug;
|
|
543
582
|
}
|
|
@@ -624,7 +663,8 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
624
663
|
brandContractStatus: "not_evaluated",
|
|
625
664
|
packetPath: preflight.packetPath,
|
|
626
665
|
packet: preflight.packet,
|
|
627
|
-
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,
|
|
628
668
|
publicRouteSlug,
|
|
629
669
|
proxyBase: stringArg(args["proxy-base"]) || DEFAULT_PROXY_BASE,
|
|
630
670
|
baseUrl: inputBaseUrl,
|
|
@@ -650,18 +690,18 @@ function resolvedFromBlockedCheckpointPreflight(preflight, args) {
|
|
|
650
690
|
// yields "not_applicable" rather than blocking, so browser QA still runs the
|
|
651
691
|
// residue/placeholder/demo gates. Test orders are not attempted (no policy).
|
|
652
692
|
export function resolveQaInputsFromSite(args) {
|
|
653
|
-
const targetRepo = resolve(String(args.site || args.built));
|
|
654
|
-
const scope = resolveBuiltSiteScope(targetRepo, { slug: stringArg(args.slug) });
|
|
655
|
-
if (!scope.ok) {
|
|
656
|
-
throw new Error(scope.error || `Could not resolve a built campaign from ${targetRepo}.`);
|
|
657
|
-
}
|
|
658
693
|
const baseUrl = normalizeBaseUrl(stringArg(args["base-url"]));
|
|
659
694
|
if (!baseUrl) {
|
|
660
|
-
throw
|
|
695
|
+
throw refused("Non-packet site QA requires --base-url <served-campaign-root> so built pages have a fetchable URL.");
|
|
661
696
|
}
|
|
662
697
|
const templateFamily = stringArg(args.family);
|
|
663
698
|
if (!templateFamily) {
|
|
664
|
-
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}.`);
|
|
665
705
|
}
|
|
666
706
|
const brandContract = loadBrandContract(templateFamily);
|
|
667
707
|
if (brandContract.status !== "loaded" || !brandContract.contract) {
|
|
@@ -1490,6 +1530,7 @@ function resolvePayload(resolved, { routeProbe = null } = {}) {
|
|
|
1490
1530
|
ok: status !== "blocked" && status !== "routes_unresolved",
|
|
1491
1531
|
status,
|
|
1492
1532
|
map_id: resolved.mapId,
|
|
1533
|
+
...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
|
|
1493
1534
|
...(resolved.packetPath ? { packet_path: resolved.packetPath } : {}),
|
|
1494
1535
|
...reportPathField(resolved),
|
|
1495
1536
|
...(resolved.proxyBase && resolved.proxyBase !== DEFAULT_PROXY_BASE ? { proxy_base: resolved.proxyBase } : {}),
|
|
@@ -1726,7 +1767,7 @@ const REMOVED_QA_POLICY_FLAGS = ["test-orders-allowed", "sandbox-test-card-confi
|
|
|
1726
1767
|
|
|
1727
1768
|
function updateQaPolicy(args) {
|
|
1728
1769
|
const packetPath = args.packet ? resolve(args.packet) : null;
|
|
1729
|
-
if (!packetPath) throw
|
|
1770
|
+
if (!packetPath) throw refused("qa policy set requires --packet <campaign-runtime.build.json>.");
|
|
1730
1771
|
const packet = readJson(packetPath);
|
|
1731
1772
|
packet.campaign ||= {};
|
|
1732
1773
|
packet.deploy ||= {};
|
|
@@ -1735,12 +1776,15 @@ function updateQaPolicy(args) {
|
|
|
1735
1776
|
// Test Orders have no permission flag. The two flags that once set one
|
|
1736
1777
|
// were removed with their packet fields in supported surface 1.28.0; a
|
|
1737
1778
|
// script still passing them gets told so instead of a silent no-op.
|
|
1779
|
+
// This check and the setOptional* value checks below run after reading only
|
|
1780
|
+
// the packet and before the packet write: refusals, so they journal nothing.
|
|
1738
1781
|
const removedFlags = REMOVED_QA_POLICY_FLAGS.filter((flag) => flag in args);
|
|
1739
1782
|
if (removedFlags.length) {
|
|
1740
|
-
throw
|
|
1783
|
+
throw refused(`qa policy set: ${removedFlags.map((flag) => `--${flag}`).join(" and ")} ${removedFlags.length > 1 ? "were" : "was"} removed in supported surface 1.28.0 (test orders run from --test-order <mode> alone; there is no permission flag). Drop the flag${removedFlags.length > 1 ? "s" : ""}. Accepted: --allowed-domains-confirmed, --deploy-target, --preview-url, --production-url, --order-path-depth.`);
|
|
1741
1784
|
}
|
|
1742
|
-
// Validated with the other argv checks, before anything is written
|
|
1743
|
-
|
|
1785
|
+
// Validated with the other argv checks, before anything is written: a
|
|
1786
|
+
// refusal at this call site, like the removed-flag check above.
|
|
1787
|
+
const orderPathDepth = refusing(() => parseOrderPathDepthFlag(args, { command: "qa policy set" }));
|
|
1744
1788
|
|
|
1745
1789
|
const changed = [];
|
|
1746
1790
|
setOptionalBoolean(packet.campaign, "allowed_domains_confirmed", args, "allowed-domains-confirmed", changed);
|
|
@@ -1810,21 +1854,24 @@ const WAIVABLE_QA_ASSERTIONS = Object.freeze(["analytics-correctness:purchase-fi
|
|
|
1810
1854
|
// report.theme.waiver: { reason, waived_by, waived_at }.
|
|
1811
1855
|
export function qaWaive(args) {
|
|
1812
1856
|
const packetPath = args.packet ? resolve(args.packet) : null;
|
|
1813
|
-
if (!packetPath) throw
|
|
1857
|
+
if (!packetPath) throw refused("qa waive requires --packet <campaign-runtime.build.json>.");
|
|
1814
1858
|
const packet = readJson(packetPath);
|
|
1859
|
+
// The three flag checks run after reading only argv and the packet, ahead of
|
|
1860
|
+
// the report lookup: refusals, so they journal nothing. The missing report
|
|
1861
|
+
// below is a handler failure and is journaled.
|
|
1815
1862
|
const assertionId = stringArg(args.assertion);
|
|
1816
1863
|
if (!assertionId) {
|
|
1817
|
-
throw
|
|
1864
|
+
throw refused(`qa waive requires --assertion <id>. Waivable assertions: ${WAIVABLE_QA_ASSERTIONS.join(", ")}.`);
|
|
1818
1865
|
}
|
|
1819
1866
|
if (!WAIVABLE_QA_ASSERTIONS.includes(assertionId)) {
|
|
1820
|
-
throw
|
|
1867
|
+
throw refused(
|
|
1821
1868
|
`qa waive does not accept --assertion "${assertionId}". The waiver lane is scoped to exactly: ${WAIVABLE_QA_ASSERTIONS.join(", ")}. `
|
|
1822
1869
|
+ "Extending the lane to another assertion is a design decision, not a flag.",
|
|
1823
1870
|
);
|
|
1824
1871
|
}
|
|
1825
1872
|
const reason = stringArg(args.reason);
|
|
1826
1873
|
if (!reason) {
|
|
1827
|
-
throw
|
|
1874
|
+
throw refused("qa waive requires --reason \"<why this failing blocker is acceptable for this campaign>\".");
|
|
1828
1875
|
}
|
|
1829
1876
|
const workspace = resolveCampaignWorkspace(packetPath, {
|
|
1830
1877
|
packet,
|
|
@@ -1998,15 +2045,25 @@ function parityReplayEvidence(bundle) {
|
|
|
1998
2045
|
return { order, capture, baselineCapture, orders: Array.isArray(bundle.orders) ? bundle.orders : [order] };
|
|
1999
2046
|
}
|
|
2000
2047
|
|
|
2048
|
+
// The up-front half of the `--max-order-creations` check. The validator is
|
|
2049
|
+
// SHARED with the order-creation budget, which every browser path builds after
|
|
2050
|
+
// a browser has launched and orders may already have been created; a throw from
|
|
2051
|
+
// there is a handler failure and must still be journaled, so the refusal tag
|
|
2052
|
+
// cannot live inside the validator. It goes here via `refusing()`, at the two
|
|
2053
|
+
// entries that check the flag before anything is resolved or launched, where a
|
|
2054
|
+
// bad value has cost the operator nothing. Message and exit code are the
|
|
2055
|
+
// validator's own.
|
|
2056
|
+
const refuseBadOrderCreationLimit = (args) => refusing(() => validatedOrderCreationLimit(args));
|
|
2057
|
+
|
|
2001
2058
|
async function runParityQa(args) {
|
|
2002
2059
|
// Checked here as well as on the budget itself: the budget is built after a
|
|
2003
2060
|
// browser has launched, and a flag the operator typed wrong should cost them
|
|
2004
2061
|
// nothing. The budget stays the authority — this is fail-fast, not the gate.
|
|
2005
|
-
|
|
2062
|
+
refuseBadOrderCreationLimit(args);
|
|
2006
2063
|
const fixturePath = stringArg(args.fixture);
|
|
2007
2064
|
const scenarioId = stringArg(args.scenario) || stringArg(args._[2]);
|
|
2008
|
-
if (!fixturePath) throw
|
|
2009
|
-
if (!scenarioId) throw
|
|
2065
|
+
if (!fixturePath) throw refused("QA parity requires --fixture <parity-fixture.json>.");
|
|
2066
|
+
if (!scenarioId) throw refused("QA parity requires --scenario <scenario-id>.");
|
|
2010
2067
|
|
|
2011
2068
|
const fixture = await loadParityFixture(resolve(fixturePath));
|
|
2012
2069
|
const scenario = resolveParityScenario(fixture, scenarioId);
|
|
@@ -2072,7 +2129,7 @@ async function runParityQa(args) {
|
|
|
2072
2129
|
async function runQa(args, options = {}) {
|
|
2073
2130
|
// Fail-fast before anything resolves or launches. The authoritative check
|
|
2074
2131
|
// lives on the creation budget itself, which every browser path builds.
|
|
2075
|
-
|
|
2132
|
+
refuseBadOrderCreationLimit(args);
|
|
2076
2133
|
const resolved = await resolveQaInputs(args);
|
|
2077
2134
|
return runResolvedQa(args, resolved, options);
|
|
2078
2135
|
}
|
|
@@ -2279,12 +2336,14 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2279
2336
|
baseDir: resolved.packetPath ? dirname(resolved.packetPath) : null,
|
|
2280
2337
|
targetRepo: resolved.packetPath ? targetRepoFor(resolved.packetPath, resolved.packet) : null,
|
|
2281
2338
|
mapId: resolved.mapId,
|
|
2339
|
+
localSpecId: resolved.localSpecId,
|
|
2282
2340
|
currentRunId: runId,
|
|
2283
2341
|
isFinding: isFindingAssertion,
|
|
2284
2342
|
});
|
|
2285
2343
|
const verdict = createVerdict({
|
|
2286
2344
|
runId,
|
|
2287
2345
|
mapId: resolved.mapId,
|
|
2346
|
+
localSpecId: resolved.localSpecId,
|
|
2288
2347
|
publicRouteSlug: resolved.publicRouteSlug || null,
|
|
2289
2348
|
campaignRefId: resolved.spec.campaign?.ref_id || null,
|
|
2290
2349
|
specVersion: resolved.specVersion,
|
|
@@ -2338,7 +2397,9 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2338
2397
|
// config, or scope mismatch should be visible on the QA path too, not just
|
|
2339
2398
|
// on remit (Kilo review, PR #177).
|
|
2340
2399
|
const consent = resolveConsent({ proxyBase: resolved.proxyBase });
|
|
2341
|
-
const publishDecision =
|
|
2400
|
+
const publishDecision = resolved.localSpecId
|
|
2401
|
+
? { publish: false, reason: "local_spec", flag_invalid: false }
|
|
2402
|
+
: decidePublishVerdict({ args, portalManaged: resolved.portalManaged === true, consent });
|
|
2342
2403
|
if (publishDecision.flag_invalid) {
|
|
2343
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`);
|
|
2344
2405
|
}
|
|
@@ -2358,6 +2419,7 @@ async function finalizeQaRun({ args, resolved, runId, startedAt, assertions, tes
|
|
|
2358
2419
|
status: verdict.disposition,
|
|
2359
2420
|
run_id: verdict.run_id,
|
|
2360
2421
|
map_id: resolved.mapId,
|
|
2422
|
+
...(resolved.localSpecId ? { local_spec_id: resolved.localSpecId } : {}),
|
|
2361
2423
|
public_route_slug: resolved.publicRouteSlug || null,
|
|
2362
2424
|
...reportPathField(resolved),
|
|
2363
2425
|
base_url: resolved.baseUrl,
|
|
@@ -2983,7 +3045,7 @@ function output(value, args) {
|
|
|
2983
3045
|
}
|
|
2984
3046
|
if (value.verdict) {
|
|
2985
3047
|
console.log(`QA run complete.`);
|
|
2986
|
-
console.log(
|
|
3048
|
+
console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
|
|
2987
3049
|
console.log(`Base URL: ${value.base_url || "(missing)"}`);
|
|
2988
3050
|
printEntryUrlLines(value.entry_urls);
|
|
2989
3051
|
console.log(`Run ID: ${value.run_id}`);
|
|
@@ -3016,7 +3078,7 @@ function output(value, args) {
|
|
|
3016
3078
|
}
|
|
3017
3079
|
console.log(`QA resolve complete.`);
|
|
3018
3080
|
console.log(`Status: ${value.status}`);
|
|
3019
|
-
console.log(
|
|
3081
|
+
console.log(`${value.local_spec_id ? "Local spec ID" : "Map ID"}: ${value.local_spec_id || value.map_id}`);
|
|
3020
3082
|
console.log(`Spec: ${value.spec_source}`);
|
|
3021
3083
|
console.log(`Base URL: ${value.base_url || "(missing)"}`);
|
|
3022
3084
|
printEntryUrlLines(value.entry_urls);
|
|
@@ -3166,7 +3228,9 @@ export function qaResolveNextProofLines(value) {
|
|
|
3166
3228
|
return [
|
|
3167
3229
|
`Next expected proof: ${qaRunCommandFromResolve(value)}`,
|
|
3168
3230
|
`Entry URL(s) resolved: ${formatEntryUrlsForProof(value.entry_urls)}`,
|
|
3169
|
-
|
|
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.",
|
|
3170
3234
|
];
|
|
3171
3235
|
}
|
|
3172
3236
|
|
|
@@ -3232,16 +3296,21 @@ function stringArg(value) {
|
|
|
3232
3296
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
3233
3297
|
}
|
|
3234
3298
|
|
|
3299
|
+
// setOptionalBoolean and setOptionalString serve `qa policy set` only, ahead of
|
|
3300
|
+
// its packet write, so a bad value there is a refusal. booleanArg itself stays
|
|
3301
|
+
// untagged: it is shared with `qa run`'s analytics leg, which calls it mid-run,
|
|
3302
|
+
// where a throw is a handler failure — so the tag goes on this call site
|
|
3303
|
+
// (`refusing()`), not in the validator.
|
|
3235
3304
|
function setOptionalBoolean(target, property, args, key, changed) {
|
|
3236
3305
|
if (!(key in args)) return;
|
|
3237
|
-
const value = booleanArg(args[key], key);
|
|
3306
|
+
const value = refusing(() => booleanArg(args[key], key));
|
|
3238
3307
|
setIfChanged(target, property, value, changed);
|
|
3239
3308
|
}
|
|
3240
3309
|
|
|
3241
3310
|
function setOptionalString(target, property, args, key, changed) {
|
|
3242
3311
|
if (!(key in args)) return;
|
|
3243
3312
|
const value = stringArg(args[key]);
|
|
3244
|
-
if (!value) throw
|
|
3313
|
+
if (!value) throw refused(`--${key} requires a value.`);
|
|
3245
3314
|
setIfChanged(target, property, value, changed);
|
|
3246
3315
|
}
|
|
3247
3316
|
|